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
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (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,434 @@
import {
AI_LICENSE_MODULE,
AI_SEARCH_PAGE_SIZE,
buildIntelligentSearchPipelineFilters,
generateOpenAICompatibleSearchAnswer,
listOpenAICompatibleModels,
MAX_SEARCH_ANSWER_MESSAGES,
MAX_SEARCH_ANSWER_TEXT_LENGTH,
MAX_SEARCH_FILTER_VALUES,
normalizeIntelligentSearchCandidates,
searchIntelligentPipeline,
type IntelligentSearchFilters,
type IntelligentSearchPipelineConfig,
type OpenAICompatibleProviderConfig,
type SearchAnswerMessage,
} from '@rocket.chat/ai-search';
import type {
AISearchAnswerMessage,
AISearchAnswerResult,
AISearchFilters,
AISearchModelOption,
AISearchResult,
AISearchStatus,
IAISearchService,
} from '@rocket.chat/core-services';
import { License, ServiceClass } from '@rocket.chat/core-services';
import { isBannedSubscription, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { serverFetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { settings } from '../../settings';
const logger = new Logger('AISearchService');
const fetchWithSsrfValidation = (url: string, options: Omit<ExtendedFetchOptions, 'allowList' | 'ignoreSsrfValidation'>) =>
serverFetch(url, {
...options,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
const asString = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '';
};
const toDate = (value?: string | Date): Date | undefined => {
if (!value) {
return undefined;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
const limitFilterValues = (values: string[] | undefined): string[] | undefined => {
if (!values) {
return undefined;
}
const limitedValues: string[] = [];
for (const value of values) {
if (value) {
limitedValues.push(value);
if (limitedValues.length === MAX_SEARCH_FILTER_VALUES) {
break;
}
}
}
return limitedValues;
};
const normalizeFilters = (filters: AISearchFilters = {}): IntelligentSearchFilters => ({
rid: filters.rid,
rids: limitFilterValues(filters.rids),
roomNames: limitFilterValues(filters.roomNames),
fromUsername: filters.fromUsername,
fromUsernames: limitFilterValues(filters.fromUsernames),
startDate: toDate(filters.startDate),
endDate: toDate(filters.endDate),
});
type OpenAICompatibleProviderSettings = Pick<OpenAICompatibleProviderConfig, 'baseUrl' | 'apiKey'> & {
model?: string;
};
export class AISearchService extends ServiceClass implements IAISearchService {
protected name = 'ai-search';
private getPipelineConfig(): IntelligentSearchPipelineConfig | undefined {
const baseUrl = settings.get<string>('AI_Intelligent_Search_Pipeline_Base_URL');
const pipelineId = settings.get<string>('AI_Intelligent_Search_Pipeline_ID');
const apiKey = settings.get<string>('AI_Intelligent_Search_API_Key');
const apiKeySecret = settings.get<string>('AI_Intelligent_Search_API_Key_Secret');
const queryTemplate = settings.get<string>('AI_Intelligent_Search_Query_Template');
const minimumSimilarityPercent = settings.get<number>('AI_Intelligent_Search_Min_Similarity_Percent');
const normalizedBaseUrl = asString(baseUrl);
const normalizedPipelineId = asString(pipelineId);
const normalizedApiKey = asString(apiKey);
const normalizedApiKeySecret = asString(apiKeySecret);
if (!normalizedBaseUrl || !normalizedPipelineId || !normalizedApiKey || !normalizedApiKeySecret) {
return undefined;
}
return {
baseUrl: normalizedBaseUrl,
pipelineId: normalizedPipelineId,
apiKey: normalizedApiKey,
apiKeySecret: normalizedApiKeySecret,
queryTemplate: asString(queryTemplate),
minimumSimilarityPercent: Number(minimumSimilarityPercent || 0),
};
}
private getAnswerProviderSettings(): OpenAICompatibleProviderSettings | undefined {
const baseUrl = settings.get<string>('AI_LLM_OpenAI_Base_URL');
const apiKey = settings.get<string>('AI_LLM_OpenAI_API_Key');
const model = settings.get<string>('AI_LLM_OpenAI_Model');
const normalizedBaseUrl = asString(baseUrl);
const normalizedApiKey = asString(apiKey);
const normalizedModel = asString(model);
if (!normalizedBaseUrl || !normalizedApiKey) {
return undefined;
}
return {
baseUrl: normalizedBaseUrl,
apiKey: normalizedApiKey,
model: normalizedModel,
};
}
private getAnswerProviderConfig(): OpenAICompatibleProviderConfig | undefined {
const provider = this.getAnswerProviderSettings();
if (!provider?.model) {
return undefined;
}
return {
name: 'OpenAI compatible',
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
model: provider.model,
};
}
async status(): Promise<AISearchStatus> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const answerGenerationEnabled = settings.get<boolean>('AI_Intelligent_Search_Answer_Enabled');
const pipelineConfig = this.getPipelineConfig();
const answerProviderConfig = this.getAnswerProviderConfig();
return {
hasIntelligentSearchLicense,
intelligentSearchEnabled: intelligentSearchEnabled === true,
intelligentSearchConfigured: Boolean(pipelineConfig),
answerGenerationConfigured: answerGenerationEnabled === true && Boolean(answerProviderConfig) && Boolean(pipelineConfig),
};
}
private async getRoomMap(roomIds: string[]): Promise<Map<string, Pick<IRoom, '_id' | 't' | 'name' | 'fname'>>> {
if (!roomIds.length) {
return new Map();
}
const rooms = await Rooms.findByIds([...new Set(roomIds)], {
projection: { _id: 1, t: 1, name: 1, fname: 1 },
}).toArray();
return new Map(rooms.map((room) => [room._id, room]));
}
private async getUserSubscribedRoomIdsForPipeline(userId: string): Promise<string[]> {
const roomIds = await Subscriptions.findByUserId(userId, {
projection: { rid: 1, status: 1 },
})
.map((subscription) => (isBannedSubscription(subscription) ? undefined : subscription.rid))
.toArray();
return roomIds.filter((rid): rid is string => Boolean(rid));
}
private async getSubscribedRoomIdSet(userId: string, roomIds: string[]): Promise<Set<string>> {
const uniqueRoomIdSet = new Set<string>();
for (const roomId of roomIds) {
if (roomId) {
uniqueRoomIdSet.add(roomId);
}
}
const uniqueRoomIds = [...uniqueRoomIdSet];
if (!uniqueRoomIds.length) {
return new Set();
}
const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, {
projection: { rid: 1, status: 1 },
}).toArray();
const subscribedRoomIds = new Set<string>();
for (const subscription of subscriptions) {
if (!isBannedSubscription(subscription)) {
subscribedRoomIds.add(subscription.rid);
}
}
return subscribedRoomIds;
}
private async normalizeIntelligentResults(
rawSearchResults: unknown,
userId: string,
limit = AI_SEARCH_PAGE_SIZE,
): Promise<AISearchResult[]> {
const candidates = normalizeIntelligentSearchCandidates(rawSearchResults, [], limit, logger);
const msgIdSet = new Set<string>();
for (const { msgId } of candidates) {
if (msgId) {
msgIdSet.add(msgId);
}
}
const msgIds = [...msgIdSet];
let messageMap = new Map<string, Pick<IMessage, '_id' | 'rid' | 'msg' | 'ts' | 'u'>>();
if (msgIds.length > 0) {
messageMap = new Map(
await Messages.findVisibleByIds(msgIds, {
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
})
.map((message) => [String(message._id), message] as const)
.toArray(),
);
logger.debug({ msg: 'AI search messages fetched from DB', requested: msgIds.length, found: messageMap.size });
}
const resultRoomIds = Array.from(messageMap.values(), ({ rid }) => rid);
const [rooms, subscribedRoomIds] = await Promise.all([
this.getRoomMap(resultRoomIds),
this.getSubscribedRoomIdSet(userId, resultRoomIds),
]);
const normalizedResults: AISearchResult[] = [];
for (const result of candidates) {
// candidates without a visible database message could surface stale pipeline text
const dbMessage = result.msgId ? messageMap.get(result.msgId) : undefined;
if (!dbMessage) {
logger.debug({ msg: 'AI search result filtered: message not visible', msgId: result.msgId });
continue;
}
const { rid } = dbMessage;
if (!rid || !subscribedRoomIds.has(rid)) {
continue;
}
const room = rooms.get(rid);
normalizedResults.push({
_id: result.msgId || result._id,
rid,
msgId: result.msgId,
text: dbMessage.msg || '',
ts: dbMessage.ts?.toISOString(),
u: dbMessage.u ? { username: dbMessage.u.username, name: dbMessage.u.name } : undefined,
...(Number.isFinite(result.score) && { score: result.score }),
...(room && { room }),
});
if (normalizedResults.length === limit) {
break;
}
}
return normalizedResults;
}
private async getUserClassifications(userId: string): Promise<string[]> {
const user = await Users.findOneById<Pick<IUser, 'roles'>>(userId, { projection: { roles: 1 } });
return Array.from(new Set(['user', ...(user?.roles || [])]));
}
private async getSubscribedRoomIds(userId: string, roomIds: string[]): Promise<string[]> {
return [...(await this.getSubscribedRoomIdSet(userId, roomIds))];
}
private async getSubscribedRoomIdsByName(userId: string, roomNames: string[] = []): Promise<string[]> {
if (!roomNames.length) {
return [];
}
const rooms = await Promise.all(
Array.from(new Set(roomNames)).map((roomName) => Rooms.findOneByNameOrFname(roomName, { projection: { _id: 1 } })),
);
const roomIds = rooms.reduce<string[]>((roomIds, room) => {
if (typeof room?._id === 'string') {
roomIds.push(room._id);
}
return roomIds;
}, []);
return this.getSubscribedRoomIds(userId, roomIds);
}
async search({
query,
userId,
filters: rawFilters,
limit = AI_SEARCH_PAGE_SIZE,
}: {
query: string;
userId: string;
filters?: AISearchFilters;
limit?: number;
}): Promise<AISearchResult[]> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const config = this.getPipelineConfig();
if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || !config) {
logger.debug({
msg: 'AI search skipped: unavailable',
hasIntelligentSearchLicense,
intelligentSearchEnabled: intelligentSearchEnabled === true,
intelligentSearchConfigured: Boolean(config),
});
return [];
}
const filters = normalizeFilters(rawFilters);
const requestedRoomIds = [...new Set([...(filters.rids || []), ...(filters.rid ? [filters.rid] : [])])];
const roomNameIds = await this.getSubscribedRoomIdsByName(userId, filters.roomNames);
const scopedRoomIds = [...new Set([...requestedRoomIds, ...roomNameIds])];
// a room filter that resolves to no subscribed rooms must not fall through to the unscoped path
if (filters.roomNames?.length && !scopedRoomIds.length) {
logger.debug({ msg: 'AI search skipped: room name filters did not resolve to subscribed rooms' });
return [];
}
const [pipelineRoomIds, classifications] = await Promise.all([
scopedRoomIds.length ? this.getSubscribedRoomIds(userId, scopedRoomIds) : this.getUserSubscribedRoomIdsForPipeline(userId),
this.getUserClassifications(userId),
]);
if (!pipelineRoomIds.length) {
logger.debug({ msg: 'AI search skipped: user has no room subscriptions' });
return [];
}
const pipelineFilters = buildIntelligentSearchPipelineFilters(pipelineRoomIds, {
...filters,
rids: [...(filters.rids || []), ...roomNameIds],
});
if (!pipelineFilters) {
logger.debug({ msg: 'AI search skipped: no subscribed rooms for filters', rid: filters.rid });
return [];
}
const json = await searchIntelligentPipeline({
query,
config,
classifications,
pipelineFilters,
limit,
fetch: fetchWithSsrfValidation,
logger,
});
return this.normalizeIntelligentResults(json, userId, limit);
}
async answer({ query, messages }: { query: string; messages: AISearchAnswerMessage[] }): Promise<AISearchAnswerResult> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const answerGenerationEnabled = settings.get<boolean>('AI_Intelligent_Search_Answer_Enabled');
const pipelineConfig = this.getPipelineConfig();
const provider = this.getAnswerProviderConfig();
const systemPromptSetting = settings.get<string>('AI_Intelligent_Search_Answer_System_Prompt');
if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || answerGenerationEnabled !== true || !pipelineConfig) {
throw new Error('error-ai-not-enabled');
}
if (!provider) {
throw new Error('error-ai-provider-not-configured');
}
const systemPrompt = asString(systemPromptSetting);
const sanitizedMessages: SearchAnswerMessage[] = messages.map(({ text, username, roomName, ts, score }) => ({
text,
username,
roomName,
ts,
score,
}));
return generateOpenAICompatibleSearchAnswer({
query,
messages: sanitizedMessages,
provider,
systemPrompt,
fetch: fetchWithSsrfValidation,
logger,
maxMessages: MAX_SEARCH_ANSWER_MESSAGES,
maxTextLength: MAX_SEARCH_ANSWER_TEXT_LENGTH,
});
}
async models(): Promise<AISearchModelOption[]> {
if (!(await License.hasModule(AI_LICENSE_MODULE))) {
return [];
}
const provider = this.getAnswerProviderSettings();
const selectedModel = settings.get<string>('AI_LLM_OpenAI_Model');
return listOpenAICompatibleModels({
provider,
selectedModel: asString(selectedModel),
fetch: fetchWithSsrfValidation,
logger,
});
}
}

View File

@@ -0,0 +1,22 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IAnalyticsService } from '@rocket.chat/core-services';
import type { IAnalyticsSeatRequest } from '@rocket.chat/core-typings';
import { Analytics } from '@rocket.chat/models';
export class AnalyticsService extends ServiceClassInternal implements IAnalyticsService {
protected name = 'analytics';
async saveSeatRequest(): Promise<void> {
await Analytics.update({ type: 'seat-request' }, { $inc: { count: 1 } }, { upsert: true });
}
async getSeatRequestCount(): Promise<number> {
const result = (await Analytics.findOne({ type: 'seat-request' }, {})) as IAnalyticsSeatRequest | null;
return result?.count ? result.count : 0;
}
async resetSeatRequestCount(): Promise<void> {
await Analytics.update({ type: 'seat-request' }, { $set: { count: 0 } }, { upsert: true });
}
}

View File

@@ -0,0 +1,250 @@
import { Apps, AppEvents } from '@rocket.chat/apps';
import type { IGetAppsFilter } from '@rocket.chat/apps/dist/server/IGetAppsFilter';
import type { IAppStorageItem } from '@rocket.chat/apps/dist/server/storage/IAppStorageItem';
import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus';
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata';
import type { AppStatusReport, IAppsEngineService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { InstanceStatus } from '@rocket.chat/instance-status';
import { isRunningMs } from '../../lib/isRunningMs';
import { SystemLogger } from '../../lib/logger/system';
export class AppsEngineNoNodesFoundError extends Error {
constructor(message = 'Not enough Apps-Engine nodes in deployment') {
super(message);
}
}
export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService {
protected name = 'apps-engine';
constructor() {
super();
this.onEvent('presence.status', async ({ user, previousStatus }): Promise<void> => {
await Apps.self?.triggerEvent(AppEvents.IPostUserStatusChanged, {
user,
currentStatus: user.status,
previousStatus,
});
});
this.onEvent('apps.added', async (appId: string): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.added" event received for app',
appId,
});
// if the app already exists in this instance, don't load it again
const app = Apps.self?.getManager()?.getOneById(appId);
if (app) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.added" event received for app, but it already exists in this instance',
appId,
});
return;
}
await Apps.self?.getManager()?.addLocal(appId);
});
this.onEvent('apps.removed', async (appId: string): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.removed" event received for app',
appId,
});
const app = Apps.self?.getManager()?.getOneById(appId);
if (!app) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.removed" event received for app, but it could not be found in this instance',
appId,
});
return;
}
await Apps.self?.getManager()?.removeLocal(appId);
});
this.onEvent('apps.updated', async (appId: string, originInstanceId?: string): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.updated" event received for app',
appId,
});
if (originInstanceId && originInstanceId === InstanceStatus.id()) {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.updated" event ignored: originated from this instance',
appId,
});
return;
}
const storageItem = await Apps.self?.getStorage()?.retrieveOne(appId);
if (!storageItem) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.updated" event received for app, but it could not be found in the storage',
appId,
});
return;
}
const appPackage = await Apps.self?.getAppSourceStorage()?.fetch(storageItem);
if (!appPackage) {
return;
}
const isEnabled = AppStatusUtils.isEnabled(storageItem.status);
if (isEnabled) {
await Apps.self?.getManager()?.updateAndStartupLocal(storageItem, appPackage);
} else {
await Apps.self?.getManager()?.updateAndInitializeLocal(storageItem, appPackage);
}
});
this.onEvent('apps.statusUpdate', async (appId: string, status: AppStatus): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.statusUpdate" event received for app with status',
appId,
status,
});
const app = Apps.self?.getManager()?.getOneById(appId);
if (!app) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.statusUpdate" event received for app, but it could not be found in this instance',
appId,
status,
});
return;
}
if ((await app.getStatus()) === status) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.statusUpdate" event received for app, but the status is the same',
appId,
status,
});
return;
}
if (AppStatusUtils.isEnabled(status)) {
await Apps.self?.getManager()?.enable(appId).catch(SystemLogger.error);
} else if (AppStatusUtils.isDisabled(status)) {
await Apps.self?.getManager()?.disable(appId, status, true).catch(SystemLogger.error);
}
});
this.onEvent('apps.settingUpdated', async (appId: string, setting): Promise<void> => {
Apps.self?.getRocketChatLogger().debug({
msg: '"apps.settingUpdated" event received for app',
appId,
setting,
});
const app = Apps.self?.getManager()?.getOneById(appId);
const oldSetting = app?.getStorageItem().settings[setting.id].value;
// avoid updating the setting if the value is the same,
// which caused an infinite loop
// and sometimes the settings can be an array
// so we need to convert it to JSON stringified to compare it
if (JSON.stringify(oldSetting) === JSON.stringify(setting.value)) {
Apps.self?.getRocketChatLogger().info({
msg: '"apps.settingUpdated" event received for app, but the setting value is the same',
appId,
settingId: setting.id,
});
return;
}
await Apps.self
?.getManager()
?.getSettingsManager()
.updateAppSetting(appId, setting as any);
});
}
isInitialized(): boolean {
return Boolean(Apps.self?.isInitialized());
}
async getApps(query: IGetAppsFilter): Promise<IAppInfo[] | undefined> {
return (await Apps.self?.getManager()?.get(query))?.map((app) => app.getInfo());
}
async getAppStorageItemById(appId: string): Promise<IAppStorageItem | undefined> {
const app = Apps.self?.getManager()?.getOneById(appId);
if (!app) {
return;
}
return app.getStorageItem();
}
async getAppsStatusLocal(): Promise<{ status: AppStatus; appId: string }[]> {
const apps = await Apps.self?.getManager()?.get();
if (!apps) {
return [];
}
return Promise.all(
apps.map(async (app) => ({
status: await app.getStatus(),
appId: app.getID(),
})),
);
}
async getAppsStatusInNodes(): Promise<AppStatusReport> {
if (!isRunningMs()) {
throw new Error('Getting apps status in cluster is only available in microservices mode');
}
if (!this.api) {
throw new Error('AppsEngineService is not initialized');
}
// If we are running MS AND this.api is defined, we KNOW there is a local node
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
const { id: localNodeId } = (await this.api.nodeList()).find((node) => node.local)!;
const services: { name: string; nodes: string[] }[] = await this.api?.call('$node.services', { onlyActive: true });
// We can filter out the local node because we already know its status
const availableNodes = services?.find((service) => service.name === 'apps-engine')?.nodes;
// Subtract 1 for the local node
if (!availableNodes || availableNodes.length - 1 < 1) {
throw new AppsEngineNoNodesFoundError();
}
const statusByApp: AppStatusReport = {};
const apps: Promise<void>[] = availableNodes.map(async (nodeID) => {
const appsStatus: Awaited<ReturnType<typeof this.getAppsStatusLocal>> | undefined = await this.api?.call(
'apps-engine.getAppsStatusLocal',
[],
{ nodeID },
);
if (!appsStatus) {
throw new Error(`Failed to get apps status from node ${nodeID}`);
}
appsStatus.forEach(({ status, appId }) => {
if (!statusByApp[appId]) {
statusByApp[appId] = [];
}
statusByApp[appId].push({ instanceId: nodeID, isLocal: nodeID === localNodeId, status });
});
});
await Promise.all(apps);
return statusByApp;
}
}

View File

@@ -0,0 +1,139 @@
import { Authorization, License, Abac, Settings } from '@rocket.chat/core-services';
import type { RoomAccessValidator } from '@rocket.chat/core-services';
import { TeamType, AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings';
import type { IUser, ITeam, IRoom } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms, TeamMember, Team, Users } from '@rocket.chat/models';
import { canAccessRoomLivechat } from './canAccessRoomLivechat';
async function canAccessPublicRoom(user?: IUser): Promise<boolean> {
if (!user?._id) {
const anon = await Settings.get<boolean>('Accounts_AllowAnonymousRead');
return !!anon;
}
return Authorization.hasPermission(user, 'view-c-room');
}
type RoomAccessValidatorConverted = (
room?: Pick<IRoom, '_id' | 't' | 'teamId' | 'prid' | 'abacAttributes'>,
user?: IUser,
extraData?: Record<string, any>,
) => Promise<boolean>;
const roomAccessValidators: RoomAccessValidatorConverted[] = [
async function _validateAccessToPublicRoomsInTeams(room, user): Promise<boolean> {
if (!room) {
return false;
}
if (!room._id || !room.teamId || room.t !== 'c') {
// if the room doesn't belongs to a team || is not a public channel - skip
return false;
}
// if user is banned from this room, deny access
if (user?._id && (await Subscriptions.findOneBannedSubscription(room._id, user._id))) {
return false;
}
// if team is public, access is allowed if the user can access public rooms
const team = await Team.findOneById<Pick<ITeam, 'type'>>(room.teamId, {
projection: { type: 1 },
});
if (team?.type === TeamType.PUBLIC) {
return canAccessPublicRoom(user);
}
// otherwise access is allowed only to members of the team
const membership =
user?._id &&
(await TeamMember.findOneByUserIdAndTeamId(user._id, room.teamId, {
projection: { _id: 1 },
}));
return !!membership;
},
async function _validateAccessToPublicRooms(room, user): Promise<boolean> {
if (!room?._id || room.t !== 'c' || room?.teamId) {
return false;
}
// if user is banned from this room, deny access
if (user?._id && (await Subscriptions.findOneBannedSubscription(room._id, user._id))) {
return false;
}
return canAccessPublicRoom(user);
},
async function _validateIfAlreadyJoined(room, user, extraData): Promise<boolean> {
if (!room?._id || !user?._id) {
return false;
}
const [canViewJoined, canViewT] = await Promise.all([
Authorization.hasPermission(user, 'view-joined-room'),
Authorization.hasPermission(user, `view-${room.t}-room`),
]);
// When there's no ABAC setting, license or values on the room, fallback to previous behavior
if (!room?.abacAttributes?.length || !(await License.hasModule('abac')) || !(await Settings.get<boolean>('ABAC_Enabled'))) {
const includeInvitations = extraData?.includeInvitations ?? false;
if (!(await Subscriptions.countByRoomIdAndUserId(room._id, user._id, includeInvitations))) {
return false;
}
return canViewJoined || canViewT;
}
return (canViewJoined || canViewT) && Abac.canAccessObject(room, user, AbacAccessOperation.READ, AbacObjectType.ROOM);
},
async function _validateAccessToDiscussionsParentRoom(room, user): Promise<boolean> {
if (!room?.prid) {
return false;
}
const parentRoom = await Rooms.findOneById(room.prid);
if (!parentRoom) {
return false;
}
return Authorization.canAccessRoom(parentRoom, user);
},
canAccessRoomLivechat,
];
export const isPartialUser = (user: IUser | Pick<IUser, '_id'> | undefined): user is Pick<IUser, '_id'> => {
return Boolean(user && Object.keys(user).length === 1 && user._id);
};
export const canAccessRoom: RoomAccessValidator = async (room, user, extraData): Promise<boolean> => {
// TODO livechat can send both as null, so they we need to validate nevertheless
// if (!room || !user) {
// return false;
// }
// TODO: remove this after migrations
// if user only contains _id, convert it to a full IUser object
if (isPartialUser(user)) {
user = (await Users.findOneById(user._id)) || undefined;
if (!user) {
throw new Error('User not found');
}
if (process.env.NODE_ENV === 'development') {
console.log('User converted to full IUser object');
}
}
for await (const roomAccessValidator of roomAccessValidators) {
if (await roomAccessValidator(room, user as IUser, extraData)) {
return true;
}
}
return false;
};

View File

@@ -0,0 +1,31 @@
import type { IAuthorizationLivechat, RoomAccessValidator } from '@rocket.chat/core-services';
import { proxify } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Rooms } from '@rocket.chat/models';
const AuthorizationLivechat = proxify<IAuthorizationLivechat>('authorization-livechat');
export const canAccessRoomLivechat: RoomAccessValidator = async (room, user, extraData): Promise<boolean> => {
// If we received a partial room and its type is not `l` or `v`, skip all checks.
if (room && room.t !== 'l') {
return false;
}
// if we received a partial room, load the full IOmnichannelRoom data for it
// Otherwise, try to load the data from the extraData (this is the case for file uploads)
const rid = room?._id || extraData?.rid;
const livechatRoom = rid && (await Rooms.findOneById(rid));
// if the roomId is invalid skip checks and fail
if (!livechatRoom) {
return false;
}
// Check the type again in case the room parameter was not received
if (livechatRoom.t !== 'l') {
return false;
}
// Call back core temporarily
return AuthorizationLivechat.canAccessRoom(livechatRoom as IOmnichannelRoom, user, extraData);
};

View File

@@ -0,0 +1,25 @@
import type { RoomAccessValidator } from '@rocket.chat/core-services';
import { Authorization } from '@rocket.chat/core-services';
import { Subscriptions, Users } from '@rocket.chat/models';
import { canAccessRoom, isPartialUser } from './canAccessRoom';
export const canReadRoom: RoomAccessValidator = async (...args) => {
const [room, user] = args;
const userArgument = isPartialUser(user) ? await Users.findOneById(user._id) : user;
if (!(await canAccessRoom(...args))) {
return false;
}
if (
userArgument &&
room?.t === 'c' &&
!(await Authorization.hasPermission(userArgument, 'preview-c-room')) &&
!(await Subscriptions.findOneByRoomIdAndUserId(room?._id, userArgument._id, { projection: { _id: 1 } }))
) {
return false;
}
return true;
};

View File

@@ -0,0 +1,244 @@
import type { IAuthorization, RoomAccessValidator, UserWithRoles } from '@rocket.chat/core-services';
import { License, ServiceClass } from '@rocket.chat/core-services';
import type { IUser, IRole, IRoom, ISubscription } from '@rocket.chat/core-typings';
import { Subscriptions, Rooms, Users, Roles, Permissions } from '@rocket.chat/models';
import mem from 'mem';
import { canAccessRoom } from './canAccessRoom';
import { canReadRoom } from './canReadRoom';
import { AuthorizationUtils } from '../../../app/authorization/lib/AuthorizationUtils';
import './canAccessRoomLivechat';
// Register as class
export class Authorization extends ServiceClass implements IAuthorization {
protected name = 'authorization';
private getRolesCached = mem(this.getRoles.bind(this), {
maxAge: 1000,
cacheKey: (args: unknown[]) => {
const [user, scope] = args as [string | UserWithRoles, IRoom['_id']?];
return typeof user === 'string' ? `${user}/${scope ?? ''}` : `${user._id}/${scope ?? ''}/${JSON.stringify(user.roles ?? [])}`;
},
});
private rolesHasPermissionCached = mem(this.rolesHasPermission.bind(this), {
cacheKey: JSON.stringify,
...((process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') && { maxAge: 1 }),
});
constructor() {
super();
const clearCache = (): void => {
mem.clear(this.getRolesCached);
mem.clear(this.rolesHasPermissionCached);
};
this.onEvent('watch.roles', clearCache);
this.onEvent('permission.changed', clearCache);
this.onEvent('authorization.guestPermissions', (permissions: string[]) => {
AuthorizationUtils.addRolePermissionWhiteList('guest', permissions);
});
}
override async started(): Promise<void> {
try {
if (!(await License.hasValidLicense())) {
return;
}
const permissions = await License.getGuestPermissions();
if (!permissions) {
return;
}
AuthorizationUtils.addRolePermissionWhiteList('guest', permissions);
} catch (error) {
console.error('Authorization Service did not start correctly', error);
}
}
async hasAllPermission(userId: string | UserWithRoles, permissions: string[], scope?: string): Promise<boolean> {
if (!userId) {
return false;
}
return this.all(userId, permissions, scope);
}
async hasPermission(userId: string | UserWithRoles, permissionId: string, scope?: string): Promise<boolean> {
if (!userId) {
return false;
}
return this.all(userId, [permissionId], scope);
}
async hasAtLeastOnePermission(userId: string | UserWithRoles, permissions: string[], scope?: string): Promise<boolean> {
if (!userId) {
return false;
}
return this.atLeastOne(userId, permissions, scope);
}
async canAccessRoom(...args: Parameters<RoomAccessValidator>): Promise<boolean> {
return canAccessRoom(...args);
}
async canReadRoom(...args: Parameters<RoomAccessValidator>): Promise<boolean> {
return canReadRoom(...args);
}
async canAccessRoomId(rid: IRoom['_id'], user: IUser['_id']): Promise<boolean> {
const room = await Rooms.findOneById<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid' | 'abacAttributes'>>(rid, {
projection: {
_id: 1,
t: 1,
teamId: 1,
prid: 1,
abacAttributes: 1,
},
});
if (!room) {
return false;
}
return this.canAccessRoom(room, { _id: user });
}
async canAccessRoomIds(rids: IRoom['_id'][], user: UserWithRoles): Promise<boolean> {
const uniqueRids = [...new Set(rids)];
if (!uniqueRids.length) {
return false;
}
if (!user) {
return false;
}
const rooms = await Rooms.findByIds<Pick<IRoom, '_id' | 't' | 'teamId' | 'prid' | 'abacAttributes'>>(uniqueRids, {
projection: {
_id: 1,
t: 1,
teamId: 1,
prid: 1,
abacAttributes: 1,
},
}).toArray();
if (rooms.length !== uniqueRids.length) {
return false;
}
const allowed = await Promise.all(rooms.map((room) => this.canAccessRoom(room, user)));
return allowed.every(Boolean);
}
async getUsersFromPublicRoles(): Promise<
{
_id: string;
username: string;
roles: string[];
}[]
> {
const roleIds = await this.getPublicRoles();
return this.getUserFromRoles(roleIds);
}
private getPublicRoles = mem(
async (): Promise<string[]> => {
const roles = Roles.find<Pick<IRole, '_id'>>({ scope: 'Users', description: { $exists: true, $ne: '' } }, { projection: { _id: 1 } });
return roles.map(({ _id }) => _id).toArray();
},
{ maxAge: 10000 },
);
private getUserFromRoles = mem(
async (roleIds: string[]) => {
const users = Users.findUsersInRoles<Pick<Required<IUser>, '_id' | 'username' | 'roles'>>(roleIds, null, {
sort: {
username: 1,
},
projection: {
_id: 1,
username: 1,
roles: 1,
},
});
return users
.map((user) => ({
...user,
roles: user.roles.filter((roleId: string) => roleIds.includes(roleId)),
}))
.toArray();
},
{ maxAge: 10000 },
);
private async rolesHasPermission(permission: string, roles: IRole['_id'][]): Promise<boolean> {
if (AuthorizationUtils.isPermissionRestrictedForRoleList(permission, roles)) {
return false;
}
const result = await Permissions.findOne({ _id: permission, roles: { $in: roles } }, { projection: { _id: 1 } });
return !!result;
}
private async getRoles(user: string | UserWithRoles, scope?: IRoom['_id']): Promise<string[]> {
const { roles: userRoles = [] } = typeof user === 'string' ? (await Users.findOneById(user, { projection: { roles: 1 } })) || {} : user;
const { roles: subscriptionsRoles = [] } =
(scope &&
(await Subscriptions.findOne<Pick<ISubscription, 'roles'>>(
{ 'rid': scope, 'u._id': typeof user === 'string' ? user : user._id },
{ projection: { roles: 1 } },
))) ||
{};
return [...userRoles, ...subscriptionsRoles].sort((a, b) => a.localeCompare(b));
}
private async resolveRoles(user: string | UserWithRoles, scope?: IRoom['_id']): Promise<string[]> {
if (typeof user !== 'string' && !scope) {
return [...(user.roles ?? [])].sort((a, b) => a.localeCompare(b));
}
return this.getRolesCached(user, scope);
}
private async atLeastOne(user: string | UserWithRoles, permissions: string[] = [], scope?: string): Promise<boolean> {
const sortedRoles = await this.resolveRoles(user, scope);
for await (const permission of permissions) {
if (await this.rolesHasPermissionCached(permission, sortedRoles)) {
return true;
}
}
return false;
}
private async all(user: string | UserWithRoles, permissions: string[] = [], scope?: string): Promise<boolean> {
const sortedRoles = await this.resolveRoles(user, scope);
for await (const permission of permissions) {
if (!(await this.rolesHasPermissionCached(permission, sortedRoles))) {
return false;
}
}
return true;
}
async hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise<boolean> {
if (!Array.isArray(roleIds)) {
throw new Error('error-invalid-arguments');
}
if (!userId) {
return false;
}
return Roles.isUserInRoles(userId, roleIds, scope);
}
}

View File

@@ -0,0 +1,189 @@
import { randomUUID } from 'node:crypto';
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
import type { IBannerService } from '@rocket.chat/core-services';
import type { BannerPlatform, IBanner, IBannerDismiss, Optional, IUser } from '@rocket.chat/core-typings';
import { Banners, BannersDismiss, Users } from '@rocket.chat/models';
import { notifyOnUserChange } from '../../lib/notifyListener';
export class BannerService extends ServiceClassInternal implements IBannerService {
protected name = 'banner';
async getById(bannerId: string): Promise<null | IBanner> {
return Banners.findOneById(bannerId);
}
async discardDismissal(bannerId: string): Promise<boolean> {
const result = await Banners.findOneById(bannerId);
if (!result) {
return false;
}
const { _id, ...banner } = result;
const snapshot = await this.create({ ...banner, snapshot: _id, active: false }); // create a snapshot
await BannersDismiss.updateMany({ bannerId }, { $set: { bannerId: snapshot._id } });
return true;
}
async create(doc: Optional<IBanner, '_id' | '_updatedAt'>): Promise<IBanner> {
const bannerId = doc._id || randomUUID();
doc.view.appId = doc.view.appId ?? 'banner-core';
doc.view.viewId = bannerId;
await Banners.createOrUpdate({
...doc,
_id: bannerId,
});
const banner = await Banners.findOneById(bannerId);
if (!banner) {
throw new Error('error-creating-banner');
}
void this.sendToUsers(banner);
return banner;
}
async getBannersForUser(userId: string, platform: BannerPlatform, bannerId?: string): Promise<IBanner[]> {
const user = await Users.findOneById<Pick<IUser, 'roles'>>(userId, {
projection: { roles: 1 },
});
const { roles } = user || { roles: [] };
const banners = await Banners.findActiveByRoleOrId(roles, platform, bannerId).toArray();
const bannerIds = banners.map(({ _id }) => _id);
const result = await BannersDismiss.findByUserIdAndBannerId<Pick<IBannerDismiss, 'bannerId'>>(userId, bannerIds, {
projection: { bannerId: 1, _id: 0 },
}).toArray();
const dismissed = new Set(result.map(({ bannerId }) => bannerId));
return banners
.filter((banner) => !dismissed.has(banner._id))
.map((banner) => ({
...banner,
view: {
...banner.view,
// All modern banners should have a viewId, but we have old banners that were created without it
// such as the seatsTaken banner. In this case, we use the bannerId as the viewId
viewId: banner.view.viewId || banner._id,
},
// add surface to legacy banners
surface: !banner.surface ? 'banner' : banner.surface,
}));
}
async dismiss(userId: string, bannerId: string): Promise<boolean> {
if (!userId || !bannerId) {
throw new Error('Invalid params');
}
const banner = await Banners.findOneById(bannerId);
if (!banner) {
const { matchedCount } = await Users.setBannerReadById(userId, bannerId);
if (!matchedCount) {
throw new Error('Banner not found');
}
void notifyOnUserChange({
id: userId,
clientAction: 'updated',
diff: {
[`banners.${bannerId}.read`]: true,
},
});
return true;
}
const user = await Users.findOneById<Pick<IUser, 'username' | '_id'>>(userId, {
projection: { username: 1 },
});
if (!user) {
throw new Error('User not found');
}
const dismissedBy = {
_id: user._id,
username: user.username,
};
const today = new Date();
const doc = {
userId,
bannerId,
dismissedBy,
dismissedAt: today,
_updatedAt: today,
};
await BannersDismiss.insertOne(doc);
return true;
}
async disable(bannerId: string): Promise<boolean> {
const result = await Banners.disable(bannerId);
if (result) {
void api.broadcast('banner.disabled', bannerId);
return true;
}
return false;
}
async enable(bannerId: string, doc: Partial<Omit<IBanner, '_id'>> = {}): Promise<boolean> {
const result = await Banners.findOneById(bannerId);
if (!result) {
return false;
}
const { _id, ...banner } = result;
const newBanner = { ...banner, ...doc, active: true };
await Banners.updateOne({ _id }, { $set: newBanner }); // reenable the banner
void this.sendToUsers({ _id, ...newBanner });
return true;
}
async sendToUsers(banner: IBanner): Promise<boolean> {
if (!banner.active) {
return false;
}
// no roles set, so it should be sent to all users
if (!banner.roles?.length) {
void api.broadcast('banner.enabled', banner._id);
return true;
}
const total = await Users.countActiveUsersInRoles(banner.roles);
// if more than 100 users should receive the banner, send it to all users
if (total > 100) {
void api.broadcast('banner.enabled', banner._id);
return true;
}
await Users.findActiveUsersInRoles(banner.roles, { projection: { _id: 1 } }).forEach((user) => {
void api.broadcast('banner.user', user._id, banner);
});
return true;
}
}

View File

@@ -0,0 +1,390 @@
import type { ICalendarService } from '@rocket.chat/core-services';
import { Presence, ServiceClassInternal, api } from '@rocket.chat/core-services';
import type { IUser, ICalendarEvent } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { Logger } from '@rocket.chat/logger';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CalendarEvent, Users } from '@rocket.chat/models';
import type { UpdateResult, DeleteResult } from 'mongodb';
import { getShiftedTime } from './utils/getShiftedTime';
import { i18n } from '../../lib/i18n';
import { getUserPreference } from '../../lib/utils/lib/getUserPreference';
import { settings } from '../../settings';
const logger = new Logger('Calendar');
const defaultMinutesForNotifications = 5;
export class CalendarService extends ServiceClassInternal implements ICalendarService {
protected name = 'calendar';
public async create(data: Omit<InsertionModel<ICalendarEvent>, 'reminderTime' | 'notificationSent'>): Promise<ICalendarEvent['_id']> {
const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, meetingUrl, busy } = data;
const minutes = reminderMinutesBeforeStart ?? defaultMinutesForNotifications;
const reminderTime = minutes ? getShiftedTime(startTime, -minutes) : undefined;
const insertData: InsertionModel<ICalendarEvent> = {
uid,
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart: minutes,
reminderTime,
notificationSent: false,
...(busy !== undefined && { busy }),
};
const insertResult = await CalendarEvent.insertOne(insertData);
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(insertResult.insertedId);
}
return insertResult.insertedId;
}
public async import(data: Omit<InsertionModel<ICalendarEvent>, 'notificationSent'>): Promise<ICalendarEvent['_id']> {
const { externalId } = data;
if (!externalId) {
return this.create(data);
}
const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data;
const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description);
const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
const updateData: Omit<InsertionModel<ICalendarEvent>, 'uid' | 'notificationSent'> = {
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
externalId,
...(busy !== undefined && { busy }),
};
const event = await this.findImportedEvent(externalId, uid);
if (!event) {
const insertResult = await CalendarEvent.insertOne({
uid,
notificationSent: false,
...updateData,
});
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(insertResult.insertedId);
}
return insertResult.insertedId;
}
const updateResult = await CalendarEvent.updateEvent(event._id, updateData);
if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();
if (busy !== false) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(event._id);
}
}
return event._id;
}
public async get(eventId: ICalendarEvent['_id']): Promise<ICalendarEvent | null> {
return CalendarEvent.findOne({ _id: eventId });
}
public async list(uid: IUser['_id'], date: Date): Promise<ICalendarEvent[]> {
return CalendarEvent.findByUserIdAndDate(uid, date).toArray();
}
public async update(eventId: ICalendarEvent['_id'], data: Partial<ICalendarEvent>): Promise<UpdateResult | null> {
const event = await this.get(eventId);
if (!event) {
return null;
}
const { startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data;
const meetingUrl = await this.getMeetingUrl(data);
const reminderTime = reminderMinutesBeforeStart && startTime ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined;
const updateData: Partial<ICalendarEvent> = {
startTime,
...(endTime && { endTime }),
subject,
description,
meetingUrl,
reminderMinutesBeforeStart,
reminderTime,
...(busy !== undefined && { busy }),
};
const updateResult = await CalendarEvent.updateEvent(eventId, updateData);
if (updateResult.modifiedCount > 0) {
await this.setupNextNotification();
if (startTime || endTime) {
const isBusy = busy !== undefined ? busy : event.busy !== false;
if (isBusy) {
await this.setupNextStatusChange();
await this.reconcileInProgressEvent(eventId);
}
}
}
return updateResult;
}
public async delete(eventId: ICalendarEvent['_id']): Promise<DeleteResult> {
const event = await this.get(eventId);
const now = new Date();
const wasInProgress = Boolean(event && event.busy !== false && event.startTime <= now && (!event.endTime || event.endTime > now));
const result = await CalendarEvent.deleteOne({
_id: eventId,
});
if (result.deletedCount > 0) {
await this.setupNextStatusChange();
// deleting an in-progress busy event: recompute the claim from the events still active
if (event && wasInProgress) {
await this.syncBusyPresence(event.uid, { excludeEventId: eventId, now });
}
}
return result;
}
public async setupNextNotification(): Promise<void> {
return this.doSetupNextNotification(false);
}
public async setupNextStatusChange(): Promise<void> {
return this.doSetupNextStatusChange();
}
private async getMeetingUrl(eventData: Partial<ICalendarEvent>): Promise<string | undefined> {
if (eventData.meetingUrl !== undefined) {
return eventData.meetingUrl || undefined;
}
if (eventData.description !== undefined) {
return this.parseDescriptionForMeetingUrl(eventData.description);
}
return undefined;
}
private async doSetupNextNotification(isRecursive: boolean): Promise<void> {
const date = await CalendarEvent.findNextNotificationDate();
if (!date) {
if (await cronJobs.has('calendar-reminders')) {
await cronJobs.remove('calendar-reminders');
}
return;
}
date.setSeconds(0);
if (!isRecursive && date.valueOf() < Date.now()) {
return this.sendCurrentNotifications(date);
}
await cronJobs.addAtTimestamp('calendar-reminders', date, async () => this.sendCurrentNotifications(date));
}
private async doSetupNextStatusChange(): Promise<void> {
// Schedules only event starts; event end is handled by the presence engine's expiration cron.
const schedulerJobId = 'calendar-status-scheduler';
const busyStatusEnabled = settings.get<boolean>('Calendar_BusyStatus_Enabled');
if (!busyStatusEnabled) {
if (await cronJobs.has(schedulerJobId)) {
await cronJobs.remove(schedulerJobId);
}
return;
}
if (await cronJobs.has(schedulerJobId)) {
await cronJobs.remove(schedulerJobId);
}
const nextStartEvent = await CalendarEvent.findNextFutureEvent(new Date());
if (!nextStartEvent) {
return;
}
await cronJobs.addAtTimestamp(schedulerJobId, nextStartEvent.startTime, async () => this.processStatusChangesAtTime());
}
private async processStatusChangesAtTime(): Promise<void> {
const processTime = new Date();
try {
const eventsStartingNow = await CalendarEvent.findEventsStartingNow({ now: processTime, offset: 5000 }).toArray();
for await (const event of eventsStartingNow) {
await this.processEventStart(event);
}
} catch (err) {
logger.error({ msg: 'Failed to process calendar status changes', err });
}
await this.doSetupNextStatusChange();
}
private async processEventStart(event: Pick<ICalendarEvent, '_id' | 'uid' | 'endTime'>): Promise<void> {
// no endTime → no expiry to set, so the claim could never auto-clear
if (!event.endTime) {
return;
}
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime });
}
// The start scheduler only fires at start times, so it misses an event imported already in progress.
private async reconcileInProgressEvent(eventId: ICalendarEvent['_id']): Promise<void> {
const event = await CalendarEvent.findOne({ _id: eventId });
if (!event?.endTime || event.busy === false) {
return;
}
const now = new Date();
if (event.startTime > now || event.endTime <= now) {
return;
}
await this.syncBusyPresence(event.uid, { excludeEventId: event._id, seedEndTime: event.endTime, now });
}
// Derives "busy until the latest active meeting ends" from the events in progress now and applies
// it as one calendar claim. `excludeEventId`/`seedEndTime` re-add the triggering event's own end.
private async syncBusyPresence(
uid: IUser['_id'],
{ excludeEventId, seedEndTime, now = new Date() }: { excludeEventId?: ICalendarEvent['_id']; seedEndTime?: Date; now?: Date } = {},
): Promise<void> {
if (!settings.get<boolean>('Calendar_BusyStatus_Enabled')) {
return;
}
const overlappingEvents = await CalendarEvent.findOverlappingEvents(excludeEventId ?? '', uid, now, now).toArray();
const endTimes = [...(seedEndTime ? [seedEndTime] : []), ...overlappingEvents.map((event) => event.endTime)].filter(
(date): date is Date => Boolean(date),
);
// No busy event left → end the calendar claim (no-op if a higher-priority status took over).
if (endTimes.length === 0) {
await Presence.endActiveState(uid, this.name);
return;
}
const statusExpiresAt = endTimes.reduce((latest, date) => (date > latest ? date : latest));
const user = await Users.findOneById<Pick<IUser, '_id' | 'language'>>(uid, { projection: { language: 1 } });
const lng = user?.language || settings.get<string>('Language') || 'en';
await Presence.setActiveState(uid, {
statusDefault: UserStatus.BUSY,
statusText: i18n.t('Presence_status_outlook_in_a_meeting', { lng }),
statusSource: 'external',
statusExpiresAt,
statusId: this.name,
});
}
private async sendCurrentNotifications(date: Date): Promise<void> {
const events = await CalendarEvent.findEventsToNotify(date, 1).toArray();
for await (const event of events) {
await this.sendEventNotification(event);
await CalendarEvent.flagNotificationSent(event._id);
}
await this.doSetupNextNotification(true);
}
private async sendEventNotification(event: ICalendarEvent): Promise<void> {
if (!(await getUserPreference(event.uid, 'notifyCalendarEvents'))) {
return;
}
return api.broadcast('notify.calendar', event.uid, {
title: event.subject,
text: event.startTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: 'numeric', dayPeriod: 'narrow' }),
payload: {
_id: event._id,
startTimeUtc: event.startTime.toISOString(),
},
});
}
private async findImportedEvent(
externalId: Required<ICalendarEvent>['externalId'],
uid: ICalendarEvent['uid'],
): Promise<ICalendarEvent | null> {
return CalendarEvent.findOneByExternalIdAndUserId(externalId, uid);
}
private async parseDescriptionForMeetingUrl(description: string): Promise<string | undefined> {
if (!description) {
return;
}
const defaultPattern = '(?:[?&]callUrl=([^\n&<]+))|(?:(?:%3F)|(?:%26))callUrl(?:%3D)((?:(?:[^\n&<](?!%26)))+[^\n&<]?)';
const pattern = (settings.get<string>('Calendar_MeetingUrl_Regex') || defaultPattern).trim();
if (!pattern) {
return;
}
const regex: RegExp | undefined = (() => {
try {
return new RegExp(pattern, 'im');
} catch {
logger.error('Failed to parse regular expression for meeting url.');
}
})();
if (!regex) {
return;
}
const results = description.match(regex);
if (!results) {
return;
}
const [, ...urls] = results;
for (const encodedUrl of urls) {
if (!encodedUrl) {
continue;
}
let url = encodedUrl;
while (!url.includes('://')) {
const decodedUrl = decodeURIComponent(url);
if (decodedUrl === url) {
break;
}
url = decodedUrl;
}
if (url.includes('://')) {
return url;
}
}
return undefined;
}
}

View File

@@ -0,0 +1,5 @@
export function getShiftedTime(time: Date, minutes: number): Date {
const newTime = new Date(time.valueOf());
newTime.setMinutes(newTime.getMinutes() + minutes);
return newTime;
}

View File

@@ -0,0 +1,39 @@
import { ServiceClassInternal, type ICallHistoryService } from '@rocket.chat/core-services';
import type { IUser, CallHistoryItem } from '@rocket.chat/core-typings';
import { CallHistory } from '@rocket.chat/models';
export class CallHistoryService extends ServiceClassInternal implements ICallHistoryService {
protected name = 'call-history';
public async search(
uid: IUser['_id'],
filters: {
searchTerm?: string;
direction?: CallHistoryItem['direction'];
inStates?: CallHistoryItem['state'][];
},
pagination: {
count: number;
offset: number;
sort?: Record<string, 1 | -1>;
},
): Promise<{ items: CallHistoryItem[]; total: number }> {
const { offset, count, sort } = pagination || {};
const { cursor, totalCount } = CallHistory.findAllByUserIdAndSearchFilters(
uid,
{ ...filters },
{
sort: sort || { ts: -1 },
skip: offset,
limit: count,
},
);
const [items, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
items,
total,
};
}
}

View File

@@ -0,0 +1,5 @@
import { Emitter } from '@rocket.chat/emitter';
export const deviceManagementEvents = new Emitter<{
'device-login': { userId: string; userAgent: string; clientAddress: string };
}>();

View File

@@ -0,0 +1,23 @@
import type { IDeviceManagementService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { getHeader } from '@rocket.chat/tools';
import { deviceManagementEvents } from './events';
export class DeviceManagementService extends ServiceClassInternal implements IDeviceManagementService {
protected name = 'device-management';
constructor() {
super();
this.onEvent('accounts.login', async ({ userId, connection }) => {
if (!connection.loginToken) {
deviceManagementEvents.emit('device-login', {
userId,
userAgent: getHeader(connection.httpHeaders, 'user-agent'),
clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'),
});
}
});
}
}

View File

@@ -0,0 +1,156 @@
import crypto from 'node:crypto';
import { settings, settingsRegistry } from '../../settings';
export const addMatrixBridgeFederationSettings = async (): Promise<void> => {
await settingsRegistry.add('Federation_Matrix_enabled', false, {
readonly: true,
type: 'boolean',
i18nLabel: 'Federation_Matrix_enabled',
i18nDescription: 'Federation_Matrix_enabled_desc',
alert: 'Old_Federation_Alert',
public: true,
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_serve_well_known', true, {
readonly: true,
type: 'boolean',
i18nLabel: 'Federation_Matrix_serve_well_known',
alert: 'Federation_Matrix_serve_well_known_Alert',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_enable_ephemeral_events', false, {
readonly: true,
type: 'boolean',
i18nLabel: 'Federation_Matrix_enable_ephemeral_events',
i18nDescription: 'Federation_Matrix_enable_ephemeral_events_desc',
alert: 'Federation_Matrix_enable_ephemeral_events_Alert',
public: true,
group: 'Federation',
section: 'Matrix Bridge',
});
const uniqueId = settings.get('uniqueID') || crypto.randomUUID().slice(0, 15).replaceAll('-', '_');
const homeserverToken = crypto.createHash('sha256').update(`hs_${uniqueId}`).digest('hex');
const applicationServiceToken = crypto.createHash('sha256').update(`as_${uniqueId}`).digest('hex');
const siteUrl = settings.get<string>('Site_Url');
await settingsRegistry.add('Federation_Matrix_id', `rocketchat_${uniqueId}`, {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_id',
i18nDescription: 'Federation_Matrix_id_desc',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_hs_token', homeserverToken, {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_hs_token',
i18nDescription: 'Federation_Matrix_hs_token_desc',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_as_token', applicationServiceToken, {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_as_token',
i18nDescription: 'Federation_Matrix_as_token_desc',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_homeserver_url', 'http://localhost:8008', {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_homeserver_url',
i18nDescription: 'Federation_Matrix_homeserver_url_desc',
alert: 'Federation_Matrix_homeserver_url_alert',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_homeserver_domain', siteUrl, {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_homeserver_domain',
i18nDescription: 'Federation_Matrix_homeserver_domain_desc',
alert: 'Federation_Matrix_homeserver_domain_alert',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_bridge_url', 'http://localhost:3300', {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_bridge_url',
i18nDescription: 'Federation_Matrix_bridge_url_desc',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_bridge_localpart', 'rocket.cat', {
readonly: true,
type: 'string',
i18nLabel: 'Federation_Matrix_bridge_localpart',
i18nDescription: 'Federation_Matrix_bridge_localpart_desc',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_registration_file', '', {
readonly: true,
type: 'code',
i18nLabel: 'Federation_Matrix_registration_file',
i18nDescription: 'Federation_Matrix_registration_file_desc',
alert: 'Federation_Matrix_registration_file_Alert',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_max_size_of_public_rooms_users', 100, {
readonly: true,
type: 'int',
i18nLabel: 'Federation_Matrix_max_size_of_public_rooms_users',
i18nDescription: 'Federation_Matrix_max_size_of_public_rooms_users_desc',
alert: 'Federation_Matrix_max_size_of_public_rooms_users_Alert',
modules: ['federation'],
public: true,
enterprise: true,
invalidValue: false,
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_configuration_status', 'Invalid', {
readonly: true,
hidden: true,
type: 'string',
i18nLabel: 'Federation_Matrix_configuration_status',
i18nDescription: 'Federation_Matrix_configuration_status_desc',
public: false,
enterprise: false,
invalidValue: '',
group: 'Federation',
section: 'Matrix Bridge',
});
await settingsRegistry.add('Federation_Matrix_check_configuration_button', 'checkFederationConfiguration', {
readonly: true,
hidden: true,
type: 'action',
actionText: 'Federation_Matrix_check_configuration',
public: false,
enterprise: false,
invalidValue: '',
group: 'Federation',
section: 'Matrix Bridge',
});
};

View File

@@ -0,0 +1,69 @@
import type { IMatrixFederationStatistics } from '@rocket.chat/core-typings';
import { Rooms, Users } from '@rocket.chat/models';
import { settings } from '../../../../../settings';
class RocketChatStatisticsAdapter {
async getBiggestRoomAvailable(): Promise<{
_id: string;
name: string;
usersCount: number;
} | null> {
const room = await Rooms.findBiggestFederatedRoomInNumberOfUsers({ projection: { usersCount: 1, fname: 1, name: 1 } });
if (!room) {
return null;
}
return {
_id: room._id,
name: room.fname || room.name || '',
usersCount: room.usersCount,
};
}
async getSmallestRoomAvailable(): Promise<{
_id: string;
name: string;
usersCount: number;
} | null> {
const room = await Rooms.findSmallestFederatedRoomInNumberOfUsers({ projection: { usersCount: 1, fname: 1, name: 1 } });
if (!room) {
return null;
}
return {
_id: room._id,
name: room.fname || room.name || '',
usersCount: room.usersCount,
};
}
async getAmountOfExternalUsers(): Promise<number> {
return Users.countFederatedExternalUsers();
}
async getAmountOfExternalRooms(): Promise<number> {
return Rooms.countFederatedRooms();
}
async getAmountOfConnectedExternalServers(): Promise<{ quantity: number; servers: string[] }> {
const externalServers = await Rooms.countDistinctFederationRoomsExcluding(settings.get('Federation_Matrix_homeserver_domain'));
return {
quantity: externalServers.length,
servers: externalServers,
};
}
}
export const getMatrixFederationStatistics = async (): Promise<IMatrixFederationStatistics> => {
const statisticsService = new RocketChatStatisticsAdapter();
return {
enabled: settings.get('Federation_Matrix_enabled'),
maximumSizeOfPublicRoomsUsers: settings.get('Federation_Matrix_max_size_of_public_rooms_users'),
biggestRoom: await statisticsService.getBiggestRoomAvailable(),
smallestRoom: await statisticsService.getSmallestRoomAvailable(),
amountOfExternalUsers: await statisticsService.getAmountOfExternalUsers(),
amountOfFederatedRooms: await statisticsService.getAmountOfExternalRooms(),
externalConnectedServers: await statisticsService.getAmountOfConnectedExternalServers(),
};
};

View File

@@ -0,0 +1,22 @@
import { settings } from '../../settings';
export function isFederationEnabled(): boolean {
return settings.get<boolean>('Federation_Service_Enabled');
}
export function throwIfFederationNotEnabled(): void {
if (!isFederationEnabled()) {
throw new Error('Federation is not enabled');
}
}
export class FederationMatrixInvalidConfigurationError extends Error {
constructor(cause?: string) {
// eslint-disable-next-line prefer-template
const message = 'Federation configuration is invalid' + (cause ? ',' + cause[0].toLowerCase() + cause.slice(1) : '');
super(message);
this.name = 'FederationMatrixInvalidConfiguration';
}
}

View File

@@ -0,0 +1,108 @@
import type { Readable } from 'node:stream';
import stream from 'node:stream';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IMediaService, ResizeResult } from '@rocket.chat/core-services';
import { streamToBuffer } from '@rocket.chat/tools';
import ExifTransformer from 'exif-be-gone';
import ft from 'file-type';
import isSvg from 'is-svg';
import sharp from 'sharp';
import type { FitEnum } from 'sharp';
export class MediaService extends ServiceClassInternal implements IMediaService {
protected name = 'media';
private imageExts = new Set([
'jpg',
'png',
'gif',
'webp',
'flif',
'cr2',
'tif',
'bmp',
'jxr',
'psd',
'ico',
'bpg',
'jp2',
'jpm',
'jpx',
'heic',
'cur',
'dcm',
]);
async resizeFromBuffer(
input: Buffer,
width: number,
height: number,
keepType: boolean,
blur: boolean,
enlarge: boolean,
fit?: keyof FitEnum | undefined,
): Promise<ResizeResult> {
const stream = this.bufferToStream(input);
return this.resizeFromStream(stream, width, height, keepType, blur, enlarge, fit);
}
async resizeFromStream(
input: stream.Stream,
width: number,
height: number,
keepType: boolean,
blur: boolean,
enlarge: boolean,
fit?: keyof FitEnum | undefined,
): Promise<ResizeResult> {
const transformer = sharp({ limitInputChannels: 8 }).resize({ width, height, fit, withoutEnlargement: !enlarge });
if (!keepType) {
transformer.jpeg();
}
if (blur) {
transformer.blur();
}
const result = transformer.toBuffer({ resolveWithObject: true });
input.pipe(transformer);
const {
data,
info: { width: widthInfo, height: heightInfo },
} = await result;
return {
data,
width: widthInfo,
height: heightInfo,
};
}
async isImage(buff: Buffer): Promise<boolean> {
const data = await ft.fromBuffer(buff);
if (!data?.ext) {
return false || this.isSvgImage(buff);
}
return this.imageExts.has(data.ext) || this.isSvgImage(buff);
}
isSvgImage(buff: Buffer): boolean {
return isSvg(buff.toString('utf-8'));
}
stripExifFromBuffer(buffer: Buffer): Promise<Buffer> {
return streamToBuffer(this.stripExifFromImageStream(this.bufferToStream(buffer)));
}
stripExifFromImageStream(stream: stream.Stream): Readable {
return stream.pipe(new ExifTransformer());
}
private bufferToStream(buffer: Buffer): stream.PassThrough {
const bufferStream = new stream.PassThrough();
bufferStream.end(buffer);
return bufferStream;
}
}

View File

@@ -0,0 +1,423 @@
import type { IImport, IImportUser } from '@rocket.chat/core-typings';
import { ObjectId } from 'mongodb';
const mockImportsInvalidateAllOperations = jest.fn();
const mockImportsInsertOne = jest.fn();
const mockImportsFindOneById = jest.fn();
const mockImportsFindLastImport = jest.fn();
const mockImportsIncreaseTotalCount = jest.fn();
const mockImportsSetOperationStatus = jest.fn();
const mockImportDataDeleteMany = jest.fn();
const mockImportDataInsertMany = jest.fn();
jest.mock('@rocket.chat/core-services', () => ({
ServiceClassInternal: class {
protected name = '';
},
}));
jest.mock('@rocket.chat/models', () => ({
Imports: {
invalidateAllOperations: (...args: unknown[]) => mockImportsInvalidateAllOperations(...args),
insertOne: (...args: unknown[]) => mockImportsInsertOne(...args),
findOneById: (...args: unknown[]) => mockImportsFindOneById(...args),
findLastImport: (...args: unknown[]) => mockImportsFindLastImport(...args),
increaseTotalCount: (...args: unknown[]) => mockImportsIncreaseTotalCount(...args),
setOperationStatus: (...args: unknown[]) => mockImportsSetOperationStatus(...args),
},
ImportData: {
col: {
deleteMany: (...args: unknown[]) => mockImportDataDeleteMany(...args),
insertMany: (...args: unknown[]) => mockImportDataInsertMany(...args),
},
},
}));
const mockImportersGet = jest.fn();
jest.mock('../../lib/import', () => ({
Importers: {
get: (...args: unknown[]) => mockImportersGet(...args),
},
}));
const mockSettingsGet = jest.fn();
jest.mock('../../settings', () => ({
settings: {
get: (...args: unknown[]) => mockSettingsGet(...args),
},
}));
const mockValidateRoleList = jest.fn();
jest.mock('../../lib/roles/validateRoleList', () => ({
validateRoleList: (...args: unknown[]) => mockValidateRoleList(...args),
}));
const mockGetNewUserRoles = jest.fn();
jest.mock('../user/lib/getNewUserRoles', () => ({
getNewUserRoles: (...args: unknown[]) => mockGetNewUserRoles(...args),
}));
// eslint-disable-next-line import-x/first
import { ImportService } from './service';
const createMockOperation = (overrides?: Partial<IImport>): IImport => ({
_id: new ObjectId().toHexString(),
type: 'csv',
importerKey: 'csv',
ts: new Date(),
status: 'importer_new',
valid: true,
user: 'user123',
_updatedAt: new Date(),
...overrides,
});
describe('ImportService', () => {
let service: ImportService;
beforeEach(() => {
jest.clearAllMocks();
service = new ImportService();
mockGetNewUserRoles.mockReturnValue(['user']);
mockValidateRoleList.mockResolvedValue(true);
});
describe('clear', () => {
it('should invalidate all operations and delete import data', async () => {
await service.clear();
expect(mockImportsInvalidateAllOperations).toHaveBeenCalledTimes(1);
expect(mockImportDataDeleteMany).toHaveBeenCalledWith({});
});
});
describe('newOperation', () => {
it('should clear previous operations and create a new import', async () => {
const mockOperation = createMockOperation();
mockImportsInsertOne.mockResolvedValue({ insertedId: mockOperation._id });
mockImportsFindOneById.mockResolvedValue(mockOperation);
const result = await service.newOperation('user123', 'csv', 'csv');
expect(mockImportsInvalidateAllOperations).toHaveBeenCalled();
expect(mockImportDataDeleteMany).toHaveBeenCalledWith({});
expect(mockImportsInsertOne).toHaveBeenCalledWith({
type: 'csv',
importerKey: 'csv',
ts: expect.any(Date),
status: 'importer_new',
valid: true,
user: 'user123',
});
expect(result).toEqual(mockOperation);
});
it('should throw an error if operation creation fails', async () => {
mockImportsInsertOne.mockResolvedValue({ insertedId: new ObjectId().toHexString() });
mockImportsFindOneById.mockResolvedValue(null);
await expect(service.newOperation('user123', 'csv', 'csv')).rejects.toThrow('failed to create import operation');
});
});
describe('status', () => {
it('should return state "none" when no operation exists', async () => {
mockImportsFindLastImport.mockResolvedValue(null);
const result = await service.status();
expect(result).toEqual({ state: 'none' });
});
it('should return "new" state for importer_new status', async () => {
const mockOperation = createMockOperation();
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'new', operation: mockOperation });
});
it('should return "loading" state for uploading status', async () => {
const mockOperation = createMockOperation({ status: 'importer_uploading' });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'loading', operation: mockOperation });
});
it('should return "ready" state for user_selection status', async () => {
const mockOperation = createMockOperation({ status: 'importer_user_selection' });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'ready', operation: mockOperation });
});
it('should return "importing" state for importing statuses', async () => {
const mockOperation = createMockOperation({ status: 'importer_importing_users' });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'importing', operation: mockOperation });
});
it('should return "done" state for importer_done status', async () => {
const mockOperation = createMockOperation({ status: 'importer_done' });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'done', operation: mockOperation });
});
it('should return "error" state for invalid operation', async () => {
const mockOperation = createMockOperation({ valid: false });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'error', operation: mockOperation });
});
it('should return "canceled" state for cancelled import', async () => {
const mockOperation = createMockOperation({ status: 'importer_import_cancelled' });
mockImportsFindLastImport.mockResolvedValue(mockOperation);
const result = await service.status();
expect(result).toEqual({ state: 'canceled', operation: mockOperation });
});
});
describe('addUsers', () => {
const mockOperation = createMockOperation({
_id: 'op1',
});
beforeEach(() => {
mockImportsFindLastImport.mockResolvedValue(mockOperation);
});
it('should return early for empty users array', async () => {
await service.addUsers([]);
expect(mockImportsFindLastImport).not.toHaveBeenCalled();
expect(mockImportDataInsertMany).not.toHaveBeenCalled();
});
it('should throw error if operation is not valid', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, valid: false });
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await expect(service.addUsers(users)).rejects.toThrow('Import operation not initialized.');
});
it('should throw error if user has no emails', async () => {
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [{ emails: [], importIds: ['id1'], type: 'user' }];
await expect(service.addUsers(users)).rejects.toThrow('Users are missing required data.');
});
it('should throw error if user has no importIds', async () => {
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: [], type: 'user' },
];
await expect(service.addUsers(users)).rejects.toThrow('Users are missing required data.');
});
it('should throw error if roles are invalid', async () => {
mockValidateRoleList.mockResolvedValue(false);
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user', roles: ['invalid-role'] },
];
await expect(service.addUsers(users)).rejects.toThrow('One or more of the users have been assigned invalid roles.');
});
it('should add users with default roles when no roles provided', async () => {
mockGetNewUserRoles.mockReturnValue(['user']);
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await service.addUsers(users);
expect(mockImportDataInsertMany).toHaveBeenCalledWith([
expect.objectContaining({
data: expect.objectContaining({
roles: ['user'],
}),
dataType: 'user',
}),
]);
});
// Test for the condition fixed in the previous commit:
// The fix was changing `[...new Set(...data.roles, ...defaultRoles)]` to `[...new Set([...data.roles, ...defaultRoles])]`
// This ensures user roles are properly merged with default roles without duplicates
it('should correctly merge user roles with default roles without duplicates', async () => {
mockGetNewUserRoles.mockReturnValue(['user', 'guest']);
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user', roles: ['admin', 'user'] }, // 'user' is in both
];
await service.addUsers(users);
const insertCall = mockImportDataInsertMany.mock.calls[0][0];
const insertedRoles = insertCall[0].data.roles;
// Verify roles are merged correctly: admin, user, guest (user appears once due to Set deduplication)
expect(insertedRoles).toContain('admin');
expect(insertedRoles).toContain('user');
expect(insertedRoles).toContain('guest');
// Ensure no duplicates - the length should be exactly 3
expect(insertedRoles.length).toBe(3);
expect(new Set(insertedRoles).size).toBe(3);
});
it('should correctly merge multiple user roles with multiple default roles', async () => {
mockGetNewUserRoles.mockReturnValue(['user', 'default-role']);
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{
emails: ['test1@example.com'],
importIds: ['id1'],
type: 'user',
roles: ['admin', 'moderator'],
},
{
emails: ['test2@example.com'],
importIds: ['id2'],
type: 'user',
roles: ['user'], // Already in default roles
},
];
await service.addUsers(users);
const insertCall = mockImportDataInsertMany.mock.calls[0][0];
// First user: admin, moderator + user, default-role = 4 unique roles
const firstUserRoles = insertCall[0].data.roles;
expect(firstUserRoles).toContain('admin');
expect(firstUserRoles).toContain('moderator');
expect(firstUserRoles).toContain('user');
expect(firstUserRoles).toContain('default-role');
expect(new Set(firstUserRoles).size).toBe(4);
// Second user: user + user, default-role = should dedupe to 2 unique roles
const secondUserRoles = insertCall[1].data.roles;
expect(secondUserRoles).toContain('user');
expect(secondUserRoles).toContain('default-role');
expect(new Set(secondUserRoles).size).toBe(2);
});
it('should update operation status and count after adding users', async () => {
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await service.addUsers(users);
expect(mockImportsIncreaseTotalCount).toHaveBeenCalledWith('op1', 'users', 1);
expect(mockImportsSetOperationStatus).toHaveBeenCalledWith('op1', 'importer_user_selection');
});
it('should throw error when operation is in loading state', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_uploading' });
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await expect(service.addUsers(users)).rejects.toThrow('The current import operation can not receive new data.');
});
it('should throw error when operation is in importing state', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_importing_users' });
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await expect(service.addUsers(users)).rejects.toThrow('The current import operation can not receive new data.');
});
it('should throw error when operation is done', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_done' });
const users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[] = [
{ emails: ['test@example.com'], importIds: ['id1'], type: 'user' },
];
await expect(service.addUsers(users)).rejects.toThrow('The current import operation is already finished.');
});
});
describe('run', () => {
const mockOperation = createMockOperation({ status: 'importer_user_selection' });
it('should throw error if operation not found', async () => {
mockImportsFindLastImport.mockResolvedValue(null);
await expect(service.run('user123')).rejects.toThrow('error-operation-not-found');
});
it('should throw error if operation is invalid', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, valid: false });
await expect(service.run('user123')).rejects.toThrow('error-operation-not-found');
});
it('should throw error if operation status is not user_selection', async () => {
mockImportsFindLastImport.mockResolvedValue({ ...mockOperation, status: 'importer_new' });
await expect(service.run('user123')).rejects.toThrow('error-invalid-operation-status');
});
it('should throw error if importer not found', async () => {
mockImportsFindLastImport.mockResolvedValue(mockOperation);
mockImportersGet.mockReturnValue(null);
await expect(service.run('user123')).rejects.toThrow('error-importer-not-defined');
});
it('should start import with correct options', async () => {
mockImportsFindLastImport.mockResolvedValue(mockOperation);
mockSettingsGet.mockReturnValue(true);
const mockStartImport = jest.fn();
const MockImporter = jest.fn().mockImplementation(() => ({
startImport: mockStartImport,
}));
mockImportersGet.mockReturnValue({
importer: MockImporter,
});
await service.run('user123');
expect(mockImportersGet).toHaveBeenCalledWith('csv');
expect(MockImporter).toHaveBeenCalledWith({ importer: MockImporter }, mockOperation, {
skipUserCallbacks: true,
skipDefaultChannels: true,
enableEmail2fa: true,
quickUserInsertion: true,
skipExistingUsers: true,
});
expect(mockStartImport).toHaveBeenCalledWith({ users: { all: true } }, 'user123');
});
});
});

View File

@@ -0,0 +1,182 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IImportService } from '@rocket.chat/core-services';
import type { IImportUser, IImport, ImportStatus } from '@rocket.chat/core-typings';
import { Imports, ImportData } from '@rocket.chat/models';
import { ObjectId } from 'mongodb';
import { Importers } from '../../lib/import';
import { validateRoleList } from '../../lib/roles/validateRoleList';
import { settings } from '../../settings';
import { getNewUserRoles } from '../user/lib/getNewUserRoles';
export class ImportService extends ServiceClassInternal implements IImportService {
protected name = 'import';
public async clear(): Promise<void> {
await Imports.invalidateAllOperations();
await ImportData.col.deleteMany({});
}
public async newOperation(userId: string, name: string, key: string): Promise<IImport> {
// Make sure there's no other operation running
await this.clear();
const importId = (
await Imports.insertOne({
type: name,
importerKey: key,
ts: new Date(),
status: 'importer_new',
valid: true,
user: userId,
})
).insertedId;
const operation = await Imports.findOneById(importId);
if (!operation) {
throw new Error('failed to create import operation');
}
return operation;
}
private getStateOfOperation(operation: IImport): 'none' | 'new' | 'loading' | 'ready' | 'importing' | 'done' | 'error' | 'canceled' {
if (!operation.valid && operation.status !== 'importer_done') {
return 'error';
}
switch (operation.status) {
case 'importer_new':
return 'new';
case 'importer_uploading':
case 'importer_downloading_file':
case 'importer_file_loaded':
case 'importer_preparing_started':
case 'importer_preparing_users':
case 'importer_preparing_channels':
case 'importer_preparing_messages':
case 'importer_preparing_contacts':
return 'loading';
case 'importer_user_selection':
return 'ready';
case 'importer_importing_started':
case 'importer_importing_users':
case 'importer_importing_channels':
case 'importer_importing_messages':
case 'importer_importing_contacts':
case 'importer_importing_files':
case 'importer_finishing':
return 'importing';
case 'importer_done':
return 'done';
case 'importer_import_failed':
return 'error';
case 'importer_import_cancelled':
return 'canceled';
}
}
public async status(): Promise<ImportStatus> {
const operation = await Imports.findLastImport();
if (!operation) {
return {
state: 'none',
};
}
const state = this.getStateOfOperation(operation);
return {
state,
operation,
};
}
private assertsValidStateForNewData(operation: IImport | undefined): asserts operation is IImport {
if (!operation?.valid) {
throw new Error('Import operation not initialized.');
}
const state = this.getStateOfOperation(operation);
switch (state) {
case 'loading':
case 'importing':
throw new Error('The current import operation can not receive new data.');
case 'done':
case 'error':
case 'canceled':
throw new Error('The current import operation is already finished.');
}
}
public async addUsers(users: Omit<IImportUser, '_id' | 'services' | 'customFields'>[]): Promise<void> {
if (!users.length) {
return;
}
const operation = await Imports.findLastImport();
this.assertsValidStateForNewData(operation);
const defaultRoles = getNewUserRoles();
const userRoles = new Set<string>(defaultRoles);
for await (const user of users) {
if (!user.emails?.some((value) => value) || !user.importIds?.some((value) => value)) {
throw new Error('Users are missing required data.');
}
if (user.roles?.length) {
for (const roleId of user.roles) {
userRoles.add(roleId);
}
}
}
if (userRoles.size > 0 && !(await validateRoleList([...userRoles]))) {
throw new Error('One or more of the users have been assigned invalid roles.');
}
await ImportData.col.insertMany(
users.map((data) => ({
_id: new ObjectId().toHexString(),
data: {
...data,
roles: data.roles ? [...new Set([...data.roles, ...defaultRoles])] : defaultRoles,
},
dataType: 'user',
_updatedAt: new Date(),
})),
);
await Imports.increaseTotalCount(operation._id, 'users', users.length);
await Imports.setOperationStatus(operation._id, 'importer_user_selection');
}
public async run(userId: string): Promise<void> {
const operation = await Imports.findLastImport();
if (!operation?.valid) {
throw new Error('error-operation-not-found');
}
if (operation.status !== 'importer_user_selection') {
throw new Error('error-invalid-operation-status');
}
const { importerKey } = operation;
const importer = Importers.get(importerKey);
if (!importer) {
throw new Error('error-importer-not-defined');
}
// eslint-disable-next-line new-cap
const instance = new importer.importer(importer, operation, {
skipUserCallbacks: true,
skipDefaultChannels: true,
enableEmail2fa: settings.get<boolean>('Accounts_TwoFactorAuthentication_By_Email_Auto_Opt_In'),
quickUserInsertion: true,
skipExistingUsers: true,
});
await instance.startImport({ users: { all: true } }, userId);
}
}

View File

@@ -0,0 +1,25 @@
import type { ILDAPService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { LDAPLoginResult } from '@rocket.chat/core-typings';
import { LDAPManager } from '../../lib/ldap/Manager';
export class LDAPService extends ServiceClassInternal implements ILDAPService {
protected name = 'ldap';
async loginRequest(username: string, password: string): Promise<LDAPLoginResult> {
return LDAPManager.login(username, password);
}
async loginAuthenticatedUserRequest(username: string): Promise<LDAPLoginResult> {
return LDAPManager.loginAuthenticatedUser(username);
}
async testConnection(): Promise<void> {
return LDAPManager.testConnection();
}
async testSearch(username: string): Promise<void> {
return LDAPManager.testSearch(username);
}
}

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('MediaCall');

View File

@@ -0,0 +1,22 @@
import type { IMediaCall } from '@rocket.chat/core-typings';
import type { VoipPushNotificationType } from '@rocket.chat/media-calls';
export function getPushNotificationType(call: IMediaCall): VoipPushNotificationType {
if (call.acceptedAt) {
return 'answeredElsewhere';
}
if (call.endedBy?.id === call.callee.id || call.hangupReason === 'rejected') {
return 'declinedElsewhere';
}
if (call.endedBy?.id === call.caller.id) {
return 'remoteEnded';
}
if (call.ended) {
return 'unanswered';
}
return 'incoming_call';
}

View File

@@ -0,0 +1,125 @@
import type { IMediaCall, IUser, MediaCallContact, MediaCallActorType } from '@rocket.chat/core-typings';
import type { VoipPushNotificationEventType } from '@rocket.chat/media-calls';
import { MediaCalls, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { getPushNotificationType } from './getPushNotificationType';
import { metrics } from '../../../lib/metrics/lib/metrics';
import { Push } from '../../../lib/notifications/push/push';
import PushNotification from '../../../lib/notifications/push-config/lib/PushNotification';
import { getUserAvatarURL } from '../../../lib/utils/getUserAvatarURL';
import { getUserPreference } from '../../../lib/utils/lib/getUserPreference';
import { settings } from '../../../settings';
import { logger } from '../logger';
async function getActorUser(actor: MediaCallContact): Promise<Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'> | null> {
const options = { projection: { name: 1, username: 1, freeSwitchExtension: 1 } };
switch (actor.type) {
case 'user':
return Users.findOneById(actor.id, options);
case 'sip':
return Users.findOneByFreeSwitchExtension(actor.id, options);
}
}
async function getActorUserData(
actor: MediaCallContact,
): Promise<{ type: MediaCallActorType; id: string; name: string; avatarUrl?: string; username?: string }> {
const actorUsername = actor.type === 'user' ? actor.username : undefined;
const actorExtension = actor.sipExtension || (actor.type === 'sip' ? actor.id : undefined);
const data = {
type: actor.type,
id: actor.id,
name: actor.displayName || actorUsername || actorExtension || '',
} as const;
const user = await getActorUser(actor);
if (user) {
const username = user.username || actorUsername;
return {
...data,
name: user.name || user.username || user.freeSwitchExtension || data.name,
...(username && { username, avatarUrl: getUserAvatarURL(username) }),
};
}
return {
...data,
...(actorUsername && { username: actorUsername, avatarUrl: getUserAvatarURL(actorUsername) }),
};
}
async function sendVoipPushNotificationAsync(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): Promise<void> {
if (settings.get('Push_enable') !== true || settings.get('VoIP_TeamCollab_Mobile_Ringing_Enabled') !== true) {
return;
}
const call = await MediaCalls.findOneById(callId);
if (!call) {
logger.error({ msg: 'Failed to send push notification: Media Call not found', callId });
return;
}
if (call.callee.type !== 'user') {
logger.error({ msg: 'Failed to send push notification: Invalid Callee Type', callId });
return;
}
const {
kind,
callee: { id: userId },
} = call;
// If the call was accepted, we don't need to notify when it ends
if (call.acceptedAt && event !== 'answer') {
return;
}
const type = getPushNotificationType(call);
// If the state changed before we had a chance to send the incoming call, skip it altogether
if (event === 'new' && type !== 'incoming_call') {
return;
}
if (type === 'incoming_call' && event !== 'new') {
return;
}
if (!(await getUserPreference(userId, 'enableMobileRinging'))) {
return;
}
const caller = await getActorUserData(call.caller);
metrics.notificationsSent.inc({ notification_type: 'mobile' });
const useVoipToken = type === 'incoming_call';
await Push.send({
useVoipToken,
priority: 10,
payload: {
host: Meteor.absoluteUrl(),
hostName: settings.get<string>('Site_Name'),
notificationType: 'voip',
type,
kind,
callId: call._id,
caller,
createdAt: call.createdAt.toISOString(),
},
...(useVoipToken && { apn: { expirationSeconds: 60 } }),
userId,
notId: PushNotification.getNotificationId(call._id),
// We should not send state change notifications to the device where the call was accepted/rejected
...(call.callee.contractId && { skipTokenId: call.callee.contractId }),
});
}
export function sendVoipPushNotification(callId: IMediaCall['_id'], event: VoipPushNotificationEventType): void {
void sendVoipPushNotificationAsync(callId, event).catch((err) => {
logger.error({ msg: 'Failed to send VoIP push notification', err, callId, event });
});
}

View File

@@ -0,0 +1,473 @@
import { api, Presence, ServiceClassInternal, type IMediaCallService, Authorization } from '@rocket.chat/core-services';
import type {
IMediaCall,
IUser,
IRoom,
IInternalMediaCallHistoryItem,
CallHistoryItemState,
IExternalMediaCallHistoryItem,
} from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { callServer, type IMediaCallServerSettings, getSignalsForExistingCall } from '@rocket.chat/media-calls';
import type {
CallFeature,
ClientMediaSignal,
ServerMediaSignal,
ServerMediaCallSignal,
ClientMediaSignalAnswer,
} from '@rocket.chat/media-signaling';
import { isClientMediaSignal } from '@rocket.chat/media-signaling';
import type { InsertionModel } from '@rocket.chat/model-typings';
import { CallHistory, MediaCalls, Rooms, Users } from '@rocket.chat/models';
import { callStateToTranslationKey, getHistoryMessagePayload } from '@rocket.chat/ui-voip/dist/ui-kit/getHistoryMessagePayload';
import { logger } from './logger';
import { sendVoipPushNotification } from './push/sendVoipPushNotification';
import { i18n } from '../../lib/i18n';
import { sendMessage } from '../../lib/messages/sendMessage';
import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage';
import { settings } from '../../settings';
export class MediaCallService extends ServiceClassInternal implements IMediaCallService {
protected name = 'media-call';
constructor() {
super();
callServer.emitter.on('signalRequest', ({ toUid, signal }) => this.sendSignal(toUid, signal));
callServer.emitter.on('callUpdated', (params) => api.broadcast('media-call.updated', params));
callServer.emitter.on('callActivated', ({ callId, uids }) => this.setPresenceForUsers(uids, callId));
callServer.emitter.on('callEnded', ({ callId, uids }) => this.clearPresenceForUsers(uids, callId));
callServer.emitter.on('historyUpdate', ({ callId }) => setImmediate(() => this.saveCallToHistory(callId)));
callServer.emitter.on('pushNotificationRequest', ({ callId, event }) => sendVoipPushNotification(callId, event));
this.onEvent('media-call.updated', (params) => callServer.receiveCallUpdate(params));
this.onEvent('watch.settings', async ({ setting }): Promise<void> => {
if (setting._id.startsWith('VoIP_TeamCollab_')) {
setImmediate(() => this.configureMediaCallServer());
}
});
this.configureMediaCallServer();
}
public async answerCall(uid: IUser['_id'], params: Omit<ClientMediaSignalAnswer, 'type'>): Promise<IMediaCall> {
const { callId, answer } = params;
const call = await MediaCalls.findOneByIdAndCallee<Pick<IMediaCall, '_id'>>(
callId,
{ type: 'user', id: uid },
{ projection: { _id: 1 } },
);
if (!call) {
throw new Error('not-found');
}
const signal: ClientMediaSignalAnswer = {
type: 'answer',
...params,
};
await callServer.receiveSignal(uid, signal, { throwIfSkipped: true });
const updatedCall = await MediaCalls.findOneById(callId);
if (!updatedCall) {
throw new Error('internal-error');
}
switch (answer) {
case 'ack':
if (updatedCall.acceptedAt || updatedCall.ended) {
throw new Error('invalid-call-state');
}
break;
case 'reject':
if (!updatedCall.ended || updatedCall.endedBy?.id !== uid) {
throw new Error('invalid-call-state');
}
break;
case 'accept':
if (updatedCall.callee.contractId !== signal.contractId) {
if (updatedCall.callee.contractId) {
throw new Error('invalid-call-state');
}
throw new Error('internal-error');
}
break;
}
return updatedCall;
}
public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal): Promise<void> {
try {
await callServer.receiveSignal(uid, signal);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, signal, uid });
}
}
public async processSerializedSignal(uid: IUser['_id'], signal: string): Promise<void> {
let signalType: string | null = null;
try {
const deserialized = await this.deserializeClientSignal(signal);
signalType = deserialized.type;
await callServer.receiveSignal(uid, deserialized);
} catch (err) {
logger.error({ msg: 'failed to process client signal', err, uid, type: signalType });
}
}
public async hangupExpiredCalls(): Promise<void> {
await callServer.hangupExpiredCalls().catch((err) => {
logger.error({ msg: 'Media Call Server failed to hangup expired calls', err });
});
try {
if (await MediaCalls.hasUnfinishedCalls()) {
callServer.scheduleExpirationCheck();
}
} catch (err) {
logger.error({ msg: 'Media Call Server failed to check if there are expired calls', err });
}
}
public async getUserStateSignals(uid: IUser['_id'], contractId: string): Promise<ServerMediaCallSignal[]> {
const calls = await MediaCalls.findAllNotOverByUid(uid).toArray();
const signals: ServerMediaCallSignal[] = [];
for (const call of calls) {
const callSignals = await getSignalsForExistingCall(call, uid, contractId);
signals.push(...callSignals);
}
return signals;
}
private async saveCallToHistory(callId: IMediaCall['_id']): Promise<void> {
logger.info({ msg: 'saving media call to history', callId });
const call = await MediaCalls.findOneById(callId);
if (!call) {
logger.warn({ msg: 'Attempt to save an invalid call to history', callId });
return;
}
if (!call.ended) {
logger.warn({ msg: 'Attempt to save a pending call to history', callId });
return;
}
if (call.uids.length !== 2) {
return this.saveExternalCallToHistory(call);
}
return this.saveInternalCallToHistory(call);
}
private async saveExternalCallToHistory(call: IMediaCall): Promise<void> {
const callerIsInternal = call.caller.type === 'user';
const calleeIsInternal = call.callee.type === 'user';
if (callerIsInternal && calleeIsInternal) {
logger.warn({ msg: 'Attempt to save an external call history with a call that is not external', callId: call._id });
return;
}
if (!callerIsInternal && !calleeIsInternal) {
logger.warn({ msg: 'Attempt to save an external call history with an invalid call', callId: call._id });
return;
}
const state = this.getCallHistoryItemState(call);
const duration = this.getCallDuration(call);
const direction = callerIsInternal ? 'outbound' : 'inbound';
const uid = callerIsInternal ? call.caller.id : call.callee.id;
const contact = callerIsInternal ? call.callee : call.caller;
const contactExtension = contact.sipExtension || contact.id;
const historyItem: InsertionModel<IExternalMediaCallHistoryItem> = {
uid,
ts: call.createdAt,
callId: call._id,
state,
type: 'media-call',
duration,
endedAt: call.endedAt || new Date(),
external: true,
direction,
contactExtension,
};
await CallHistory.insertOne(historyItem).catch((err: unknown) => logger.error({ msg: 'Failed to insert item into Call History', err }));
}
private getContactDataForInternalHistory(
contact: IMediaCall['caller'] | IMediaCall['callee'],
): Pick<IInternalMediaCallHistoryItem, 'contactId' | 'contactName' | 'contactUsername'> {
return {
contactId: contact.id,
contactName: contact.displayName,
contactUsername: contact.username,
};
}
private async saveInternalCallToHistory(call: IMediaCall): Promise<void> {
if (call.caller.type !== 'user' || call.callee.type !== 'user') {
logger.warn({ msg: 'Attempt to save an internal call history with a call that is not internal', callId: call._id });
return;
}
const room = await this.getRoomIdForInternalCall(call).catch((err) => {
logger.error({ msg: 'Failed to determine room id for Internal Call', err });
return undefined;
});
const { _id: rid } = room || {};
const state = this.getCallHistoryItemState(call);
const duration = this.getCallDuration(call);
const sharedData: Omit<InsertionModel<IInternalMediaCallHistoryItem>, 'uid' | 'direction' | 'contactId'> = {
ts: call.createdAt,
callId: call._id,
state,
type: 'media-call',
duration,
endedAt: call.endedAt || new Date(),
external: false,
...(rid && { rid }),
};
const outboundHistoryItem = {
...sharedData,
uid: call.caller.id,
direction: 'outbound',
...this.getContactDataForInternalHistory(call.callee),
} as const;
const inboundHistoryItem = {
...sharedData,
uid: call.callee.id,
direction: 'inbound',
...this.getContactDataForInternalHistory(call.caller),
} as const;
await CallHistory.insertMany([outboundHistoryItem, inboundHistoryItem]).catch((err: unknown) =>
logger.error({ msg: 'Failed to insert items into Call History', err }),
);
if (room) {
return this.sendHistoryMessage(call, room);
}
}
private getLanguageForUser(language?: string): string {
return language || settings.get('Language') || 'en';
}
private async sendHistoryMessage(call: IMediaCall, room: IRoom): Promise<void> {
const userId = call.caller.id || call.createdBy?.id; // I think this should always be the caller, since during a transfer the createdBy contact is the one that transferred the call
const user = await Users.findOneById(userId);
if (!user) {
return;
}
const state = this.getCallHistoryItemState(call);
const skipNotifications = state !== 'not-answered' || call.hangupReason === 'rejected';
const i18nKey = callStateToTranslationKey(state).i18n?.key;
const msg = i18nKey ? i18n.t(i18nKey, { lng: this.getLanguageForUser(user.language) }) : '';
const duration = this.getCallDuration(call);
const record = getHistoryMessagePayload(state, duration, call._id, msg);
try {
const message = await sendMessage(user, record, room, { skipNotifications });
if ('_id' in message) {
await CallHistory.updateMany({ callId: call._id }, { $set: { messageId: message._id } });
return;
}
throw new Error('Failed to save message id in history');
} catch (err) {
logger.error({ msg: 'Failed to send history message', err, callId: call._id });
}
}
private getCallDuration(call: IMediaCall): number {
const { activatedAt, endedAt = new Date() } = call;
if (!activatedAt) {
return 0;
}
const diff = endedAt.valueOf() - activatedAt.valueOf();
return Math.floor(diff / 1000);
}
private getCallHistoryItemState(call: IMediaCall): CallHistoryItemState {
if (call.transferredBy) {
return 'transferred';
}
if (call.hangupReason === 'not-answered') {
return 'not-answered';
}
if (call.hangupReason?.startsWith('timeout')) {
return 'failed';
}
if (call.hangupReason?.includes('error')) {
if (!call.activatedAt) {
return 'failed';
}
return 'error';
}
if (!call.acceptedAt) {
return 'not-answered';
}
if (!call.activatedAt) {
return 'failed';
}
return 'ended';
}
private async getRoomIdForInternalCall(call: IMediaCall): Promise<IRoom> {
const uniqueUids = [...new Set(call.uids)];
const room = await Rooms.findOneDirectRoomContainingAllUserIDs(uniqueUids);
if (room) {
return room;
}
const requesterId = call.createdBy.type === 'user' && call.createdBy.id;
const callerId = call.caller.type === 'user' && call.caller.id;
const dmCreatorId = requesterId || callerId || call.uids[0];
const usernames = (
await Users.findByIds(call.uids, { projection: { username: 1 } })
.map((user) => user.username)
.toArray()
).filter((username) => username);
if (usernames.length !== 2) {
throw new Error('Invalid usernames for DM.');
}
const dmCreatorIsPartOfTheCall = call.uids.includes(dmCreatorId);
const newRoom = await createDirectMessage(usernames, dmCreatorId, !dmCreatorIsPartOfTheCall); // If the dm creator is not part of the call, we need to exclude him from the new DM
return {
...newRoom,
_id: newRoom.rid,
};
}
private async setPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
const users = await Users.findByIds<Pick<IUser, '_id' | 'language'>>(uids, { projection: { language: 1 } }).toArray();
const languageByUid = new Map(users.map((user) => [user._id, user.language]));
await Promise.all(
uids.map(async (uid) => {
try {
await Presence.setActiveState(uid, {
statusDefault: UserStatus.BUSY,
statusText: i18n.t('Presence_status_on_a_call', { lng: this.getLanguageForUser(languageByUid.get(uid)) }),
statusSource: 'internal',
statusId: callId,
});
} catch (err) {
logger.error({ msg: 'Failed to set presence for user on call', uid, err });
}
}),
);
}
private async clearPresenceForUsers(uids: IUser['_id'][], callId: IMediaCall['_id']): Promise<void> {
// pass callId so only this call's claim is cleared, never another claim that took over
await Promise.all(
uids.map((uid) =>
Presence.endActiveState(uid, callId).catch((err) =>
logger.error({ msg: 'Failed to clear presence for user after call', uid, err }),
),
),
);
}
private async sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): Promise<void> {
void api.broadcast('user.media-signal', { userId: toUid, signal });
}
private configureMediaCallServer(): void {
callServer.configure(this.getMediaServerSettings());
}
private getMediaServerSettings(): IMediaCallServerSettings {
const sipEnabled = settings.get<boolean>('VoIP_TeamCollab_SIP_Integration_Enabled') ?? false;
const mobileRinging = settings.get<boolean>('VoIP_TeamCollab_Mobile_Ringing_Enabled') ?? false;
const forceSip = sipEnabled && (settings.get<boolean>('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls') ?? false);
return {
internalCalls: {
requireExtensions: forceSip,
routeExternally: forceSip ? 'always' : 'never',
},
sip: {
enabled: sipEnabled,
drachtio: {
host: settings.get<string>('VoIP_TeamCollab_Drachtio_Host') ?? '',
port: settings.get<number>('VoIP_TeamCollab_Drachtio_Port') ?? 9022,
secret: settings.get<string>('VoIP_TeamCollab_Drachtio_Password') ?? '',
},
sipServer: {
host: settings.get<string>('VoIP_TeamCollab_SIP_Server_Host') ?? '',
port: settings.get<number>('VoIP_TeamCollab_SIP_Server_Port') ?? 5060,
},
},
mobileRinging,
permissionCheck: (uid, callType) => this.userHasMediaCallPermission(uid, callType),
isFeatureAvailableForUser: (uid, feature) => this.userHasFeaturePermission(uid, feature),
};
}
private userHasFeaturePermission(_uid: IUser['_id'], feature: CallFeature): boolean {
if (feature === 'audio') {
return true;
}
if (feature === 'screen-share') {
return settings.get<boolean>('VoIP_TeamCollab_Screen_Sharing_Enabled') ?? false;
}
return true;
}
private async userHasMediaCallPermission(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean> {
if (callType === 'any') {
return Authorization.hasAtLeastOnePermission(uid, ['allow-internal-voice-calls', 'allow-external-voice-calls']);
}
const permissionId = `allow-${callType}-voice-calls`;
return Authorization.hasPermission(uid, permissionId);
}
private async deserializeClientSignal(serialized: string): Promise<ClientMediaSignal> {
try {
const signal = JSON.parse(serialized);
if (!isClientMediaSignal(signal)) {
throw new Error('signal-format-invalid');
}
return signal;
} catch (err) {
logger.error({ msg: 'Failed to parse client signal', err });
throw err;
}
}
}

View File

@@ -0,0 +1,379 @@
import type {
OEmbedUrlContentResult,
MessageUrl,
OEmbedUrlWithMetadata,
OEmbedMeta,
IMessage,
OEmbedUrlContent,
} from '@rocket.chat/core-typings';
import { isOEmbedUrlWithMetadata } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { OEmbedCache, Messages } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import he from 'he';
import iconv from 'iconv-lite';
import ipRangeCheck from 'ip-range-check';
import jschardet from 'jschardet';
import { camelCase } from 'lodash';
import { Info } from '../../../../app/utils/rocketchat.info';
import { settings } from '../../../settings';
import { afterParseUrlContent, beforeGetUrlContent } from '../lib/oembed/providers';
const MAX_EXTERNAL_URL_PREVIEWS = 5;
const log = new Logger('OEmbed');
// Detect encoding
// Priority:
// Detected == HTTP Header > Detected == HTML meta > HTTP Header > HTML meta > Detected > Default (utf-8)
// See also: https://www.w3.org/International/questions/qa-html-encoding-declarations.en#quickanswer
const getCharset = function (contentType: string, body: Buffer): string {
let detectedCharset;
let httpHeaderCharset;
let htmlMetaCharset;
let result;
contentType = contentType || '';
const binary = body.toString('binary');
const detected = jschardet.detect(binary);
if (detected.confidence > 0.8) {
detectedCharset = detected.encoding.toLowerCase();
}
const m1 = contentType.match(/charset=([\w\-]+)/i);
if (m1) {
httpHeaderCharset = m1[1].toLowerCase();
}
const m2 = binary.match(/<meta\b[^>]*charset=["']?([\w\-]+)/i);
if (m2) {
htmlMetaCharset = m2[1].toLowerCase();
}
if (detectedCharset) {
if (detectedCharset === httpHeaderCharset) {
result = httpHeaderCharset;
} else if (detectedCharset === htmlMetaCharset) {
result = htmlMetaCharset;
}
}
if (!result) {
result = httpHeaderCharset || htmlMetaCharset || detectedCharset;
}
return result || 'utf-8';
};
const toUtf8 = function (contentType: string, body: Buffer): string {
return iconv.decode(body, getCharset(contentType, body));
};
const getUrlContent = async (urlObj: URL, redirectCount = 5): Promise<OEmbedUrlContent> => {
const portsProtocol = new Map<string, string>(
Object.entries({
80: 'http:',
8080: 'http:',
443: 'https:',
}),
);
const ignoredHosts =
settings
.get<string>('API_EmbedIgnoredHosts')
.replace(/\s/g, '')
.split(',')
.filter(Boolean)
.map((host) => host.toLowerCase()) || [];
const isIgnoredHost = (hostname: string | undefined): boolean => {
hostname = hostname?.toLowerCase();
if (!hostname || !ignoredHosts.length) {
return false;
}
const exactHosts = ignoredHosts.filter((h) => !h.includes('*'));
if (exactHosts.includes(hostname) || ipRangeCheck(hostname, exactHosts)) {
return true;
}
return ignoredHosts
.filter((h) => h.includes('*'))
.some((pattern) => {
const validationRegex = /^(?:\*\.)?(?:\*|[a-z0-9-]+)(?:\.(?:\*|[a-z0-9-]+))*$/i;
if (!validationRegex.test(pattern) || pattern === '*') {
return false;
}
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
const source = `^${escaped.replace(/\*/g, '[^.]*')}$`;
try {
const regex = new RegExp(source, 'i');
return regex.test(hostname);
} catch {
// fail safe on invalid patterns
return false;
}
});
};
if (isIgnoredHost(urlObj.hostname)) {
throw new Error('host is ignored');
}
const safePorts = settings.get<string>('API_EmbedSafePorts').replace(/\s/g, '').split(',') || [];
// checks if the URL port is in the safe ports list
if (safePorts.length > 0 && urlObj.port && !safePorts.includes(urlObj.port)) {
throw new Error('invalid/unsafe port');
}
// if port is not detected, use protocol to verify instead
if (safePorts.length > 0 && !urlObj.port && !safePorts.some((port) => portsProtocol.get(port) === urlObj.protocol)) {
throw new Error('invalid/unsafe port');
}
const data = beforeGetUrlContent({ urlObj });
const url = data.urlObj.toString();
const sizeLimit = 250000;
log.debug({ msg: 'Fetching URL for OEmbed', url, redirectCount });
const start = Date.now();
const response = await fetch(
url,
{
compress: true,
follow: redirectCount,
headers: {
'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version}`,
'Accept-Language': settings.get('Language') || 'en',
...data.headerOverrides,
},
timeout: settings.get<number>('API_EmbedTimeout') * 1000,
size: sizeLimit, // max size of the response body, this was not working as expected so I'm also manually verifying that on the iterator
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
},
settings.get('Allow_Invalid_SelfSigned_Certs'),
);
const end = Date.now();
let totalSize = 0;
const chunks = [];
for await (const chunk of response.body) {
totalSize += chunk.length;
chunks.push(chunk as Buffer);
if (totalSize > sizeLimit) {
log.warn({ msg: 'OEmbed request size exceeded', url });
break;
}
}
log.debug({ msg: 'Obtained response from server', length: totalSize, timeSpent: `${end - start}ms` });
const buffer = Buffer.concat(chunks);
return {
headers: Object.fromEntries(response.headers),
body: toUtf8(response.headers.get('content-type') || 'text/plain', buffer),
statusCode: response.status,
urlObj,
};
};
const parseUrl = async function (url: string): Promise<{ urlPreview: MessageUrl; foundMeta: boolean }> {
const parsedUrlObject: MessageUrl = { url, meta: {} };
let foundMeta = false;
if (!isAbsoluteURL(url)) {
return { urlPreview: parsedUrlObject, foundMeta };
}
const data = await getUrlMetaWithCache(url);
if (!data) {
return { urlPreview: parsedUrlObject, foundMeta };
}
if (isOEmbedUrlWithMetadata(data) && data.meta) {
parsedUrlObject.meta = getRelevantMetaTags(data.meta) || {};
if (parsedUrlObject.meta?.oembedHtml) {
parsedUrlObject.meta.oembedHtml = insertMaxWidthInOembedHtml(parsedUrlObject.meta.oembedHtml) || '';
}
}
foundMeta = true;
return {
urlPreview: {
...parsedUrlObject,
...((parsedUrlObject.headers || data.headers) && {
headers: {
...parsedUrlObject.headers,
...(data.headers?.contentLength && { contentLength: data.headers.contentLength }),
...(data.headers?.contentType && { contentType: data.headers.contentType }),
},
}),
},
foundMeta,
};
};
const getUrlMeta = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | OEmbedUrlContentResult | undefined> {
log.debug({ msg: 'Obtaining metadata for URL', url });
const urlObj = new URL(url);
if (withFragment) {
urlObj.searchParams.set('_escaped_fragment_', '');
}
log.debug({ msg: 'Fetching URL content', url: urlObj.toString() });
let content: OEmbedUrlContent | undefined;
try {
content = await getUrlContent(urlObj, 5);
} catch (err) {
log.error({ msg: 'Error fetching URL content', url: urlObj.toString(), err });
}
if (!content) {
return;
}
log.debug({ msg: 'Parsing metadata for URL', url });
const metas: OEmbedMeta = {} as any;
if (content?.body) {
const escapeMeta = (name: string, value: string): string => {
metas[name] = metas[name] || he.unescape(value);
return metas[name];
};
content.body.replace(/<title[^>]*>([^<]*)<\/title>/gim, (_meta, title) => {
return escapeMeta('pageTitle', title);
});
content.body.replace(/<meta[^>]*(?:name|property)=[']([^']*)['][^>]*\scontent=[']([^']*)['][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*(?:name|property)=["]([^"]*)["][^>]*\scontent=["]([^"]*)["][^>]*>/gim, (_meta, name, value) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=[']([^']*)['][^>]*(?:name|property)=[']([^']*)['][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
content.body.replace(/<meta[^>]*\scontent=["]([^"]*)["][^>]*(?:name|property)=["]([^"]*)["][^>]*>/gim, (_meta, value, name) => {
return escapeMeta(camelCase(name), value);
});
if (metas.fragment === '!' && withFragment == null) {
return getUrlMeta(url, true);
}
delete metas.oembedHtml;
}
const headers: { [k: string]: string } = {};
if (content?.headers) {
const headerObj = content.headers;
Object.keys(headerObj).forEach((header) => {
headers[camelCase(header)] = headerObj[header];
});
}
if (content && content.statusCode !== 200) {
return;
}
return afterParseUrlContent({
url,
meta: metas,
headers,
content,
});
};
const getUrlMetaWithCache = async function (
url: string,
withFragment?: boolean,
): Promise<OEmbedUrlWithMetadata | undefined | OEmbedUrlContentResult> {
log.debug({ msg: 'Getting OEmbed metadata for URL', url });
const cache = await OEmbedCache.findOneById(url);
if (cache) {
log.debug({ msg: 'Found OEmbed metadata in cache for URL', url });
return cache.data;
}
const data = await getUrlMeta(url, withFragment);
if (!data) {
return;
}
try {
log.debug({ msg: 'Saving OEmbed metadata in cache for URL', url });
await OEmbedCache.createWithIdAndData(url, data);
} catch (_error) {
log.error({ msg: 'OEmbed duplicated record', url });
}
return data;
};
const getRelevantMetaTags = function (metaObj: OEmbedMeta): Record<string, string> | void {
const tags: Record<string, string> = {};
Object.keys(metaObj).forEach((key) => {
const value = metaObj[key];
if (/^(og|fb|twitter|oembed|msapplication).+|description|title|pageTitle$/.test(key.toLowerCase()) && value && value.trim() !== '') {
tags[key] = value;
}
});
if (Object.keys(tags).length > 0) {
return tags;
}
};
const insertMaxWidthInOembedHtml = (oembedHtml?: string): string | undefined =>
oembedHtml?.replace('iframe', 'iframe style="max-width: 100%;width:400px;height:225px"');
const rocketUrlParser = async function (message: IMessage): Promise<IMessage> {
log.debug({ msg: 'Parsing message URLs' });
if (!settings.get('API_Embed')) {
return message;
}
if (!Array.isArray(message.urls)) {
return message;
}
log.debug({ msg: 'URLs found in message', count: message.urls.length });
if (
(message.attachments && message.attachments.length > 0) ||
message.urls.filter((item) => !item.url.includes(settings.get('Site_Url'))).length > MAX_EXTERNAL_URL_PREVIEWS
) {
log.debug({ msg: 'All URLs ignored for OEmbed' });
return message;
}
let changed = false;
for await (const item of message.urls) {
if (item.ignoreParse === true) {
log.debug({ msg: 'URL ignored for OEmbed', url: item.url });
continue;
}
const { urlPreview, foundMeta } = await parseUrl(item.url);
Object.assign(item, foundMeta ? urlPreview : {});
changed = changed || foundMeta;
}
if (changed === true) {
await Messages.setUrlsById(message._id, message.urls);
}
return message;
};
export const OEmbed: {
rocketUrlParser: (message: IMessage) => Promise<IMessage>;
parseUrl: (url: string) => Promise<{ urlPreview: MessageUrl; foundMeta: boolean }>;
} = {
rocketUrlParser,
parseUrl,
};

View File

@@ -0,0 +1,18 @@
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import type { AtLeast, IMessage, IRoom } from '@rocket.chat/core-typings';
import { isFederationEnabled } from '../../federation/utils';
export class FederationActions {
public static shouldPerformAction(_message: IMessage, room: AtLeast<IRoom, 'federated'>): boolean {
if (!isRoomFederated(room)) {
return true;
}
if (!isRoomNativeFederated(room)) {
return false;
}
return isFederationEnabled();
}
}

View File

@@ -0,0 +1,71 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import type BadWordsFilter from 'bad-words';
export class BeforeSaveBadWords {
badWords: BadWordsFilter | null = null;
badWordsRegex: RegExp | null = null;
protected logger: Logger;
constructor() {
this.logger = new Logger('BadWordsFilter');
}
async configure(badWordsList?: string, goodWordsList?: string) {
const { default: Filter } = await import('bad-words');
const badWords =
badWordsList
?.split(',')
.map((word) => word.trim())
.filter(Boolean) || [];
const options = {
list: badWords,
// library definition does not allow optional definition
exclude: undefined,
splitRegex: undefined,
placeHolder: undefined,
regex: undefined,
replaceRegex: undefined,
emptyList: undefined,
};
this.badWords = new Filter(options);
try {
this.badWordsRegex = new RegExp(`(?<=^|[\\p{P}\\p{Z}])(${badWords.join('|')})(?=$|[\\p{P}\\p{Z}])`, 'gmiu');
} catch (err) {
this.badWordsRegex = null;
this.logger.error({ msg: 'Error when initializing bad words filter', err });
}
if (goodWordsList?.length) {
this.badWords.removeWords(...goodWordsList.split(',').map((word) => word.trim()));
}
}
disable() {
this.badWords = null;
this.badWordsRegex = null;
}
async filterBadWords({ message }: { message: IMessage }): Promise<IMessage> {
if (!message.msg || !this.badWords) {
return message;
}
try {
message.msg = this.badWords.clean(message.msg);
} catch (error) {
// ignore
} finally {
if (this.badWordsRegex) {
message.msg = message.msg.replace(this.badWordsRegex, (match) => '*'.repeat(match.length));
}
}
return message;
}
}

View File

@@ -0,0 +1,24 @@
import { MeteorError, Omnichannel } from '@rocket.chat/core-services';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
export class BeforeSaveCheckMAC {
async isWithinLimits({ message, room }: { message: IMessage; room: IRoom }): Promise<void> {
if (!isOmnichannelRoom(room)) {
return;
}
if (message.token) {
return;
}
if (message.t) {
return;
}
const canSendMessage = await Omnichannel.isWithinMACLimit(room);
if (!canSendMessage) {
throw new MeteorError('error-mac-limit-reached');
}
}
}

View File

@@ -0,0 +1,172 @@
import QueryString from 'node:querystring';
import URL from 'node:url';
import type { MessageAttachment, IMessage, IUser, IOmnichannelRoom, IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, isQuoteAttachment } from '@rocket.chat/core-typings';
import { createQuoteAttachment } from '../../../../lib/createQuoteAttachment';
const recursiveRemoveAttachments = (attachments: MessageAttachment, deep = 1, quoteChainLimit: number): MessageAttachment => {
if (attachments && isQuoteAttachment(attachments)) {
if (deep < quoteChainLimit - 1) {
attachments.attachments?.map((msg) => recursiveRemoveAttachments(msg, deep + 1, quoteChainLimit));
} else if (attachments.attachments) {
attachments.attachments = attachments.attachments.filter((attachment) => !isQuoteAttachment(attachment));
if (attachments.attachments.length === 0) {
delete attachments.attachments;
}
}
}
return attachments;
};
const validateAttachmentDeepness = (message: IMessage, quoteChainLimit: number): IMessage => {
if (!message?.attachments) {
return message;
}
if ((message.attachments && quoteChainLimit < 2) || isNaN(quoteChainLimit)) {
message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment));
if (message.attachments.length === 0) {
delete message.attachments;
}
}
message.attachments = message.attachments?.map((attachment) => recursiveRemoveAttachments(attachment, 1, quoteChainLimit));
return message;
};
const removeQuoteAttachments = (message: IMessage) => {
if (!message.attachments) {
return;
}
message.attachments = message.attachments.filter((attachment) => !isQuoteAttachment(attachment));
};
type JumpToMessageInit = {
getMessages(messageIds: IMessage['_id'][]): Promise<IMessage[]>;
getRooms(roomIds: IRoom['_id'][]): Promise<IRoom[] | IOmnichannelRoom[] | null>;
canAccessRoom(room: IRoom, user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>): Promise<boolean>;
getUserAvatarURL(user?: string): string;
};
/**
* Transform URLs in messages into quote attachments
*/
export class BeforeSaveJumpToMessage {
private getMessages: JumpToMessageInit['getMessages'];
private getRooms: JumpToMessageInit['getRooms'];
private canAccessRoom: JumpToMessageInit['canAccessRoom'];
private getUserAvatarURL: JumpToMessageInit['getUserAvatarURL'];
constructor(options: JumpToMessageInit) {
this.getMessages = options.getMessages;
this.getRooms = options.getRooms;
this.canAccessRoom = options.canAccessRoom;
this.getUserAvatarURL = options.getUserAvatarURL;
}
async createAttachmentForMessageURLs({
message,
user: currentUser,
config,
}: {
message: IMessage;
user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>;
config: {
chainLimit: number;
siteUrl: string;
useRealName: boolean;
};
}): Promise<IMessage> {
// Quote attachments are always rebuilt. Do not keep old ones since they may not still be linked to the message
removeQuoteAttachments(message);
// if no message is present, or the message doesn't have any URL, skip
if (!message?.urls?.length) {
return message;
}
const linkedMessages = message.urls
.filter((item) => item.url.includes(config.siteUrl))
.map((item) => {
const urlObj = URL.parse(item.url);
// if the URL doesn't have query params (doesn't reference message) skip
if (!urlObj.query) {
return;
}
const { msg: msgId } = QueryString.parse(urlObj.query);
if (typeof msgId !== 'string') {
return;
}
return { msgId, url: item.url };
})
.filter(Boolean);
const msgs = await this.getMessages(linkedMessages.map((linkedMsg) => linkedMsg?.msgId) as string[]);
const validMessages = msgs.filter((msg) => validateAttachmentDeepness(msg, config.chainLimit));
const rooms = await this.getRooms(validMessages.map((msg) => msg.rid));
const roomsWithPermission =
rooms &&
(await Promise.all(
rooms.map(async (room) => {
if (!!message.token && isOmnichannelRoom(room) && !!room.v?.token && message.token === room.v.token) {
return room;
}
if (currentUser && (await this.canAccessRoom(room, currentUser))) {
return room;
}
}),
));
const validRooms = roomsWithPermission?.filter((room) => !!room);
const { useRealName } = config;
const quotes = [];
for (const item of message.urls) {
if (!item.url.includes(config.siteUrl)) {
continue;
}
const linkedMessage = linkedMessages.find((msg) => msg?.url === item.url);
if (!linkedMessage) {
continue;
}
const messageFromUrl = validMessages.find((msg) => msg._id === linkedMessage.msgId);
if (!messageFromUrl) {
continue;
}
if (!validRooms?.find((room) => room?._id === messageFromUrl.rid)) {
continue;
}
item.ignoreParse = true;
quotes.push(createQuoteAttachment(messageFromUrl, item.url, useRealName, this.getUserAvatarURL(messageFromUrl.u.username)));
}
if (quotes.length > 0) {
const currentAttachments = message.attachments || [];
message.attachments = [...currentAttachments, ...quotes];
}
return message;
}
}

View File

@@ -0,0 +1,51 @@
import { isE2EEMessage } from '@rocket.chat/core-typings';
import type { IMessage } from '@rocket.chat/core-typings';
import { parse } from '@rocket.chat/message-parser';
import { getMessageMaxParseLength } from '../../../../lib/getMessageMaxParseLength';
type ParserConfig = {
colors?: boolean;
emoticons?: boolean;
customDomains?: string[];
katex?: {
dollarSyntax: boolean;
parenthesisSyntax: boolean;
};
};
export class BeforeSaveMarkdownParser {
constructor(private enabled: boolean = true) {
// no op
}
async parseMarkdown({ message, config }: { message: IMessage; config: ParserConfig }): Promise<IMessage> {
if (!this.enabled) {
return message;
}
if (isE2EEMessage(message)) {
return message;
}
const messageMaxParseLength = getMessageMaxParseLength();
if (messageMaxParseLength > 0 && message.msg && message.msg.length > messageMaxParseLength) {
delete message.md;
return message;
}
try {
if (message.msg) {
message.md = parse(message.msg, config);
}
if (message.attachments?.[0]?.description) {
message.attachments[0].descriptionMd = parse(message.attachments[0].description, config);
}
} catch (e) {
console.error(e); // errors logged while the parser is at experimental stage
}
return message;
}
}

View File

@@ -0,0 +1,87 @@
import { api, Team, MeteorError } from '@rocket.chat/core-services';
import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
import { Subscriptions, Users, Rooms } from '@rocket.chat/models';
import { i18n } from '../../../lib/i18n';
import { MentionsServer } from '../../../lib/messaging/mentions/Mentions';
import { settings } from '../../../settings';
class MentionQueries {
async getUsers(usernames: string[]): Promise<{ type: 'team' | 'user'; _id: string; username?: string; name?: string }[]> {
const uniqueUsernames = [...new Set(usernames)];
const teams = await Team.listByNames(uniqueUsernames, { projection: { name: 1 } });
const users = await Users.find<Pick<IUser, '_id' | 'username' | 'name'>>(
{ username: { $in: uniqueUsernames } },
{ projection: { _id: 1, username: 1, name: 1 } },
).toArray();
const taggedUsers = users.map((user) => ({
...user,
type: 'user' as const,
}));
if (settings.get<boolean>('Troubleshoot_Disable_Teams_Mention')) {
return taggedUsers;
}
const taggedTeams = teams.map((team) => ({
...team,
type: 'team' as const,
}));
return [...taggedUsers, ...taggedTeams];
}
async getUser(userId: string): Promise<IUser | null> {
return Users.findOneById(userId);
}
getTotalChannelMembers(rid: string): Promise<number> {
return Subscriptions.countByRoomId(rid);
}
getChannels(channels: string[]): Promise<Pick<IRoom, '_id' | 'name' | 'fname' | 'federated'>[]> {
return Rooms.find(
{
$and: [
{
$or: [
{ $and: [{ $or: [{ federated: { $exists: false } }, { federated: false }], name: { $in: [...new Set(channels)] } }] },
{ federated: true, fname: { $in: [...new Set(channels)] } },
],
},
],
t: { $in: ['c', 'p'] },
},
{ projection: { _id: 1, name: 1, fname: 1, federated: 1 } },
).toArray();
}
}
const queries = new MentionQueries();
export const mentionServer = new MentionsServer({
pattern: () => settings.get<string>('UTF8_User_Names_Validation'),
messageMaxAll: () => settings.get<number>('Message_MaxAll'),
getUsers: async (usernames: string[]) => queries.getUsers(usernames),
getUser: async (userId: string) => queries.getUser(userId),
getTotalChannelMembers: (rid: string) => queries.getTotalChannelMembers(rid),
getChannels: (channels: string[]) => queries.getChannels(channels),
async onMaxRoomMembersExceeded({ sender, rid }: { sender: IMessage['u']; rid: string }): Promise<void> {
// Get the language of the user for the error notification.
const { language } = (await this.getUser(sender._id)) || {};
const msg = i18n.t('Group_mentions_disabled_x_members', { total: this.messageMaxAll(), lng: language });
void api.broadcast('notify.ephemeralMessage', sender._id, rid, {
msg,
});
// Also throw to stop propagation of 'sendMessage'.
throw new MeteorError('error-action-not-allowed', msg, {
method: 'filterATAllTag',
action: msg,
});
},
});

View File

@@ -0,0 +1,38 @@
import { Authorization, MeteorError } from '@rocket.chat/core-services';
import type { IMessage, IUser } from '@rocket.chat/core-typings';
import { i18n } from '../../../lib/i18n';
export class BeforeSavePreventMention {
async preventMention({
message,
user,
mention,
permission,
}: {
message: IMessage;
user: Pick<IUser, '_id' | 'username' | 'name' | 'language'>;
mention: 'here' | 'all';
permission: string;
}): Promise<boolean> {
if (!message.mentions?.some(({ _id }) => _id === mention)) {
return true;
}
// Check if the user has permissions to use @all in both global and room scopes.
if (await Authorization.hasPermission(message.u._id, permission)) {
return true;
}
if (await Authorization.hasPermission(message.u._id, permission, message.rid)) {
return true;
}
const action = i18n.t('Notify_all_in_this_room', { lng: user.language });
// Also throw to stop propagation of 'sendMessage'.
throw new MeteorError('error-action-not-allowed', `Notify ${mention} in this room not allowed`, {
action,
});
}
}

View File

@@ -0,0 +1,38 @@
import type { IMessage } from '@rocket.chat/core-typings';
// look for spotify syntax (e.g.: spotify:track:1q6IK1l4qpYykOaWaLJkWG) on the message and add them to the urls array
export class BeforeSaveSpotify {
async convertSpotifyLinks({ message }: { message: IMessage }): Promise<IMessage> {
if (!message.msg?.trim()) {
return message;
}
const urls = Array.isArray(message.urls) ? message.urls : [];
let changed = false;
const msgParts = message.msg.split(/(```\w*[\n ]?[\s\S]*?```+?)|(`(?:[^`]+)`)/);
for (let index = 0; index < msgParts.length; index++) {
const part = msgParts[index];
if (!/(?:```(\w*)[\n ]?([\s\S]*?)```+?)|(?:`(?:[^`]+)`)/.test(part)) {
const re = /(?:^|\s)spotify:([^:\s]+):([^:\s]+)(?::([^:\s]+))?(?::(\S+))?(?:\s|$)/g;
let match;
while ((match = re.exec(part)) != null) {
const data = match.slice(1).filter(Boolean);
const path = data.map((value) => encodeURI(value)).join('/');
const url = `https://open.spotify.com/${path}`;
urls.push({ url, source: `spotify:${data.join(':')}`, meta: {} });
changed = true;
}
}
}
if (changed) {
message.urls = urls;
}
return message;
}
}

View File

@@ -0,0 +1,196 @@
import type { OEmbedMeta, OEmbedUrlContent, OEmbedProvider } from '@rocket.chat/core-typings';
import { camelCase } from 'change-case';
import { Info } from '../../../../../app/utils/rocketchat.info';
import { SystemLogger } from '../../../../lib/logger/system';
import { settings } from '../../../../settings';
class Providers {
private providers: OEmbedProvider[];
constructor() {
this.providers = [];
}
static getConsumerUrl(provider: OEmbedProvider, url: string): string | undefined {
if (!provider.endPoint) {
return;
}
const urlObj = new URL(provider.endPoint);
urlObj.searchParams.set('url', url);
return urlObj.toString();
}
static getCustomHeaders(provider: OEmbedProvider): { [k: string]: string } {
return provider.getHeaderOverrides?.() || {};
}
registerProvider(provider: OEmbedProvider): number {
return this.providers.push(provider);
}
getProviders(): OEmbedProvider[] {
return this.providers;
}
getProviderForUrl(url: string): OEmbedProvider | undefined {
return this.providers?.find((provider) => {
return (
provider.urls?.some((re) => {
return re.test(url);
}) ?? false
);
});
}
}
const providers = new Providers();
providers.registerProvider({
urls: [new RegExp('https?://soundcloud\\.com/\\S+')],
endPoint: 'https://soundcloud.com/oembed?format=json&maxheight=150',
});
providers.registerProvider({
urls: [
new RegExp('https?://vimeo\\.com/[^/]+'),
new RegExp('https?://vimeo\\.com/channels/[^/]+/[^/]+'),
new RegExp('https://vimeo\\.com/groups/[^/]+/videos/[^/]+'),
],
endPoint: 'https://vimeo.com/api/oembed.json?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.youtube\\.com/\\S+'), new RegExp('https?://youtu\\.be/\\S+')],
endPoint: 'https://www.youtube.com/oembed?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.rdio\\.com/\\S+'), new RegExp('https?://rd\\.io/\\S+')],
endPoint: 'https://www.rdio.com/api/oembed/?format=json&maxheight=150',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.slideshare\\.net/[^/]+/[^/]+')],
endPoint: 'https://www.slideshare.net/api/oembed/2?format=json&maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.dailymotion\\.com/video/\\S+')],
endPoint: 'https://www.dailymotion.com/services/oembed?maxheight=200',
});
providers.registerProvider({
urls: [new RegExp('https?://(twitter|x)\\.com/[^/]+/status/\\S+')],
getHeaderOverrides: () => {
return {
'User-Agent': `${settings.get('API_Embed_UserAgent')} Rocket.Chat/${Info.version} Googlebot/2.1`,
};
},
});
providers.registerProvider({
urls: [new RegExp('https?://(play|open)\\.spotify\\.com/(track|album|playlist|show)/\\S+')],
endPoint: 'https://open.spotify.com/oembed',
});
providers.registerProvider({
urls: [new RegExp('https?://www\\.loom\\.com/\\S+')],
endPoint: 'https://www.loom.com/v1/oembed?format=json',
});
if (process.env.TEST_MODE) {
// resolves the oembed payload from the CI mock-server instead of a real provider — see the oembed suite in tests/end-to-end/api/chat.ts
providers.registerProvider({
urls: [new RegExp('https?://mock-server\\.dev(:\\d+)?/video/\\S+')],
endPoint: 'http://mock-server.dev:8080/oembed',
});
}
export const beforeGetUrlContent = (data: {
urlObj: URL;
}): {
urlObj: URL;
headerOverrides?: { [k: string]: string };
} => {
if (!data.urlObj) {
return data;
}
const url = data.urlObj.toString();
const provider = providers.getProviderForUrl(url);
if (!provider) {
return data;
}
const consumerUrl = Providers.getConsumerUrl(provider, url);
const headerOverrides = Providers.getCustomHeaders(provider);
if (!consumerUrl) {
return { ...data, headerOverrides };
}
return { ...data, headerOverrides, urlObj: new URL(consumerUrl) };
};
const cleanupOembed = (data: {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
}): {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
} => {
if (!data?.meta) {
return data;
}
// remove oembedHtml key from original meta
const { oembedHtml, ...meta } = data.meta;
return {
...data,
meta: meta as OEmbedMeta,
};
};
export const afterParseUrlContent = (data: {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
}): {
url: string;
meta: OEmbedMeta;
headers: { [k: string]: string };
content: OEmbedUrlContent;
} => {
if (!data?.url || !data.content?.body) {
return cleanupOembed(data);
}
const provider = providers.getProviderForUrl(data.url);
if (!provider) {
return cleanupOembed(data);
}
data.meta.oembedUrl = data.url;
try {
const metas = JSON.parse(data.content.body);
Object.entries(metas).forEach(([key, value]) => {
if (value && typeof value === 'string') {
data.meta[camelCase(`oembed_${key}`)] = value;
}
});
} catch (err) {
SystemLogger.error({ err });
}
return data;
};

View File

@@ -0,0 +1,352 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type { IMessageService } from '@rocket.chat/core-services';
import { Authorization, ServiceClassInternal } from '@rocket.chat/core-services';
import { isEditedMessage } from '@rocket.chat/core-typings';
import type { MessageUrl, IMessage, MessageTypesValues, IUser, IRoom, AtLeast } from '@rocket.chat/core-typings';
import { Messages, Rooms } from '@rocket.chat/models';
import { OEmbed } from './hooks/AfterSaveOEmbed';
import { BeforeSaveCannedResponse } from '../../../ee/server/hooks/messages/BeforeSaveCannedResponse';
import { FederationMatrixInvalidConfigurationError } from '../federation/utils';
import { FederationActions } from './hooks/BeforeFederationActions';
import { BeforeSaveBadWords } from './hooks/BeforeSaveBadWords';
import { BeforeSaveCheckMAC } from './hooks/BeforeSaveCheckMAC';
import { BeforeSaveJumpToMessage } from './hooks/BeforeSaveJumpToMessage';
import { BeforeSaveMarkdownParser } from './hooks/BeforeSaveMarkdownParser';
import { mentionServer } from './hooks/BeforeSaveMentions';
import { BeforeSavePreventMention } from './hooks/BeforeSavePreventMention';
import { BeforeSaveSpotify } from './hooks/BeforeSaveSpotify';
import { closeUnclosedCodeBlock } from '../../../lib/utils/closeUnclosedCodeBlock';
import { notifyUsersOnSystemMessage } from '../../hooks/messages/notifyUsersOnMessage';
import { SystemLogger } from '../../lib/logger/system';
import { deleteMessage } from '../../lib/messages/deleteMessage';
import { parseUrlsInMessage } from '../../lib/messages/parseUrlsInMessage';
import { sendMessage } from '../../lib/messages/sendMessage';
import { updateMessage } from '../../lib/messages/updateMessage';
import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage';
import { executeSetReaction } from '../../lib/messaging/reactions/setReaction';
import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/notifyListener';
import { shouldBreakInVersion } from '../../lib/shouldBreakInVersion';
import { getUserAvatarURL } from '../../lib/utils/getUserAvatarURL';
import { executeSendMessage } from '../../meteor-methods/messages/sendMessage';
import { settings } from '../../settings';
const disableMarkdownParser = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_PARSER).toLowerCase());
export class MessageService extends ServiceClassInternal implements IMessageService {
protected name = 'message';
private preventMention: BeforeSavePreventMention;
private badWords: BeforeSaveBadWords;
private spotify: BeforeSaveSpotify;
private jumpToMessage: BeforeSaveJumpToMessage;
private cannedResponse: BeforeSaveCannedResponse;
private markdownParser: BeforeSaveMarkdownParser;
private checkMAC: BeforeSaveCheckMAC;
override async created() {
this.preventMention = new BeforeSavePreventMention();
this.badWords = new BeforeSaveBadWords();
this.spotify = new BeforeSaveSpotify();
this.jumpToMessage = new BeforeSaveJumpToMessage({
getMessages(messageIds) {
return Messages.findVisibleByIds(messageIds).toArray();
},
getRooms(roomIds) {
return Rooms.findByIds(roomIds).toArray();
},
canAccessRoom(room: IRoom, user: IUser): Promise<boolean> {
return Authorization.canAccessRoom(room, user);
},
getUserAvatarURL(user?: string): string {
return (user && getUserAvatarURL(user)) || '';
},
});
this.cannedResponse = new BeforeSaveCannedResponse();
this.markdownParser = new BeforeSaveMarkdownParser(!disableMarkdownParser);
this.checkMAC = new BeforeSaveCheckMAC();
await this.configureBadWords();
}
private async configureBadWords() {
settings.watchMultiple(
['Message_AllowBadWordsFilter', 'Message_BadWordsFilterList', 'Message_BadWordsWhitelist'],
async ([enabled, badWordsList, whiteList]) => {
if (!enabled) {
this.badWords.disable();
return;
}
await this.badWords.configure(badWordsList as string, whiteList as string);
},
);
}
async sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise<IMessage> {
return executeSendMessage(fromId, { rid, msg });
}
async saveMessageFromFederation({
fromId,
rid,
federation_event_id,
msg,
e2e_content,
file,
files,
attachments,
thread,
ts,
}: {
fromId: string;
rid: string;
federation_event_id: string;
msg?: string;
e2e_content?: {
algorithm: 'm.megolm.v1.aes-sha2';
ciphertext: string;
};
file?: IMessage['file'];
files?: IMessage['files'];
attachments?: IMessage['attachments'];
thread?: { tmid: string; tshow: boolean };
ts: Date;
}): Promise<IMessage> {
return executeSendMessage(
fromId,
{
rid,
msg,
...thread,
federation: {
eventId: federation_event_id,
version: 1,
},
...(file && { file }),
...(files && { files }),
...(attachments && { attachments }),
...(e2e_content && {
t: 'e2e',
content: e2e_content,
}),
},
{ ts },
);
}
async sendMessageWithValidation(user: IUser, message: Partial<IMessage>, room: Partial<IRoom>, upsert = false): Promise<IMessage> {
return sendMessage(user, message, room, { upsert });
}
async deleteMessage(user: IUser, message: IMessage): Promise<void> {
return deleteMessage(message, user);
}
async updateMessage(message: IMessage, user: IUser, originalMsg?: IMessage, previewUrls?: string[]): Promise<void> {
return updateMessage(message, user, originalMsg, previewUrls);
}
async reactToMessage(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean): Promise<void> {
return executeSetReaction(userId, reaction, messageId, shouldReact);
}
async saveSystemMessageAndNotifyUser<T = IMessage>(
type: MessageTypesValues,
rid: string,
messageText: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const createdMessage = await this.saveSystemMessage(type, rid, messageText, owner, extraData);
const room = await Rooms.findOneById(rid);
if (!room) {
throw new Error('Failed to find the room.');
}
await notifyUsersOnSystemMessage(createdMessage, room);
return createdMessage;
}
async saveSystemMessage<T = IMessage>(
type: MessageTypesValues,
rid: string,
message: string,
owner: Pick<IUser, '_id' | 'username' | 'name'>,
extraData?: Partial<T>,
): Promise<IMessage> {
const { _id: userId, username, name } = owner;
if (!username) {
throw new Error('The username cannot be empty.');
}
const [{ insertedId }, room] = await Promise.all([
Messages.createWithTypeRoomIdMessageUserAndUnread(
type,
rid,
message,
{ _id: userId, username, name },
settings.get('Message_Read_Receipt_Enabled'),
extraData,
),
Rooms.findOneAndIncMsgCountById(rid, 1, { projection: { prid: 1, msgs: 1, lm: 1, sysMes: 1 } }),
]);
if (!insertedId) {
throw new Error('Failed to save system message.');
}
const createdMessage = await Messages.findOneById(insertedId);
if (!createdMessage) {
throw new Error('Failed to find the created message.');
}
if (Apps.self?.isLoaded()) {
void Apps.self?.triggerEvent(AppEvents.IPostSystemMessageSent, createdMessage);
}
void notifyOnMessageChange({ id: createdMessage._id, data: createdMessage });
void notifyOnRoomChangedById(rid);
if (room?.prid) {
try {
await updateAndNotifyParentRoomWithParentMessage(room);
} catch (err) {
SystemLogger.error({ msg: 'Failed to propagate discussion metadata', err, rid });
}
}
return createdMessage;
}
async beforeSave({
message,
room,
user,
previewUrls,
parseUrls = true,
}: {
message: IMessage;
room: IRoom;
user: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'language'>;
previewUrls?: string[];
parseUrls?: boolean;
}): Promise<IMessage> {
// TODO looks like this one was not being used (so I'll left it commented)
// await this.joinDiscussionOnMessage({ message, room, user });
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to send message');
}
message = await this.cannedResponse.replacePlaceholders({ message, room, user });
message = await this.badWords.filterBadWords({ message });
// TODO: Auto-close unclosed markdown code blocks for server versions below 9.0.0
// In 9.0.0, this behavior is handled on the client side, so this block should be removed.
if (!shouldBreakInVersion('9.0.0') && message.msg) {
message = { ...message, msg: closeUnclosedCodeBlock(message.msg) };
}
message = await this.markdownParser.parseMarkdown({ message, config: this.getMarkdownConfig() });
message = await mentionServer.execute(message);
if (parseUrls) {
message.urls = parseUrlsInMessage(message, previewUrls);
}
message = await this.spotify.convertSpotifyLinks({ message });
message = await this.jumpToMessage.createAttachmentForMessageURLs({
message,
user,
config: {
chainLimit: settings.get<number>('Message_QuoteChainLimit'),
siteUrl: settings.get<string>('Site_Url'),
useRealName: settings.get<boolean>('UI_Use_Real_Name'),
},
});
if (!this.isEditedOrOld(message)) {
await Promise.all([
this.checkMAC.isWithinLimits({ message, room }),
this.preventMention.preventMention({ message, user, mention: 'all', permission: 'mention-all' }),
this.preventMention.preventMention({ message, user, mention: 'here', permission: 'mention-here' }),
]);
}
return message;
}
// The actions made on this event should be asynchronous
// That means, caller should not expect to receive updated message
// after calling
async afterSave({ message }: { message: IMessage }): Promise<void> {
await OEmbed.rocketUrlParser(message);
// Since this will happen after the message is sent and ack on the UI
// we'll notify until after these hooks are finished
void notifyOnMessageChange({ id: message._id });
}
private getMarkdownConfig() {
const customDomainAutoLink = settings.get<string>('Message_CustomDomain_AutoLink');
const customDomains = customDomainAutoLink ? customDomainAutoLink.split(',').map((domain) => domain.trim()) : [];
return {
colors: settings.get<boolean>('HexColorPreview_Enabled'),
emoticons: true,
customDomains,
...(settings.get<boolean>('Katex_Enabled') && {
katex: {
dollarSyntax: settings.get<boolean>('Katex_Dollar_Syntax'),
parenthesisSyntax: settings.get<boolean>('Katex_Parenthesis_Syntax'),
},
}),
};
}
private isEditedOrOld(message: IMessage): boolean {
return isEditedMessage(message) || !message.ts || Math.abs(Date.now() - message.ts.getTime()) > 60000;
}
// joinDiscussionOnMessage
// private async joinDiscussionOnMessage({ message, room, user }: { message: IMessage; room: IRoom; user: IUser }) {
// // abort if room is not a discussion
// if (!room.prid) {
// return;
// }
// // check if user already joined the discussion
// const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, message.u._id, {
// projection: { _id: 1 },
// });
// if (sub) {
// return;
// }
// await Room.join({ room, user });
// }
async beforeReacted(message: IMessage, room: AtLeast<IRoom, 'federated'>) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to react to message');
}
}
async beforeDelete(message: IMessage, room: IRoom) {
if (!FederationActions.shouldPerformAction(message, room)) {
throw new FederationMatrixInvalidConfigurationError('Unable to delete message');
}
}
async parseOEmbedUrl(url: string): Promise<{
urlPreview: MessageUrl;
foundMeta: boolean;
}> {
return OEmbed.parseUrl(url);
}
}

View File

@@ -0,0 +1,218 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
import type { AutoUpdateRecord, IMeteor } from '@rocket.chat/core-services';
import type { ILivechatAgent, LoginServiceConfiguration, UserStatus } from '@rocket.chat/core-typings';
import { LoginServiceConfiguration as LoginServiceConfigurationModel, Users } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import { Meteor } from 'meteor/meteor';
import { processOnChange, serviceConfigCallbacks } from './userReactivity';
import { configureEmailInboxes } from '../../features/EmailInbox/EmailInbox';
import { isOutgoingIntegration } from '../../lib/integrations/lib/definition';
import { triggerHandler } from '../../lib/integrations/lib/triggerHandler';
import { metrics } from '../../lib/metrics';
import notifications from '../../lib/notifications/core/lib/Notifications';
import { notifyGuestStatusChanged } from '../../lib/omnichannel/guests';
import { onlineAgents, monitorAgents } from '../../lib/omnichannel/stream/agentStatus';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { getURL } from '../../lib/utils/getURL';
import { ListenersModule } from '../../modules/listeners/listeners.module';
import { invalidate as invalidatePublicationUserCache } from '../../modules/streamer/publication-user-cache';
import { settings } from '../../settings';
import { use } from '../../settings/Middleware';
import { setValue, updateValue } from '../../settings/raw';
const disableMsgRoundtripTracking = ['yes', 'true'].includes(String(process.env.DISABLE_MESSAGE_ROUNDTRIP_TRACKING).toLowerCase());
settings.set = use(settings.set, (context, next) => {
next(...context);
const [record] = context;
updateValue(record._id, record);
});
const clientVersionsStore = new Map<string, AutoUpdateRecord>();
export class MeteorService extends ServiceClassInternal implements IMeteor {
protected name = 'meteor';
constructor() {
super();
new ListenersModule(this, notifications);
this.onEvent('user.forceLogout', (uid: string, sessionId?: string) => {
if (sessionId) {
const sessions = Meteor.server.sessions.get(sessionId);
sessions?.connectionHandle.close();
return;
}
Meteor.server.sessions.forEach((session) => {
if (session.userId === uid) {
session.connectionHandle.close();
}
});
});
this.onEvent('watch.settings', async ({ clientAction, setting }): Promise<void> => {
if (clientAction !== 'removed') {
settings.set(setting);
return;
}
settings.set({ ...setting, value: undefined });
setValue(setting._id, undefined);
});
this.onEvent('watch.loginServiceConfiguration', ({ clientAction, id, data }) => {
if (clientAction === 'removed') {
serviceConfigCallbacks.forEach((callbacks) => {
wrapExceptions(() => callbacks.removed?.(id)).suppress();
});
return;
}
if (data) {
serviceConfigCallbacks.forEach((callbacks) => {
wrapExceptions(() => callbacks[clientAction === 'inserted' ? 'added' : 'changed']?.(id, data)).suppress();
});
}
});
this.onEvent('watch.users', async (data) => {
invalidatePublicationUserCache(data.id);
if (data.clientAction === 'updated' && data.diff) {
processOnChange(data.diff, data.id);
}
if (!monitorAgents) {
return;
}
if (data.clientAction !== 'removed' && 'diff' in data && !data.diff.status && !data.diff.statusLivechat) {
return;
}
switch (data.clientAction) {
case 'updated':
case 'inserted':
const agent = await Users.findOneAgentById<Pick<ILivechatAgent, 'status' | 'statusLivechat'>>(data.id, {
projection: {
status: 1,
statusLivechat: 1,
},
});
const serviceOnline = agent && agent.status !== 'offline' && agent.statusLivechat === 'available';
if (serviceOnline) {
return onlineAgents.add(data.id);
}
onlineAgents.remove(data.id);
break;
case 'removed':
onlineAgents.remove(data.id);
break;
}
});
this.onEvent('watch.integrations', async ({ clientAction, id, data }) => {
switch (clientAction) {
case 'inserted':
if (isOutgoingIntegration(data)) {
triggerHandler.addIntegration(data);
}
break;
case 'updated':
if (isOutgoingIntegration(data)) {
triggerHandler.removeIntegration(data);
triggerHandler.addIntegration(data);
}
break;
case 'removed':
triggerHandler.removeIntegration({ _id: id });
break;
}
});
this.onEvent('watch.emailInbox', async () => {
await configureEmailInboxes();
});
if (!disableMsgRoundtripTracking) {
this.onEvent('watch.messages', async ({ message }) => {
if (message?._updatedAt instanceof Date) {
const elapsedMs = Date.now() - message._updatedAt.getTime();
metrics.messageRoundtripTime.observe(elapsedMs);
metrics.messageRoundtripTimeSeconds.observe(elapsedMs / 1000);
}
});
}
}
override async started(): Promise<void> {
// Even after server startup, client versions might not be updated yet, the only way
// to make sure we can send the most up to date versions is using the publication below.
// Since it receives each document one at a time, we have to store them to be able to send
// them all when needed (i.e.: on ddp-streamer startup).
Meteor.server.publish_handlers.meteor_autoupdate_clientVersions.call({
added(_collection: string, _id: string, version: AutoUpdateRecord) {
clientVersionsStore.set(_id, version);
void api.broadcast('meteor.clientVersionUpdated', version);
},
changed(_collection: string, _id: string, version: AutoUpdateRecord) {
clientVersionsStore.set(_id, version);
void api.broadcast('meteor.clientVersionUpdated', version);
},
onStop() {
//
},
ready() {
//
},
});
}
async getAutoUpdateClientVersions(): Promise<Record<string, AutoUpdateRecord>> {
return Object.fromEntries(clientVersionsStore);
}
async getLoginServiceConfiguration(): Promise<LoginServiceConfiguration[]> {
return LoginServiceConfigurationModel.find({}, { projection: { secret: 0 } }).toArray();
}
async callMethodWithToken(
userId: string,
token: string,
method: string,
args: any[],
): Promise<{
result: unknown;
}> {
const user = await Users.findOneByIdAndLoginHashedToken(userId, token, {
projection: { _id: 1 },
});
if (!user) {
return {
result: await Meteor.callAsync(method, ...args),
};
}
return {
result: await Meteor.runAsUser(userId, () => Meteor.callAsync(method, ...args)),
};
}
async notifyGuestStatusChanged(token: string, status: UserStatus): Promise<void> {
return notifyGuestStatusChanged(token, status);
}
async getURL(path: string, params: Record<string, any> = {}, cloudDeepLinkUrl?: string): Promise<string> {
return getURL(path, params, cloudDeepLinkUrl);
}
async getMessageURLToReplyTo(roomType: string, roomId: string, messageIdToReplyTo: string): Promise<string> {
return getURL(`${roomCoordinator.getRouteLink(roomType, { rid: roomId })}?msg=${messageIdToReplyTo}`, { full: true });
}
}

View File

@@ -0,0 +1,136 @@
import { api } from '@rocket.chat/core-services';
import { Settings, Users } from '@rocket.chat/models';
import { getLoginExpirationInMs } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { MongoInternals } from 'meteor/mongo';
const { _expireTokens: expireTokensOriginal } = Accounts;
Accounts._expireTokens = async () => {
const loginExpiration: number | undefined = await Settings.getValueById('Accounts_LoginExpiration');
const oldestValidDate = new Date(Date.now() - getLoginExpirationInMs(loginExpiration));
const users = await Users.find(
{
$or: [
{ 'services.resume.loginTokens.when': { $lt: oldestValidDate } },
{ 'services.resume.loginTokens.when': { $lt: +oldestValidDate } },
],
},
{ projection: { services: 1 } },
).toArray();
const updates: { _id: string; validTokens: any[] }[] = [];
users.forEach(({ _id, services }) => {
const loginTokens = services?.resume?.loginTokens;
// remove all tokens that are expired
const validTokens = loginTokens?.filter((token: any) => token.when >= oldestValidDate) || [];
updates.push({ _id, validTokens });
});
await expireTokensOriginal.call(Accounts);
updates.forEach(({ _id, validTokens }) => {
void api.broadcast('watch.users', {
clientAction: 'updated',
id: _id,
diff: {
'services.resume.loginTokens': validTokens,
},
unset: {},
});
});
};
type Callbacks = {
added(id: string, record: object): void;
changed(id: string, record: object): void;
removed(id: string): void;
};
export const serviceConfigCallbacks = new Set<Callbacks>();
// Stores the callbacks for the disconnection reactivity bellow
const userCallbacks = new Map();
// Overrides the native observe changes to prevent database polling and stores the callbacks
// for the users' tokens to re-implement the reactivity based on our database listeners
const { mongo } = MongoInternals.defaultRemoteCollectionDriver();
MongoInternals.Connection.prototype._observeChanges = async function (
{
collectionName,
selector,
options = {},
}: {
collectionName: string;
selector: Record<string, any>;
options?: {
projection?: Record<string, number>;
fields?: Record<string, number>;
};
},
_ordered: boolean,
callbacks: Callbacks,
): Promise<any> {
let cbs: Set<{ hashedToken: string; callbacks: Callbacks }>;
let data: { hashedToken: string; callbacks: Callbacks };
if (callbacks?.added) {
const records = await mongo
.rawCollection(collectionName)
.find(selector, {
...(options.projection || options.fields ? { projection: options.projection || options.fields } : {}),
})
.toArray();
for (const { _id, ...fields } of records) {
callbacks.added(String(_id), fields);
}
if (collectionName === 'users' && selector['services.resume.loginTokens.hashedToken']) {
cbs = userCallbacks.get(selector._id) || new Set();
data = {
hashedToken: selector['services.resume.loginTokens.hashedToken'],
callbacks,
};
cbs.add(data);
userCallbacks.set(selector._id, cbs);
}
}
if (collectionName === 'meteor_accounts_loginServiceConfiguration') {
serviceConfigCallbacks.add(callbacks);
}
return {
stop(): void {
if (cbs) {
cbs.delete(data);
}
serviceConfigCallbacks.delete(callbacks);
},
};
};
// Re-implement meteor's reactivity that uses observe to disconnect sessions when the token
// associated was removed
export const processOnChange = (diff: Record<string, any>, id: string): void => {
if (!diff || !('services.resume.loginTokens' in diff)) {
return;
}
const loginTokens: undefined | { hashedToken: string }[] = diff['services.resume.loginTokens'];
const tokens = loginTokens?.map(({ hashedToken }) => hashedToken);
const cbs = userCallbacks.get(id);
if (cbs) {
[...cbs]
.filter(({ hashedToken }) => !tokens?.includes(hashedToken))
.forEach((item) => {
item.callbacks.removed(id);
cbs.delete(item);
});
}
};

View File

@@ -0,0 +1,71 @@
import { Banner } from '@rocket.chat/core-services';
import type { IBanner, BannerPlatform } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import type * as UiKit from '@rocket.chat/ui-kit';
import { getWorkspaceAccessToken } from '../../lib/cloud';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
type NpsSurveyData = {
id: string;
platform: BannerPlatform[];
roles: string[];
survey: UiKit.BannerView;
createdAt: Date;
startAt: Date;
expireAt: Date;
};
export const getAndCreateNpsSurvey = async function getNpsSurvey(npsId: string) {
const token = await getWorkspaceAccessToken();
if (!token) {
return false;
}
const npsEnabled = settings.get('NPS_survey_enabled');
if (!npsEnabled) {
return false;
}
const npsUrl = settings.get('Nps_Url');
try {
const result = await fetch(`${npsUrl}/v1/surveys/${npsId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
});
if (!result.ok) {
SystemLogger.error({ msg: 'invalid response from the nps service:', result });
return;
}
const surveyData = (await result.json()) as NpsSurveyData;
const banner: IBanner = {
_id: npsId,
platform: surveyData.platform,
createdAt: new Date(surveyData.createdAt),
expireAt: new Date(surveyData.expireAt),
startAt: new Date(surveyData.startAt),
_updatedAt: new Date(), // Needed by the IRocketChatRecord interface
roles: surveyData.roles,
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
view: surveyData.survey,
surface: 'banner',
};
await Banner.create(banner);
} catch (err) {
SystemLogger.error({ err });
return false;
}
};

View File

@@ -0,0 +1,54 @@
import type { IBanner } from '@rocket.chat/core-typings';
import { BannerPlatform } from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';
import moment from 'moment';
import { i18n } from '../../lib/i18n';
import { sendMessagesToAdmins } from '../../lib/sendMessagesToAdmins';
import { settings } from '../../settings';
export const getBannerForAdmins = (expireAt: Date): Omit<IBanner, '_id'> => {
const lng = settings.get<string>('Language') || 'en';
return {
platform: [BannerPlatform.Web],
createdAt: new Date(),
expireAt,
startAt: new Date(),
roles: ['admin'],
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
_updatedAt: new Date(),
surface: 'banner',
view: {
viewId: Random.id(),
appId: '',
blocks: [
{
type: 'section',
blockId: 'attention',
text: {
type: 'plain_text',
text: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', {
date: moment(expireAt).format('YYYY-MM-DD'),
lng,
}),
emoji: false,
},
},
],
},
};
};
export const notifyAdmins = (expireAt: Date) =>
sendMessagesToAdmins({
msgs: async ({ adminUser }: { adminUser: any }): Promise<any> => ({
msg: i18n.t('NPS_survey_is_scheduled_to-run-at__date__for_all_users', {
date: moment(expireAt).format('YYYY-MM-DD'),
lng: adminUser.language,
}),
}),
});

View File

@@ -0,0 +1,37 @@
import type { INpsVote } from '@rocket.chat/core-typings';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { getWorkspaceAccessToken } from '../../lib/cloud';
import { SystemLogger } from '../../lib/logger/system';
import { settings } from '../../settings';
type NPSResultPayload = {
total: number;
votes: Pick<INpsVote, 'identifier' | 'roles' | 'score' | 'comment'>[];
};
export const sendNpsResults = async function sendNpsResults(npsId: string, data: NPSResultPayload) {
const token = await getWorkspaceAccessToken();
if (!token) {
return false;
}
const npsUrl = settings.get('Nps_Url');
try {
return (
await fetch(`${npsUrl}/v1/surveys/${npsId}/results`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: data,
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
})
).json();
} catch (err) {
SystemLogger.error({ err });
return false;
}
};

View File

@@ -0,0 +1,192 @@
import { createHash } from 'node:crypto';
import type { INPSService, NPSVotePayload, NPSCreatePayload } from '@rocket.chat/core-services';
import { ServiceClassInternal, Banner, NPS, Settings } from '@rocket.chat/core-services';
import type { INpsVote, INps } from '@rocket.chat/core-typings';
import { NPSStatus, INpsVoteStatus } from '@rocket.chat/core-typings';
import { Nps, NpsVote } from '@rocket.chat/models';
import { getBannerForAdmins, notifyAdmins } from './notification';
import { sendNpsResults } from './sendNpsResults';
import { SystemLogger } from '../../lib/logger/system';
export class NPSService extends ServiceClassInternal implements INPSService {
protected name = 'nps';
async create(nps: NPSCreatePayload): Promise<boolean> {
const npsEnabled = await Settings.get<boolean>('NPS_survey_enabled');
if (!npsEnabled) {
throw new Error('Server opted-out for NPS surveys');
}
const any = await Nps.findOne({}, { projection: { _id: 1 } });
if (!any) {
if (nps.expireAt < nps.startAt || nps.expireAt < new Date()) {
throw new Error('NPS already expired');
}
await Banner.create(getBannerForAdmins(nps.expireAt));
await notifyAdmins(nps.startAt);
}
const { npsId, startAt, expireAt, createdBy } = nps;
try {
await Nps.save({
_id: npsId,
startAt,
expireAt,
createdBy,
status: NPSStatus.OPEN,
});
} catch (err) {
SystemLogger.error({ msg: 'Error creating NPS', err });
throw new Error('Error creating NPS');
}
return true;
}
async sendResults(): Promise<void> {
const npsEnabled = await Settings.get<boolean>('NPS_survey_enabled');
if (!npsEnabled) {
return;
}
const npsSending = await Nps.getOpenExpiredAlreadySending();
const nps = npsSending || (await Nps.getOpenExpiredAndStartSending());
if (!nps) {
return;
}
const total = await NpsVote.countByNpsId(nps._id);
const votesToSend = await NpsVote.findNotSentByNpsId(nps._id).toArray();
// if there is nothing to sent, check if something gone wrong
if (votesToSend.length === 0) {
// check if still has votes left to send
const totalSent = await NpsVote.countByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT);
if (totalSent === total) {
await Nps.updateStatusById(nps._id, NPSStatus.SENT);
return;
}
// update old votes (sent 5 minutes ago or more) in 'sending' status back to 'new'
await NpsVote.updateOldSendingToNewByNpsId(nps._id);
// try again in 5 minutes
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000);
return;
}
const today = new Date();
const sending = await Promise.all(
votesToSend.map(async (vote) => {
const value = await NpsVote.findOneAndUpdate(
{
_id: vote._id,
status: INpsVoteStatus.NEW,
},
{
$set: {
status: INpsVoteStatus.SENDING,
sentAt: today,
},
},
{
projection: {
identifier: 1,
roles: 1,
score: 1,
comment: 1,
},
},
);
return value;
}),
);
const votes = sending.filter(Boolean) as Pick<INpsVote, '_id' | 'identifier' | 'roles' | 'score' | 'comment'>[];
if (votes.length > 0) {
const voteIds = votes.map(({ _id }) => _id);
const votesWithoutIds = votes.map(({ identifier, roles, score, comment }) => ({
identifier,
roles,
score,
comment,
}));
const payload = {
total,
votes: votesWithoutIds,
};
await sendNpsResults(nps._id, payload);
await NpsVote.updateVotesToSent(voteIds);
}
const totalSent = await NpsVote.countByNpsIdAndStatus(nps._id, INpsVoteStatus.SENT);
if (totalSent < total) {
// send more in five minutes
setTimeout(() => NPS.sendResults(), 5 * 60 * 1000);
return;
}
await Nps.updateStatusById(nps._id, NPSStatus.SENT);
}
async vote({ userId, npsId, roles, score, comment }: NPSVotePayload): Promise<void> {
const npsEnabled = await Settings.get<boolean>('NPS_survey_enabled');
if (!npsEnabled) {
return;
}
if (!npsId || typeof npsId !== 'string') {
throw new Error('Invalid NPS id');
}
const nps = await Nps.findOneById<Pick<INps, 'status' | 'startAt' | 'expireAt'>>(npsId, {
projection: { status: 1, startAt: 1, expireAt: 1 },
});
if (!nps) {
return;
}
if (nps.status !== NPSStatus.OPEN) {
throw new Error('NPS not open for votes');
}
const today = new Date();
if (today > nps.expireAt) {
throw new Error('NPS expired');
}
if (today < nps.startAt) {
throw new Error('NPS survey not started');
}
const identifier = createHash('sha256').update(`${userId}${npsId}`).digest('hex');
const result = await NpsVote.save({
ts: new Date(),
npsId,
identifier,
roles,
score,
comment,
status: INpsVoteStatus.NEW,
});
if (!result) {
throw new Error('Error saving NPS vote');
}
}
async closeOpenSurveys(): Promise<void> {
await Nps.closeAllByStatus(NPSStatus.OPEN);
}
}

View File

@@ -0,0 +1,411 @@
/* eslint-disable new-cap */
import type { ConversationData } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
import type moment from 'moment';
import type { Filter } from 'mongodb';
import { secondsToHHMMSS } from '../../../lib/utils/secondsToHHMMSS';
type AgentOverviewValidActions =
| 'Total_conversations'
| 'Avg_chat_duration'
| 'Total_messages'
| 'Avg_first_response_time'
| 'Best_first_response_time'
| 'Avg_response_time'
| 'Avg_reaction_time';
export class AgentOverviewData {
constructor(private readonly roomsModel: ILivechatRoomsModel) {}
updateMap<K>(map: Map<K, number>, key: K, value: number) {
const currentKeyValue = map.get(key);
map.set(key, currentKeyValue ? currentKeyValue + value : value);
}
sortByValue(data: { value: string }[], inv = false) {
data.sort((a, b) => {
// sort array
if (parseFloat(a.value) > parseFloat(b.value)) {
return inv ? -1 : 1; // if inv, reverse sort
}
if (parseFloat(a.value) < parseFloat(b.value)) {
return inv ? 1 : -1;
}
return 0;
});
}
isActionAllowed(action: string | undefined): action is AgentOverviewValidActions {
if (!action) {
return false;
}
return [
'Total_conversations',
'Avg_chat_duration',
'Total_messages',
'Avg_first_response_time',
'Best_first_response_time',
'Avg_response_time',
'Avg_reaction_time',
].includes(action);
}
callAction<T extends AgentOverviewValidActions>(action: T, ...args: [moment.Moment, moment.Moment, string?, Filter<IOmnichannelRoom>?]) {
switch (action) {
case 'Total_conversations':
return this.Total_conversations(...args);
case 'Avg_chat_duration':
return this.Avg_chat_duration(...args);
case 'Total_messages':
return this.Total_messages(...args);
case 'Avg_first_response_time':
return this.Avg_first_response_time(...args);
case 'Best_first_response_time':
return this.Best_first_response_time(...args);
case 'Avg_response_time':
return this.Avg_response_time(...args);
case 'Avg_reaction_time':
return this.Avg_reaction_time(...args);
default:
throw new Error('Invalid action');
}
}
async Total_conversations(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let total = 0;
const agentConversations = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: '%_of_conversations',
},
],
data: [],
};
await this.roomsModel
.getAnalyticsMetricsBetweenDateWithMessages(
'l',
date,
{
departmentId,
},
{},
extraQuery,
)
.forEach((room) => {
if (room.servedBy) {
this.updateMap(agentConversations, room.servedBy.username, 1);
total++;
}
});
agentConversations.forEach((value, key) => {
// calculate percentage
const percentage = ((value / total) * 100).toFixed(2);
data.data.push({
name: key,
value: `${percentage}%`,
});
});
this.sortByValue(data.data, true); // reverse sort array
return data;
}
async Avg_chat_duration(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentChatDurations = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Avg_chat_duration',
},
],
data: [],
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => {
if (servedBy && metrics && metrics.chatDuration) {
if (agentChatDurations.has(servedBy.username)) {
agentChatDurations.set(servedBy.username, {
chatDuration: agentChatDurations.get(servedBy.username).chatDuration + metrics.chatDuration,
total: agentChatDurations.get(servedBy.username).total + 1,
});
} else {
agentChatDurations.set(servedBy.username, {
chatDuration: metrics.chatDuration,
total: 1,
});
}
}
});
agentChatDurations.forEach((obj, key) => {
// calculate percentage
const avg = (obj.chatDuration / obj.total).toFixed(2);
data.data.push({
name: key,
value: secondsToHHMMSS(avg),
});
});
this.sortByValue(data.data, true); // reverse sort array
return data;
}
async Total_messages(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentMessages = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Total_messages',
},
],
data: [],
};
// we don't want to count visitor messages
const extraFilter = { $lte: ['$token', null] };
await this.roomsModel
.getAnalyticsMetricsBetweenDateWithMessages('l', date, { departmentId }, extraFilter, extraQuery)
.forEach(({ servedBy, msgs }) => {
if (servedBy) {
this.updateMap(agentMessages, servedBy.username, msgs);
}
});
agentMessages.forEach((value, key) => {
// calculate percentage
data.data.push({
name: key,
value,
});
});
this.sortByValue(data.data, true); // reverse sort array
return data;
}
async Avg_first_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentAvgRespTime = new Map(); // stores avg response time for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Avg_first_response_time',
},
],
data: [],
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => {
if (responseBy && metrics && metrics.response && metrics.response.ft) {
if (agentAvgRespTime.has(responseBy.username)) {
agentAvgRespTime.set(responseBy.username, {
frt: agentAvgRespTime.get(responseBy.username).frt + metrics.response.ft,
total: agentAvgRespTime.get(responseBy.username).total + 1,
});
} else {
agentAvgRespTime.set(responseBy.username, {
frt: metrics.response.ft,
total: 1,
});
}
}
});
agentAvgRespTime.forEach((obj, key) => {
// calculate avg
const avg = obj.frt / obj.total;
data.data.push({
name: key,
value: secondsToHHMMSS(avg.toFixed(2)),
});
});
this.sortByValue(data.data, false); // sort array
return data;
}
async Best_first_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentFirstRespTime = new Map(); // stores best response time for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Best_first_response_time',
},
],
data: [],
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, responseBy }) => {
if (responseBy && metrics && metrics.response && metrics.response.ft) {
if (agentFirstRespTime.has(responseBy.username)) {
agentFirstRespTime.set(responseBy.username, Math.min(agentFirstRespTime.get(responseBy.username), metrics.response.ft));
} else {
agentFirstRespTime.set(responseBy.username, metrics.response.ft);
}
}
});
agentFirstRespTime.forEach((value, key) => {
// calculate avg
data.data.push({
name: key,
value: secondsToHHMMSS(value.toFixed(2)),
});
});
this.sortByValue(data.data, false); // sort array
return data;
}
async Avg_response_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentAvgRespTime = new Map(); // stores avg response time for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Avg_response_time',
},
],
data: [],
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => {
if (servedBy && metrics && metrics.response && metrics.response.avg) {
if (agentAvgRespTime.has(servedBy.username)) {
agentAvgRespTime.set(servedBy.username, {
avg: agentAvgRespTime.get(servedBy.username).avg + metrics.response.avg,
total: agentAvgRespTime.get(servedBy.username).total + 1,
});
} else {
agentAvgRespTime.set(servedBy.username, {
avg: metrics.response.avg,
total: 1,
});
}
}
});
agentAvgRespTime.forEach((obj, key) => {
// calculate avg
const avg = obj.avg / obj.total;
data.data.push({
name: key,
value: secondsToHHMMSS(avg.toFixed(2)),
});
});
this.sortByValue(data.data, false); // sort array
return data;
}
async Avg_reaction_time(from: moment.Moment, to: moment.Moment, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
head: [
{
name: 'Agent',
},
{
name: 'Avg_reaction_time',
},
],
data: [],
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics, servedBy }) => {
if (servedBy && metrics && metrics.reaction && metrics.reaction.ft) {
if (agentAvgReactionTime.has(servedBy.username)) {
agentAvgReactionTime.set(servedBy.username, {
frt: agentAvgReactionTime.get(servedBy.username).frt + metrics.reaction.ft,
total: agentAvgReactionTime.get(servedBy.username).total + 1,
});
} else {
agentAvgReactionTime.set(servedBy.username, {
frt: metrics.reaction.ft,
total: 1,
});
}
}
});
agentAvgReactionTime.forEach((obj, key) => {
// calculate avg
const avg = obj.frt / obj.total;
data.data.push({
name: key,
value: secondsToHHMMSS(avg.toFixed(2)),
});
});
this.sortByValue(data.data, false); // sort array
return data;
}
}

View File

@@ -0,0 +1,154 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
import type { Filter } from 'mongodb';
/* eslint-disable new-cap */
type ChartDataValidActions =
| 'Total_conversations'
| 'Avg_chat_duration'
| 'Total_messages'
| 'Avg_first_response_time'
| 'Avg_reaction_time'
| 'Best_first_response_time'
| 'Avg_response_time';
type DateParam = {
gte: Date;
lte: Date;
};
export class ChartData {
constructor(private readonly roomsModel: ILivechatRoomsModel) {}
isActionAllowed(action: string | undefined): action is ChartDataValidActions {
if (!action) {
return false;
}
return [
'Total_conversations',
'Avg_chat_duration',
'Total_messages',
'Avg_first_response_time',
'Avg_reaction_time',
'Best_first_response_time',
'Avg_response_time',
].includes(action);
}
callAction<T extends ChartDataValidActions>(action: T, ...args: [DateParam, string?, Filter<IOmnichannelRoom>?]) {
switch (action) {
case 'Total_conversations':
return this.Total_conversations(...args);
case 'Avg_chat_duration':
return this.Avg_chat_duration(...args);
case 'Total_messages':
return this.Total_messages(...args);
case 'Avg_first_response_time':
return this.Avg_first_response_time(...args);
case 'Avg_reaction_time':
return this.Avg_reaction_time(...args);
case 'Best_first_response_time':
return this.Best_first_response_time(...args);
case 'Avg_response_time':
return this.Avg_response_time(...args);
default:
throw new Error('Invalid action');
}
}
async Total_conversations(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
// @ts-expect-error - Check extraquery usage on this func
return this.roomsModel.getTotalConversationsBetweenDate('l', date, { departmentId }, extraQuery);
}
async Avg_chat_duration(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let total = 0;
let count = 0;
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.chatDuration) {
total += metrics.chatDuration;
count++;
}
});
const avgCD = count ? total / count : 0;
return Math.round(avgCD * 100) / 100;
}
async Total_messages(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let total = 0;
// we don't want to count visitor messages
const extraFilter = { $lte: ['$token', null] };
await this.roomsModel
.getAnalyticsMetricsBetweenDateWithMessages('l', date, { departmentId }, extraFilter, extraQuery)
.forEach(({ msgs }) => {
if (msgs) {
total += msgs;
}
});
return total;
}
async Avg_first_response_time(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let frt = 0;
let count = 0;
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.response?.ft) {
frt += metrics.response.ft;
count++;
}
});
const avgFrt = count ? frt / count : 0;
return Math.round(avgFrt * 100) / 100;
}
async Best_first_response_time(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let maxFrt = 0;
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.response?.ft) {
maxFrt = maxFrt ? Math.min(maxFrt, metrics.response.ft) : metrics.response.ft;
}
});
if (!maxFrt) {
maxFrt = 0;
}
return Math.round(maxFrt * 100) / 100;
}
async Avg_response_time(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let art = 0;
let count = 0;
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.response?.avg) {
art += metrics.response.avg;
count++;
}
});
const avgArt = count ? art / count : 0;
return Math.round(avgArt * 100) / 100;
}
async Avg_reaction_time(date: DateParam, departmentId?: string, extraQuery: Filter<IOmnichannelRoom> = {}) {
let arnt = 0;
let count = 0;
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.reaction?.ft) {
arnt += metrics.reaction.ft;
count++;
}
});
const avgArnt = count ? arnt / count : 0;
return Math.round(avgArnt * 100) / 100;
}
}

View File

@@ -0,0 +1,219 @@
/* eslint-disable new-cap */
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ILivechatRoomsModel } from '@rocket.chat/model-typings';
import moment from 'moment-timezone';
import type { Filter } from 'mongodb';
import { secondsToHHMMSS } from '../../../lib/utils/secondsToHHMMSS';
type OverviewDataValidActions = 'Conversations' | 'Productivity';
export class OverviewData {
constructor(private readonly roomsModel: ILivechatRoomsModel) {}
isActionAllowed(action: string | undefined): action is OverviewDataValidActions {
if (!action) {
return false;
}
return ['Conversations', 'Productivity'].includes(action);
}
callAction<T extends OverviewDataValidActions>(
action: T,
...args: [moment.Moment, moment.Moment, string?, string?, ((v: string) => string)?, Filter<IOmnichannelRoom>?]
) {
switch (action) {
case 'Conversations':
return this.Conversations(...args);
case 'Productivity':
return this.Productivity(...args);
default:
throw new Error('Invalid action');
}
}
getKeyHavingMaxValue<T>(map: Map<T, number>, def: T): T {
let maxValue = 0;
let maxKey = def; // default
map.forEach((value, key) => {
if (value > maxValue) {
maxValue = value;
maxKey = key;
}
});
return maxKey;
}
sumAllMapKeys<T>(map: Map<T, number>): number {
let sum = 0;
map.forEach((value) => {
sum += value;
});
return sum;
}
getBusiestDay(map: Map<string, Map<string, number>>): string {
let mostMessages = -1;
let busiestDay = '-';
map.forEach((value, key) => {
const v = this.sumAllMapKeys(value);
if (v > mostMessages) {
mostMessages = v;
busiestDay = key;
}
});
return busiestDay;
}
getAllMapKeysSize<T>(map: Map<T, Map<T, number>>): number {
let size = 0;
[...map.keys()].forEach((key) => {
size += map.get(key)?.size || 0;
});
return size;
}
async Conversations(
from: moment.Moment,
to: moment.Moment,
departmentId?: string,
timezone = 'UTC',
t = (v: string): string => v,
extraQuery: Filter<IOmnichannelRoom> = {},
) {
const analyticsMap = new Map();
let openConversations = 0; // open conversations
let totalMessages = 0; // total msgs
let totalConversations = 0; // Total conversations
const days = to.diff(from, 'days') + 1; // total days
const date = {
gte: moment.tz(from, timezone).startOf('day').utc(),
lte: moment.tz(to, timezone).endOf('day').utc(),
};
// @ts-expect-error - Check extraquery usage on this func
const cursor = this.roomsModel.getAnalyticsBetweenDate(date, { departmentId }, extraQuery);
for await (const room of cursor) {
totalConversations++;
if (room.metrics && !room.metrics.chatDuration && !room.onHold) {
openConversations++;
}
const creationDay = moment.tz(room.ts, timezone).format('DD-MM-YYYY'); // @string: 01-01-2021
const creationHour = moment.tz(room.ts, timezone).format('H'); // @int : 0, 1, ... 23
if (!analyticsMap.has(creationDay)) {
analyticsMap.set(creationDay, new Map());
}
const dayMap = analyticsMap.get(creationDay);
if (!dayMap.has(creationHour)) {
dayMap.set(creationHour, 0);
}
dayMap.set(creationHour, dayMap.get(creationHour) ? dayMap.get(creationHour) + room.msgs : room.msgs);
totalMessages += room.msgs;
}
// @ts-expect-error - Check extraquery usage on this func
const onHoldConversations = await this.roomsModel.getOnHoldConversationsBetweenDate(from, to, departmentId, extraQuery);
const busiestDayFromMap = this.getBusiestDay(analyticsMap); // returns busiest day based on the number of messages sent on that day
const busiestHour = this.getKeyHavingMaxValue<number>(analyticsMap.get(busiestDayFromMap) || new Map(), -1); // returns key with max value
const busiestTimeFrom = busiestHour >= 0 ? moment.tz(`${busiestHour}`, 'H', timezone).format('hA') : ''; // @string: 12AM, 1AM ...
const busiestTimeTo = busiestHour >= 0 ? moment.tz(`${busiestHour}`, 'H', timezone).add(1, 'hour').format('hA') : ''; // @string: 1AM, 2AM ...
const busiestDay = busiestDayFromMap !== '-' ? moment.tz(busiestDayFromMap, 'DD-MM-YYYY', timezone).format('dddd') : ''; // @string: Monday, Tuesday ...
return [
{
title: 'Total_conversations',
value: totalConversations,
},
{
title: 'Open_conversations',
value: openConversations,
},
{
title: 'On_Hold_conversations',
value: onHoldConversations,
},
{
title: 'Total_messages',
value: totalMessages,
},
{
title: 'Busiest_day',
value: t(busiestDay) || '-',
},
{
title: 'Conversations_per_day',
value: (totalConversations / days).toFixed(2),
},
{
title: 'Busiest_time',
value: `${busiestTimeFrom}${busiestTimeTo ? ` - ${busiestTimeTo}` : ''}` || '-',
},
];
}
async Productivity(
from: moment.Moment,
to: moment.Moment,
departmentId?: string,
_timezone?: string,
_t = (v: string): string => v,
extraQuery?: Filter<IOmnichannelRoom>,
) {
let avgResponseTime = 0;
let firstResponseTime = 0;
let avgReactionTime = 0;
let count = 0;
const date = {
gte: from.toDate(),
lte: to.toDate(),
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {
if (metrics?.response && metrics.reaction) {
avgResponseTime += metrics.response.avg;
firstResponseTime += metrics.response.ft;
avgReactionTime += metrics.reaction.ft;
count++;
}
});
if (count) {
avgResponseTime /= count;
firstResponseTime /= count;
avgReactionTime /= count;
}
const data = [
{
title: 'Avg_response_time',
value: secondsToHHMMSS(avgResponseTime.toFixed(2)),
},
{
title: 'Avg_first_response_time',
value: secondsToHHMMSS(firstResponseTime.toFixed(2)),
},
{
title: 'Avg_reaction_time',
value: secondsToHHMMSS(avgReactionTime.toFixed(2)),
},
];
return data;
}
}

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const serviceLogger = new Logger('OmnichannelAnalyticsService');

View File

@@ -0,0 +1,175 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type {
AgentOverviewDataOptions,
AnalyticsOverviewDataOptions,
ChartDataOptions,
IOmnichannelAnalyticsService,
} from '@rocket.chat/core-services';
import { LivechatRooms } from '@rocket.chat/models';
import moment from 'moment-timezone';
import { AgentOverviewData } from './AgentData';
import { ChartData } from './ChartData';
import { OverviewData } from './OverviewData';
import { serviceLogger } from './logger';
import { dayIterator } from './utils';
import { callbacks } from '../../lib/callbacks';
import { i18n } from '../../lib/i18n';
import { getTimezone } from '../../lib/utils/lib/getTimezone';
const HOURS_IN_DAY = 24;
// TODO: move EE analytics to this service & remove callback usage
export class OmnichannelAnalyticsService extends ServiceClassInternal implements IOmnichannelAnalyticsService {
protected name = 'omnichannel-analytics';
readonly overview: OverviewData;
readonly chart: ChartData;
readonly agentOverview: AgentOverviewData;
constructor() {
super();
this.overview = new OverviewData(LivechatRooms);
this.chart = new ChartData(LivechatRooms);
this.agentOverview = new AgentOverviewData(LivechatRooms);
}
async getAgentOverviewData(options: AgentOverviewDataOptions) {
const { departmentId, utcOffset, daterange: { from: fDate, to: tDate } = {}, chartOptions: { name } = {}, executedBy } = options;
const timezone = getTimezone({ utcOffset });
const from = moment
.tz(fDate || '', 'YYYY-MM-DD', timezone)
.startOf('day')
.utc();
const to = moment
.tz(tDate || '', 'YYYY-MM-DD', timezone)
.endOf('day')
.utc();
if (!moment(from).isValid() || !moment(to).isValid()) {
serviceLogger.error('AgentOverview -> Invalid dates');
return;
}
if (!this.agentOverview.isActionAllowed(name)) {
serviceLogger.error({ msg: 'AgentOverview action is not valid', name });
return;
}
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
return this.agentOverview.callAction(name, from, to, departmentId, extraQuery);
}
async getAnalyticsChartData(options: ChartDataOptions) {
const {
utcOffset,
departmentId,
daterange: { from: fDate, to: tDate } = {},
chartOptions: { name: chartLabel },
executedBy,
} = options;
// Check if function exists, prevent server error in case property altered
if (!this.chart.isActionAllowed(chartLabel)) {
serviceLogger.error({ msg: 'ChartData action is not valid', chartLabel });
return;
}
const timezone = getTimezone({ utcOffset });
const from = moment
.tz(fDate || '', 'YYYY-MM-DD', timezone)
.startOf('day')
.utc();
const to = moment
.tz(tDate || '', 'YYYY-MM-DD', timezone)
.endOf('day')
.utc();
const isSameDay = from.diff(to, 'days') === 0;
if (!moment(from).isValid() || !moment(to).isValid()) {
serviceLogger.error('ChartData -> Invalid dates');
return;
}
const data: {
chartLabel: string;
dataLabels: string[];
dataPoints: number[];
} = {
chartLabel,
dataLabels: [],
dataPoints: [],
};
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
if (isSameDay) {
// data for single day
const m = moment(from);
for await (const currentHour of Array.from({ length: HOURS_IN_DAY }, (_, i) => i)) {
const hour = parseInt(m.add(currentHour ? 1 : 0, 'hour').format('H'));
const label = {
from: moment.utc().set({ hour }).tz(timezone).format('hA'),
to: moment.utc().set({ hour }).endOf('hour').tz(timezone).format('hA'),
};
data.dataLabels.push(`${label.from}-${label.to}`);
const date = {
gte: m.toDate(),
lte: moment(m).endOf('hour').toDate(),
};
data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery));
}
} else {
for await (const m of dayIterator(from, to)) {
data.dataLabels.push(m.format('M/D'));
const date = {
gte: m.toDate(),
lte: moment(m).endOf('day').toDate(),
};
data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery));
}
}
return data;
}
async getAnalyticsOverviewData(options: AnalyticsOverviewDataOptions) {
const {
departmentId,
utcOffset = 0,
language,
daterange: { from: fDate, to: tDate } = {},
analyticsOptions: { name } = {},
executedBy,
} = options;
const timezone = getTimezone({ utcOffset });
const from = moment
.tz(fDate || '', 'YYYY-MM-DD', timezone)
.startOf('day')
.utc();
const to = moment
.tz(tDate || '', 'YYYY-MM-DD', timezone)
.endOf('day')
.utc();
if (!moment(from).isValid() || !moment(to).isValid()) {
serviceLogger.error('OverviewData -> Invalid dates');
return;
}
if (!this.overview.isActionAllowed(name)) {
serviceLogger.error({ msg: 'OverviewData action is not valid', name });
return;
}
const t = i18n.getFixedT(language);
const extraQuery = await callbacks.run('livechat.applyRoomRestrictions', {}, { userId: executedBy });
return this.overview.callAction(name, from, to, departmentId, timezone, t, extraQuery);
}
}

View File

@@ -0,0 +1,30 @@
import moment from 'moment-timezone';
const HOURS_IN_DAY = 24;
export async function* dayIterator(from: moment.Moment, to: moment.Moment): AsyncGenerator<moment.Moment> {
const m = from.clone().startOf('day');
const f = to.clone().startOf('day');
while (m.diff(f, 'days') <= 0) {
yield m;
m.add(1, 'days');
}
}
export async function* weekIterator(from: moment.Moment, to: moment.Moment, timezone: string): AsyncGenerator<moment.Moment> {
const m = moment.tz(from, timezone);
while (m.diff(to, 'weeks') <= 0) {
yield moment(m);
m.add(1, 'weeks');
}
}
export async function* hourIterator(day: moment.Moment): AsyncGenerator<moment.Moment> {
const m = moment(day).startOf('day');
let passedHours = 0;
while (passedHours < HOURS_IN_DAY) {
yield moment(m);
m.add(1, 'hours');
passedHours++;
}
}

View File

@@ -0,0 +1,7 @@
import type { ISMSProviderConstructor } from '@rocket.chat/core-typings';
import { Twilio } from './twilio';
export const registerSmsProviders = (register: (n: string, s: ISMSProviderConstructor) => void): void => {
register('twilio', Twilio);
};

View File

@@ -0,0 +1,295 @@
import { api } from '@rocket.chat/core-services';
import type { ISMSProvider, ServiceData, SMSProviderResponse, SMSProviderResult } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import filesize from 'filesize';
import twilio from 'twilio';
import { i18n } from '../../../lib/i18n';
import { SystemLogger } from '../../../lib/logger/system';
import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions';
import { settings } from '../../../settings';
type TwilioData = {
From: string;
To: string;
Body: string;
NumMedia?: string;
ToCountry?: string;
ToState?: string;
ToCity?: string;
ToZip?: string;
FromCountry?: string;
FromState?: string;
FromCity?: string;
FromZip?: string;
Latitude?: string;
Longitude?: string;
} & Record<`MediaUrl${number}`, string> &
Record<`MediaContentType${number}`, string>;
const isTwilioData = (data: unknown): data is TwilioData => {
if (typeof data !== 'object' || data === null) {
return false;
}
const { From, To, Body } = data as Record<string, unknown>;
return typeof From === 'string' && typeof To === 'string' && typeof Body === 'string';
};
const MAX_FILE_SIZE = 5242880;
const notifyAgent = (userId: string | undefined, rid: string | undefined, msg: string) =>
userId &&
rid &&
void api.broadcast('notify.ephemeralMessage', userId, rid, {
msg,
});
export class Twilio implements ISMSProvider {
parse(data: unknown): ServiceData {
let numMedia = 0;
if (!isTwilioData(data)) {
throw new Error('Invalid data');
}
const returnData: ServiceData = {
from: data.From,
to: data.To,
body: data.Body,
extra: {
toCountry: data.ToCountry,
toState: data.ToState,
toCity: data.ToCity,
toZip: data.ToZip,
fromCountry: data.FromCountry,
fromState: data.FromState,
fromCity: data.FromCity,
fromZip: data.FromZip,
fromLatitude: data.Latitude,
fromLongitude: data.Longitude,
},
};
if (data.NumMedia) {
numMedia = parseInt(data.NumMedia, 10);
}
if (isNaN(numMedia)) {
SystemLogger.error({ msg: 'Error parsing NumMedia', numMedia: data.NumMedia });
return returnData;
}
returnData.media = [];
for (let mediaIndex = 0; mediaIndex < numMedia; mediaIndex++) {
const media = {
url: '',
contentType: '',
};
const mediaUrl = data[`MediaUrl${mediaIndex}`];
const contentType = data[`MediaContentType${mediaIndex}`];
media.url = mediaUrl;
media.contentType = contentType;
returnData.media.push(media);
}
return returnData;
}
private async getClient(rid?: string, userId?: string) {
const sid = settings.get<string>('SMS_Twilio_Account_SID');
const token = settings.get<string>('SMS_Twilio_authToken');
if (!sid || !token) {
await notifyAgent(userId, rid, i18n.t('SMS_Twilio_NotConfigured'));
return;
}
try {
return twilio(sid, token);
} catch (error) {
await notifyAgent(userId, rid, i18n.t('SMS_Twilio_InvalidCredentials'));
SystemLogger.error({ msg: '(Twilio) ->', err: error });
}
}
private async validateFileUpload(
extraData: {
fileUpload?: { size: number; type: string; publicFilePath: string };
location?: { coordinates: [number, number] };
rid?: string;
userId?: string;
},
lang: string,
): Promise<string> {
const { rid, userId, fileUpload: { size, type, publicFilePath } = { size: 0, type: 'invalid' } } = extraData;
const user = userId ? await Users.findOne({ _id: userId }, { projection: { language: 1 } }) : null;
const lng = user?.language || lang;
let reason;
if (!settings.get('SMS_Twilio_FileUpload_Enabled')) {
reason = i18n.t('FileUpload_Disabled', { lng });
} else if (size > MAX_FILE_SIZE) {
reason = i18n.t('File_exceeds_allowed_size_of_bytes', {
size: filesize(MAX_FILE_SIZE),
lng,
});
} else if (!fileUploadIsValidContentType(type, settings.get('SMS_Twilio_FileUpload_MediaTypeWhiteList'))) {
reason = i18n.t('File_type_is_not_accepted', { lng });
} else if (!publicFilePath) {
reason = i18n.t('FileUpload_NotAllowed', { lng });
}
// Check if JWT is set for public file uploads when protect_files is on
// If it's not, notify user upload won't go to twilio
const protectFileUploads = settings.get('FileUpload_ProtectFiles');
const jwtEnabled = settings.get('FileUpload_Enable_json_web_token_for_files');
const isJWTKeySet = jwtEnabled && !!settings.get('FileUpload_json_web_token_secret_for_files');
if (protectFileUploads && (!jwtEnabled || !isJWTKeySet)) {
reason = i18n.t('FileUpload_ProtectFilesEnabled_JWTNotSet', { lng });
}
if (reason) {
await notifyAgent(userId, rid, reason);
SystemLogger.error({ msg: '(Twilio) ->', reason });
return '';
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return publicFilePath!;
}
async send(
fromNumber: string,
toNumber: string,
message: string,
extraData?: {
fileUpload?: { size: number; type: string; publicFilePath: string };
location?: { coordinates: [number, number] };
rid?: string;
userId?: string;
},
): Promise<SMSProviderResult> {
const { rid, userId } = extraData || {};
const client = await this.getClient(rid, userId);
if (!client) {
return {
isSuccess: false,
resultMsg: 'Twilio not configured',
};
}
let body = message;
let mediaUrl;
const defaultLanguage = settings.get<string>('Language') || 'en';
if (extraData?.fileUpload) {
const publicFilePath = await this.validateFileUpload(extraData, defaultLanguage);
if (!publicFilePath) {
return {
isSuccess: false,
resultMsg: 'File upload not allowed',
};
}
mediaUrl = [publicFilePath];
}
let persistentAction;
if (extraData?.location) {
const [longitude, latitude] = extraData.location.coordinates;
persistentAction = [`geo:${latitude},${longitude}`];
body = i18n.t('Location', { lng: defaultLanguage });
}
try {
const result = await client.messages.create({
to: toNumber,
from: fromNumber,
body,
...(mediaUrl && { mediaUrl }),
...(persistentAction && { persistentAction }),
});
if (result.errorCode) {
await notifyAgent(userId, rid, result.errorMessage);
SystemLogger.error({ msg: '(Twilio) ->', errorCode: result.errorCode });
}
return {
isSuccess: result.status !== 'failed',
resultMsg: result.status,
};
} catch (e: any) {
await notifyAgent(userId, rid, e.message);
return {
isSuccess: false,
resultMsg: e.message,
};
}
}
response(): SMSProviderResponse {
return {
headers: {
'Content-Type': 'text/xml',
},
body: '<Response></Response>',
};
}
private getUrl(url: string, siteUrl: string): string {
const baseUrl = new URL(url);
const newUrl = new URL(siteUrl);
baseUrl.protocol = newUrl.protocol;
baseUrl.host = newUrl.host;
return baseUrl.toString();
}
async isRequestFromTwilio(signature: string, request: Request, requestBody: unknown): Promise<boolean> {
const authToken = settings.get<string>('SMS_Twilio_authToken');
let siteUrl = settings.get<string>('Site_Url');
if (siteUrl.endsWith('/')) {
siteUrl = siteUrl.replace(/.$/, '');
}
if (!authToken || !siteUrl) {
SystemLogger.error('(Twilio) -> URL or Twilio token not configured.');
return false;
}
const twilioUrl = request.url ? this.getUrl(request.url, siteUrl) : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`;
return twilio.validateRequest(authToken, signature, twilioUrl, requestBody as Record<string, any>);
}
async validateRequest(request: Request, requestBody: unknown): Promise<boolean> {
// We're not getting original twilio requests on CI :p
if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') {
return true;
}
const twilioHeader = request.headers.get('x-twilio-signature') || '';
const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader;
return this.isRequestFromTwilio(twilioSignature, request, requestBody);
}
error(error: Error & { reason?: string }): SMSProviderResponse {
let message = '';
if (error.reason) {
message = `<Message>${error.reason}</Message>`;
}
return {
headers: {
'Content-Type': 'text/xml',
},
body: `<Response>${message}</Response>`,
};
}
}

View File

@@ -0,0 +1,36 @@
import { ServiceClassInternal, Settings } from '@rocket.chat/core-services';
import type { IOmnichannelIntegrationService } from '@rocket.chat/core-services';
import type { ISMSProviderConstructor, ISMSProvider } from '@rocket.chat/core-typings';
import { registerSmsProviders } from './providers';
export class OmnichannelIntegrationService extends ServiceClassInternal implements IOmnichannelIntegrationService {
protected name = 'omnichannel-integration';
private smsServices: Record<string, ISMSProviderConstructor> = {};
registerSmsService(name: string, service: ISMSProviderConstructor) {
this.smsServices[name] = service;
}
constructor() {
super();
registerSmsProviders(this.registerSmsService.bind(this));
}
async getSmsService(name: string): Promise<ISMSProvider> {
if (!(await Settings.get<boolean>('SMS_Enabled'))) {
throw new Error('error-sms-service-disabled');
}
if (!this.smsServices[name.toLowerCase()]) {
throw new Error('error-sms-service-not-configured');
}
return new this.smsServices[name.toLowerCase()]();
}
async isConfiguredSmsService(name: string): Promise<boolean> {
return name.toLowerCase() === (await Settings.get<string>('SMS_Service'));
}
}

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const queueLogger = new Logger('OmnichannelQueue');

View File

@@ -0,0 +1,281 @@
import { ServiceStarter } from '@rocket.chat/core-services';
import { LivechatInquiryStatus, type InquiryWithAgentInfo, type IOmnichannelQueue } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { LivechatInquiry, LivechatRooms } from '@rocket.chat/models';
import { tracerSpan } from '@rocket.chat/tracing';
import { queueLogger } from './logger';
import { getOmniChatSortQuery } from '../../../app/livechat/lib/inquiries';
import { metrics } from '../../lib/metrics';
import { notifyOnLivechatInquiryChangedByRoom } from '../../lib/notifyListener';
import { dispatchAgentDelegated } from '../../lib/omnichannel/Helper';
import { RoutingManager } from '../../lib/omnichannel/RoutingManager';
import { getInquirySortMechanismSetting } from '../../lib/omnichannel/settings';
import { settings } from '../../settings';
const DEFAULT_RACE_TIMEOUT = 5000;
export class OmnichannelQueue implements IOmnichannelQueue {
private serviceStarter: ServiceStarter;
private timeoutHandler: ReturnType<typeof setTimeout> | null = null;
constructor() {
this.serviceStarter = new ServiceStarter(
() => this._start(),
() => this._stop(),
);
}
private running = false;
private errorDelay = 10 * 1000; // 10 seconds
private delay() {
const timeout = settings.get<number>('Omnichannel_queue_delay_timeout') ?? 5;
return timeout < 1 ? DEFAULT_RACE_TIMEOUT : timeout * 1000;
}
public isRunning() {
return this.running;
}
private async _start() {
if (this.running) {
return;
}
const activeQueues = await this.getActiveQueues();
queueLogger.debug({ msg: 'Active queues', count: activeQueues.length });
this.running = true;
queueLogger.info('Service started');
return this.execute();
}
private async _stop() {
if (!this.running) {
return;
}
await LivechatInquiry.unlockAll();
this.running = false;
if (this.timeoutHandler !== null) {
clearTimeout(this.timeoutHandler);
this.timeoutHandler = null;
}
queueLogger.info('Service stopped');
}
async start() {
return this.serviceStarter.start();
}
async stop() {
return this.serviceStarter.stop();
}
private async getActiveQueues() {
return (await LivechatInquiry.getDistinctQueuedDepartments({})).map(({ _id }) => _id);
}
private async execute() {
try {
if (!this.running) {
queueLogger.debug('Queue stopped. Cannot execute');
return;
}
if (await License.shouldPreventAction('monthlyActiveContacts', 1)) {
queueLogger.debug('MAC limit reached. Queue wont execute');
this.running = false;
return;
}
// We still go 1 by 1, but we go with every queue every cycle instead of just 1 queue per cycle
// And we get tracing :)
const queues = await this.getActiveQueues();
for await (const queue of queues) {
await tracerSpan(
'omnichannel.queue',
{ attributes: { workerTime: new Date().toISOString(), queue: queue || 'Public' }, root: true },
() => this.checkQueue(queue),
);
}
this.scheduleExecution();
} catch (e) {
queueLogger.error({
msg: 'Queue Worker Error. Rescheduling with extra delay',
extraDelay: this.errorDelay,
err: e,
});
this.scheduleExecution(this.errorDelay);
}
}
private async checkQueue(queue: string | null) {
queueLogger.debug({ msg: 'Processing items for queue', queue: queue || 'Public' });
try {
const nextInquiry = await LivechatInquiry.findNextAndLock(getOmniChatSortQuery(getInquirySortMechanismSetting()), queue);
if (!nextInquiry) {
queueLogger.debug({ msg: 'No more items for queue', queue: queue || 'Public' });
return;
}
const result = await this.processWaitingQueue(queue, nextInquiry as InquiryWithAgentInfo);
if (!result) {
queueLogger.debug({ msg: 'Inquiry not taken. Unlocking and re-queueing', inquiry: nextInquiry._id });
return await LivechatInquiry.unlock(nextInquiry._id);
}
queueLogger.debug({ msg: 'Inquiry taken successfully. Unlocking', inquiry: nextInquiry._id });
await LivechatInquiry.unlock(nextInquiry._id);
queueLogger.debug({
msg: 'Inquiry processed',
inquiry: nextInquiry._id,
queue: queue || 'Public',
result,
});
} catch (e) {
metrics.totalItemsFailedByQueue.inc({ queue: queue || 'Public' });
queueLogger.error({
msg: 'Error processing queue',
queue: queue || 'Public',
err: e,
});
}
}
private scheduleExecution(extraDelay?: number): void {
if (this.timeoutHandler !== null) {
return;
}
this.timeoutHandler = setTimeout(
() => {
this.timeoutHandler = null;
return this.execute();
},
this.delay() + (extraDelay || 0),
);
}
async shouldStart() {
if (!settings.get('Livechat_enabled')) {
void this.stop();
return;
}
const routingSupportsAutoAssign = RoutingManager.getConfig()?.autoAssignAgent;
queueLogger.debug({
msg: 'Routing method supports auto assignment',
method: settings.get('Livechat_Routing_Method'),
status: routingSupportsAutoAssign ? 'Starting' : 'Stopping',
});
void (routingSupportsAutoAssign ? this.start() : this.stop());
}
private async reconciliation(reason: 'closed' | 'taken' | 'missing', { roomId, inquiryId }: { roomId: string; inquiryId: string }) {
switch (reason) {
case 'closed': {
queueLogger.debug({
msg: 'Room closed. Removing inquiry',
roomId,
inquiryId,
step: 'reconciliation',
});
await LivechatInquiry.removeByRoomId(roomId);
void notifyOnLivechatInquiryChangedByRoom(roomId, 'removed');
break;
}
case 'taken': {
queueLogger.debug({
msg: 'Room taken. Updating inquiry status',
roomId,
inquiryId,
step: 'reconciliation',
});
// Reconciliate served inquiries, by updating their status to taken after queue tried to pick and failed
await LivechatInquiry.takeInquiry(inquiryId);
void notifyOnLivechatInquiryChangedByRoom(roomId, 'updated', { status: LivechatInquiryStatus.TAKEN, takenAt: new Date() });
break;
}
case 'missing': {
queueLogger.debug({
msg: 'Room from inquiry missing. Removing inquiry',
roomId,
inquiryId,
step: 'reconciliation',
});
await LivechatInquiry.removeByRoomId(roomId);
void notifyOnLivechatInquiryChangedByRoom(roomId, 'removed');
break;
}
default: {
return true;
}
}
return true;
}
private async processWaitingQueue(department: string | null, inquiry: InquiryWithAgentInfo) {
const queue = department || 'Public';
queueLogger.debug({ msg: 'Processing inquiry', inquiry: inquiry._id, queue });
const { defaultAgent } = inquiry;
const roomFromDb = await LivechatRooms.findOneById(inquiry.rid);
// This is a precaution to avoid taking inquiries tied to rooms that no longer exist.
// This should never happen.
if (!roomFromDb) {
metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'missing_room' });
return this.reconciliation('missing', { roomId: inquiry.rid, inquiryId: inquiry._id });
}
// This is a precaution to avoid taking the same inquiry multiple times. It should not happen, but it's a safety net
if (roomFromDb.servedBy) {
metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'room_taken' });
return this.reconciliation('taken', { roomId: inquiry.rid, inquiryId: inquiry._id });
}
// This is another precaution. If the room is closed, we should not take it
if (roomFromDb.closedAt) {
metrics.totalItemsProcessedByReconciliationQueue.inc({ queue, action: 'room_closed' });
return this.reconciliation('closed', { roomId: inquiry.rid, inquiryId: inquiry._id });
}
const room = await RoutingManager.delegateInquiry(inquiry, defaultAgent, undefined, roomFromDb);
if (!room) {
queueLogger.debug({ msg: 'RoutingManager failed to delegate inquiry', inquiry: inquiry._id, queue });
return false;
}
if (room.servedBy) {
const {
_id: rid,
servedBy: { _id: agentId },
} = room;
queueLogger.debug({ msg: 'Inquiry taken successfully by agent. Notifying', inquiry: inquiry._id, agentId });
setTimeout(() => {
void dispatchAgentDelegated(rid, agentId);
}, 1000);
const waitTimeSeconds = (Date.now() - inquiry.ts.getTime()) / 1000;
metrics.timeToQueueProcessingByQueue.observe({ queue }, waitTimeSeconds);
metrics.timeToQueueProcessingByQueueHistogram.observe({ queue }, waitTimeSeconds);
metrics.totalItemsProcessedByQueue.inc({ queue });
return true;
}
return false;
}
}

View File

@@ -0,0 +1,59 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IOmnichannelService } from '@rocket.chat/core-services';
import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import moment from 'moment';
import { OmnichannelQueue } from './queue';
import { RoutingManager } from '../../lib/omnichannel/RoutingManager';
import { notifyAgentStatusChanged } from '../../lib/omnichannel/omni-users';
import { settings } from '../../settings';
export class OmnichannelService extends ServiceClassInternal implements IOmnichannelService {
protected name = 'omnichannel';
private queueWorker: IOmnichannelQueue;
constructor() {
super();
this.queueWorker = new OmnichannelQueue();
}
override async created() {
this.onEvent('presence.status', async ({ user }): Promise<void> => {
if (!user?._id) {
return;
}
const hasRole = user.roles.some((role) => ['livechat-manager', 'livechat-monitor', 'livechat-agent'].includes(role));
if (hasRole) {
// TODO change `Livechat.notifyAgentStatusChanged` to a service call
await notifyAgentStatusChanged(user._id, user.status);
}
});
}
override async started() {
settings.watchMultiple(['Livechat_enabled', 'Livechat_Routing_Method'], () => {
this.queueWorker.shouldStart();
});
License.onLimitReached('monthlyActiveContacts', async (): Promise<void> => {
this.queueWorker.isRunning() && (await this.queueWorker.stop());
});
License.onValidateLicense(async (): Promise<void> => {
RoutingManager.isMethodSet() && (await this.queueWorker.shouldStart());
});
// NOTE: When there's no license or license is invalid, we fallback to CE behavior
// CE behavior means there's no MAC limit, so we start the queue
License.onInvalidateLicense(async (): Promise<void> => {
this.queueWorker.isRunning() && (await this.queueWorker.shouldStart());
});
}
async isWithinMACLimit(room: AtLeast<IOmnichannelRoom, 'v'>): Promise<boolean> {
const currentMonth = moment.utc().format('YYYY-MM');
return room.v?.activity?.includes(currentMonth) || !(await License.shouldPreventAction('monthlyActiveContacts'));
}
}

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('Push');

View File

@@ -0,0 +1,57 @@
import type { IPushService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IPushToken, Optional } from '@rocket.chat/core-typings';
import { PushToken } from '@rocket.chat/models';
import { logger } from './logger';
import { registerPushToken } from './tokenManagement/registerPushToken';
export class PushService extends ServiceClassInternal implements IPushService {
protected name = 'push';
constructor() {
super();
this.onEvent('watch.users', async (data) => {
// for some reason data.diff can be set to undefined
if (!('diff' in data) || !data.diff || !('services.resume.loginTokens' in data.diff)) {
return;
}
const loginTokens = Array.isArray(data.diff['services.resume.loginTokens']) ? data.diff['services.resume.loginTokens'] : [];
if (data.diff['services.resume.loginTokens'] === undefined || loginTokens.length === 0) {
await PushToken.removeAllByUserId(data.id);
return;
}
const tokens = loginTokens.map(({ hashedToken }: { hashedToken: string }) => hashedToken);
if (tokens.length > 0) {
await PushToken.removeByUserIdExceptTokens(data.id, tokens);
}
});
}
async registerPushToken(
data: Optional<Pick<IPushToken, '_id' | 'token' | 'authToken' | 'appName' | 'userId' | 'metadata'>, '_id' | 'metadata'>,
): Promise<Omit<IPushToken, 'authToken'>> {
const tokenId = await registerPushToken(data);
const removeResult = await PushToken.removeDuplicateTokens({
_id: tokenId,
token: data.token,
appName: data.appName,
authToken: data.authToken,
});
if (removeResult.deletedCount) {
logger.debug({ msg: 'Removed existing app items', removed: removeResult.deletedCount });
}
const updatedDoc = await PushToken.findOneById<Omit<IPushToken, 'authToken'>>(tokenId, { projection: { authToken: 0 } });
if (!updatedDoc) {
logger.error({ msg: 'Could not find PushToken document on mongo after successful operation', tokenId });
throw new Error('could-not-find-token-document');
}
return updatedDoc;
}
}

View File

@@ -0,0 +1,22 @@
import type { IPushToken } from '@rocket.chat/core-typings';
import { PushToken } from '@rocket.chat/models';
export async function findDocumentToUpdate(data: Partial<IPushToken>): Promise<IPushToken | null> {
if (data._id) {
const existingDoc = await PushToken.findOneById(data._id);
if (existingDoc) {
return existingDoc;
}
}
// VoIP tokens MUST match the id
if (data.voipToken) {
return null;
}
if (data.token && data.appName) {
return PushToken.findOneByTokenAndAppName(data.token, data.appName);
}
return null;
}

View File

@@ -0,0 +1,68 @@
import type { IPushToken, Optional } from '@rocket.chat/core-typings';
import { PushToken } from '@rocket.chat/models';
import { findDocumentToUpdate } from './findDocumentToUpdate';
import { logger } from '../logger';
export type PushTokenData = Optional<
Pick<IPushToken, '_id' | 'token' | 'authToken' | 'appName' | 'userId' | 'metadata' | 'voipToken'>,
'_id' | 'metadata'
>;
function canModifyTokenDocument(doc: IPushToken, data: Partial<IPushToken>): boolean {
// If there's no voip on either side of the operation, any doc can be updated
if (!doc.voipToken && !data.voipToken) {
return true;
}
// VoIP tokens MUST be referenced by id, so if there's no id on the data, do not let this doc be changed
if (!data._id || data._id !== doc._id) {
return false;
}
return true;
}
async function insertToken(data: PushTokenData): Promise<IPushToken['_id']> {
const insertResult = await PushToken.insertToken({
...(data._id && { _id: data._id }),
token: data.token,
authToken: data.authToken,
appName: data.appName,
userId: data.userId,
...(data.metadata && { metadata: data.metadata }),
...(data.voipToken && data._id && { voipToken: data.voipToken }),
});
const { authToken: _, ...dataWithNoAuthToken } = data;
logger.debug({ msg: 'Push token added', dataWithNoAuthToken, insertResult });
return insertResult.insertedId;
}
async function updateToken(doc: IPushToken, data: PushTokenData): Promise<IPushToken['_id']> {
const updateResult = await PushToken.refreshTokenById(doc._id, {
token: data.token,
authToken: data.authToken,
appName: data.appName,
userId: data.userId,
...(data.voipToken && { voipToken: data.voipToken }),
});
if (updateResult.modifiedCount) {
const { authToken: _, ...dataWithNoAuthToken } = data;
logger.debug({ msg: 'Push token updated', dataWithNoAuthToken, updateResult });
}
return doc._id;
}
export async function registerPushToken(data: PushTokenData): Promise<IPushToken['_id']> {
const doc = await findDocumentToUpdate(data);
if (!doc || !canModifyTokenDocument(doc, data)) {
return insertToken(data);
}
return updateToken(doc, data);
}

View File

@@ -0,0 +1,33 @@
import type { AtLeast, IUser } from '@rocket.chat/core-typings';
const getFname = (members: AtLeast<IUser, 'name' | 'username'>[]): string | undefined => {
if (members.length === 0) {
return;
}
return members.map(({ name, username }) => name || username).join(', ');
};
const getName = (members: AtLeast<IUser, 'name' | 'username'>[]): string | undefined => {
if (members.length === 0) {
return;
}
return members.map(({ username }) => username).join(', ');
};
type NameMap = { [userId: string]: { fname: string; name: string } };
export function getNameForDMs(members: AtLeast<IUser, '_id' | 'name' | 'username'>[]): NameMap {
const nameMap: NameMap = {};
const sortedMembers = members.sort((u1, u2) => (u1.name! || u1.username!).localeCompare(u2.name! || u2.username!));
for (const member of sortedMembers) {
const otherMembers = sortedMembers.filter((m) => m._id !== member._id);
nameMap[member._id] = {
fname: getFname(otherMembers) || member.name || member.username || '',
name: getName(otherMembers) || member.username || '',
};
}
return nameMap;
}

View File

@@ -0,0 +1,30 @@
import { isRoomFederated, isRoomNativeFederated } from '@rocket.chat/core-typings';
import type { IRoomNativeFederated, IRoom } from '@rocket.chat/core-typings';
import { throwIfFederationNotEnabled } from '../../federation/utils';
export class FederationActions {
public static shouldPerformFederationAction(room: IRoom): room is IRoomNativeFederated {
if (!isRoomFederated(room)) {
return false;
}
if (!isRoomNativeFederated(room)) {
throw new Error('Room is federated but its not native implementation');
}
return true;
}
public static blockIfRoomFederatedButServiceNotReady(room: IRoom) {
if (!isRoomFederated(room)) {
return;
}
if (!isRoomNativeFederated(room)) {
throw new Error('Room is federated but its not native implementation');
}
throwIfFederationNotEnabled();
}
}

View File

@@ -0,0 +1,394 @@
import { ServiceClassInternal, Authorization, Message, MeteorError, FederationMatrix } from '@rocket.chat/core-services';
import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services';
import {
type AtLeast,
type IRoom,
type IUser,
type MessageTypesValues,
type ISubscription,
isOmnichannelRoom,
isRoomWithJoinCode,
} from '@rocket.chat/core-typings';
import { isUserNativeFederated } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { getNameForDMs } from './getNameForDMs';
import { FederationActions } from './hooks/BeforeFederationActions';
import { RoomMemberActions } from '../../../definition/IRoomTypeConfig';
import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig';
import { readThread } from '../../lib/messaging/threads/functions';
import {
notifyOnSubscriptionChanged,
notifyOnSubscriptionChangedById,
notifyOnSubscriptionChangedByRoomIdAndUserId,
} from '../../lib/notifyListener';
import { readMessages } from '../../lib/readMessages';
import { performAcceptRoomInvite } from '../../lib/rooms/acceptRoomInvite';
import { addUserToRoom } from '../../lib/rooms/addUserToRoom';
import { performUserBan } from '../../lib/rooms/banUserFromRoom';
import { createRoom } from '../../lib/rooms/createRoom'; // TODO remove this import
import { executeUnbanUserFromRoom } from '../../lib/rooms/executeUnbanUserFromRoom';
import { removeUserFromRoom, performUserRemoval } from '../../lib/rooms/removeUserFromRoom';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { saveRoomName } from '../../lib/rooms/settings';
import { saveRoomTopic } from '../../lib/rooms/settings/saveRoomTopic';
import { getDefaultSubscriptionPref } from '../../lib/utils/lib/getDefaultSubscriptionPref';
import { getValidRoomName } from '../../lib/utils/lib/getValidRoomName';
import { createDirectMessage } from '../../meteor-methods/messages/createDirectMessage';
import { addRoomLeader } from '../../meteor-methods/rooms/addRoomLeader';
import { addRoomModerator } from '../../meteor-methods/rooms/addRoomModerator';
import { addRoomOwner } from '../../meteor-methods/rooms/addRoomOwner';
import { removeRoomLeader } from '../../meteor-methods/rooms/removeRoomLeader';
import { removeRoomModerator } from '../../meteor-methods/rooms/removeRoomModerator';
import { removeRoomOwner } from '../../meteor-methods/rooms/removeRoomOwner';
export class RoomService extends ServiceClassInternal implements IRoomService {
protected name = 'room';
async updateDirectMessageRoomName(
room: IRoom,
ignoreStatusFromSubs?: string[],
updatedNames?: AtLeast<IUser, '_id' | 'name' | 'username'>[],
): Promise<boolean> {
if (room.t !== 'd') {
throw new Error('Invalid room type');
}
const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1, status: 1 } }).toArray();
const uids = subs.map((sub) => sub.u._id).filter((uid) => !updatedNames?.some((user) => user._id === uid));
const roomMembers = await Users.findUsersByIds(uids, { projection: { name: 1, username: 1 } }).toArray();
const roomNames = getNameForDMs([...roomMembers, ...(updatedNames ?? [])]);
for await (const sub of subs) {
// don't update the name if the user is invited but hasn't accepted yet
if (!ignoreStatusFromSubs?.includes(sub._id) && sub.status === 'INVITED') {
continue;
}
await Subscriptions.updateOne({ _id: sub._id }, { $set: roomNames[sub.u._id] });
void notifyOnSubscriptionChangedByRoomIdAndUserId(room._id, sub.u._id, 'updated');
}
return true;
}
async create(uid: string, params: ICreateRoomParams): Promise<IRoom> {
const { type, name, members = [], readOnly, extraData, options } = params;
const hasPermission = await Authorization.hasPermission(uid, `create-${type}`);
if (!hasPermission) {
throw new Error('no-permission');
}
const user = await Users.findOneById(uid);
if (!user?.username) {
throw new Error('User not found');
}
// TODO convert `createRoom` function to "raw" and move to here
return createRoom(type, name, user, members, false, readOnly, extraData, options) as unknown as IRoom;
}
async createDirectMessage({ to, from }: { to: string; from: string }): Promise<{ rid: string }> {
const [toUser, fromUser] = await Promise.all([
Users.findOneById(to, { projection: { username: 1 } }),
Users.findOneById(from, { projection: { _id: 1 } }),
]);
if (!toUser?.username || !fromUser) {
throw new Error('error-invalid-user');
}
return this.createDirectMessageWithMultipleUsers([toUser.username], fromUser._id);
}
async createDirectMessageWithMultipleUsers(members: string[], creatorId: string): Promise<{ rid: string }> {
return createDirectMessage(members, creatorId);
}
async addMember(uid: string, rid: string): Promise<boolean> {
const hasPermission = await Authorization.hasPermission(uid, 'add-user-to-joined-room', rid);
if (!hasPermission) {
throw new Error('no-permission');
}
return true;
}
async addUserToRoom(
roomId: string,
user: Pick<IUser, '_id'>,
inviter?: Pick<IUser, '_id' | 'username'>,
options?: {
skipSystemMessage?: boolean;
skipAlertSound?: boolean;
},
): Promise<boolean | undefined> {
return addUserToRoom(roomId, user, inviter, options);
}
async removeUserFromRoom(
roomId: string,
user: IUser,
options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues },
): Promise<void> {
return removeUserFromRoom(roomId, user, options);
}
async performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise<void> {
return performUserRemoval(room, user, options);
}
async performUserBan(room: IRoom, user: IUser, byUser: IUser): Promise<void> {
return performUserBan(room, user, byUser);
}
async performUserUnban(room: IRoom, user: IUser, byUser: IUser): Promise<void> {
return executeUnbanUserFromRoom(room._id, user, byUser);
}
async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise<void> {
return performAcceptRoomInvite(room, subscription, user);
}
async revokeInvite(room: IRoom, user: IUser): Promise<void> {
const subscription = await Subscriptions.removeInvitedByRoomIdAndUserId(room._id, user._id);
if (!subscription) {
return;
}
void notifyOnSubscriptionChanged(subscription, 'removed');
}
async getValidRoomName(displayName: string, roomId = '', options: { allowDuplicates?: boolean } = {}): Promise<string> {
return getValidRoomName(displayName, roomId, options);
}
async saveRoomTopic(
roomId: string,
roomTopic: string | undefined,
user: Pick<IUser, 'username' | '_id' | 'federation' | 'federated'>,
sendMessage = true,
): Promise<void> {
await saveRoomTopic(roomId, roomTopic, user, sendMessage);
}
async getRouteLink(room: AtLeast<IRoom, '_id' | 't' | 'name'>): Promise<string | boolean> {
return roomCoordinator.getRouteLink(room.t as string, { rid: room._id, name: room.name });
}
/**
* Method called by users to join a room.
*/
async join({ room, user, joinCode }: { room: IRoom; user: IUser; joinCode?: string }) {
if (!(await roomCoordinator.getRoomDirectives(room.t)?.allowMemberAction(room, RoomMemberActions.JOIN, user._id))) {
throw new MeteorError('error-not-allowed', 'Not allowed', { method: 'joinRoom' });
}
if (isOmnichannelRoom(room) && !room.open) {
throw new MeteorError('room-closed', 'Room is closed', { method: 'joinRoom' });
}
if (!(await Authorization.canAccessRoom(room, user))) {
throw new MeteorError('error-not-allowed', 'Not allowed', { method: 'joinRoom' });
}
if (
FederationActions.shouldPerformFederationAction(room) &&
!isUserNativeFederated(user) &&
!(await FederationMatrix.canUserAccessFederation(user))
) {
throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation', { method: 'joinRoom' });
}
if (isRoomWithJoinCode(room) && !(await Authorization.hasPermission(user._id, 'join-without-join-code'))) {
if (!joinCode) {
throw new MeteorError('error-code-required', 'Code required', { method: 'joinRoom' });
}
const isCorrectJoinCode = !!(await Rooms.findOneByJoinCodeAndId(joinCode, room._id, {
projection: { _id: 1 },
}));
if (!isCorrectJoinCode) {
throw new MeteorError('error-code-invalid', 'Invalid code', { method: 'joinRoom' });
}
}
return addUserToRoom(room._id, user);
}
async beforeLeave(room: IRoom): Promise<void> {
FederationActions.blockIfRoomFederatedButServiceNotReady(room);
}
async beforeUserRemoved(room: IRoom): Promise<void> {
FederationActions.blockIfRoomFederatedButServiceNotReady(room);
}
async beforeNameChange(room: IRoom): Promise<void> {
FederationActions.blockIfRoomFederatedButServiceNotReady(room);
}
async beforeTopicChange(room: IRoom): Promise<void> {
FederationActions.blockIfRoomFederatedButServiceNotReady(room);
}
async saveRoomName(roomId: string, userId: string, name: string) {
const user = await Users.findOneById(userId);
if (!user) {
throw new Error('User not found');
}
await saveRoomName(roomId, name, user);
}
public async addUserRoleRoomScoped(
fromUserId: string,
userId: string,
roomId: string,
role: 'moderator' | 'owner' | 'leader' | 'user',
): Promise<void> {
if (role === 'moderator') {
await addRoomModerator(fromUserId, roomId, userId);
return;
}
if (role === 'owner') {
await addRoomOwner(fromUserId, roomId, userId);
return;
}
if (role === 'leader') {
await addRoomLeader(fromUserId, roomId, userId);
return;
}
const sub = await Subscriptions.findByUserIdAndRoomIds(userId, [roomId], { projection: { roles: 1 } }).next();
if (!sub) {
throw new Error('user and room subsciption not found');
}
if (!sub.roles) {
return; // 'user' role essentially
}
for await (const currentRole of sub.roles) {
if (currentRole === 'owner') {
await removeRoomOwner(fromUserId, roomId, userId);
return;
}
if (currentRole === 'leader') {
await removeRoomLeader(fromUserId, roomId, userId);
return;
}
if (currentRole === 'moderator') {
await removeRoomModerator(fromUserId, roomId, userId);
return;
}
}
}
async createUserSubscription({
room,
ts,
userToBeAdded,
inviter,
createAsHidden = false,
skipAlertSound = false,
skipSystemMessage = false,
status,
roles,
}: {
room: IRoom;
ts: Date;
userToBeAdded: IUser;
inviter?: Pick<IUser, '_id' | 'username' | 'name'>;
createAsHidden?: boolean;
skipAlertSound?: boolean;
skipSystemMessage?: boolean;
status?: 'INVITED';
roles?: ISubscription['roles'];
}): Promise<string | undefined> {
const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded);
const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, {
ts,
open: !createAsHidden,
alert: createAsHidden ? false : !skipAlertSound,
unread: 1,
userMentions: 1,
groupMentions: 0,
...(roles && { roles }),
...(status && { status }),
...(inviter && { inviter: { _id: inviter._id, username: inviter.username!, ...(inviter.name && { name: inviter.name }) } }),
...autoTranslateConfig,
...getDefaultSubscriptionPref(userToBeAdded),
// `name` is optional for users, so fall back to the username like `getNameForDMs` does
...(room.t === 'd' && inviter && { fname: inviter.name || inviter.username, name: inviter.username }),
});
if (insertedId) {
void notifyOnSubscriptionChangedById(insertedId, 'inserted');
}
if (!skipSystemMessage && userToBeAdded.username) {
if (inviter) {
const extraData = {
ts,
u: {
_id: inviter._id,
username: inviter.username,
},
};
if (room.teamMain) {
await Message.saveSystemMessage('added-user-to-team', room._id, userToBeAdded.username, userToBeAdded, extraData);
} else if (status === 'INVITED') {
await Message.saveSystemMessage('ui', room._id, userToBeAdded.username, userToBeAdded, {
u: { _id: inviter._id, username: inviter.username },
});
} else {
await Message.saveSystemMessage('au', room._id, userToBeAdded.username, userToBeAdded, extraData);
}
} else if (room.prid) {
await Message.saveSystemMessage('ut', room._id, userToBeAdded.username, userToBeAdded, { ts });
} else if (room.teamMain) {
await Message.saveSystemMessage('ujt', room._id, userToBeAdded.username, userToBeAdded, { ts });
} else {
await Message.saveSystemMessage('uj', room._id, userToBeAdded.username, userToBeAdded, { ts });
}
}
return insertedId;
}
async markAsRead(room: IRoom, userId: string, readThreads = false): Promise<void> {
await readMessages(room, userId, readThreads);
}
async readThread({ user, room, tmid }: { user: IUser; room: IRoom; tmid: string }): Promise<void> {
await readThread({
user,
room,
tmid,
});
}
async unbanAndInviteUser(
subscription: ISubscription,
inviteeUser: Pick<IUser, '_id' | 'username' | 'name'>,
inviterUser: Required<Pick<IUser, '_id' | 'username'>> & Pick<IUser, 'name'>,
): Promise<void> {
await Subscriptions.unbanToInvitedById(subscription._id, inviterUser);
void notifyOnSubscriptionChangedById(subscription._id, 'updated');
if (inviteeUser?.username) {
await Message.saveSystemMessage('ui', subscription.rid, inviteeUser.username, inviteeUser, {
u: { _id: inviterUser._id, username: inviterUser.username },
});
}
}
}

View File

@@ -0,0 +1,16 @@
import { Emitter } from '@rocket.chat/emitter';
export const sauEvents = new Emitter<{
'sau.accounts.login': {
userId: string;
instanceId: string;
connectionId: string;
loginToken?: string;
clientAddress: string;
userAgent: string;
host: string;
};
'sau.accounts.logout': { userId: string; sessionId: string };
'sau.socket.connected': { instanceId: string; connectionId: string };
'sau.socket.disconnected': { instanceId: string; connectionId: string };
}>();

View File

@@ -0,0 +1,39 @@
// import type { Db } from 'mongodb';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { ISAUMonitorService } from '@rocket.chat/core-services';
import { getHeader } from '@rocket.chat/tools';
import { sauEvents } from './events';
export class SAUMonitorService extends ServiceClassInternal implements ISAUMonitorService {
protected name = 'sau-monitor';
constructor() {
super();
this.onEvent('accounts.login', async ({ userId, connection }) => {
sauEvents.emit('sau.accounts.login', {
userId,
instanceId: connection.instanceId,
connectionId: connection.id,
loginToken: connection.loginToken,
clientAddress: connection.clientAddress || getHeader(connection.httpHeaders, 'x-real-ip'),
userAgent: getHeader(connection.httpHeaders, 'user-agent'),
host: getHeader(connection.httpHeaders, 'host'),
});
});
this.onEvent('accounts.logout', async ({ userId, connection }) => {
sauEvents.emit('sau.accounts.logout', { userId, sessionId: connection.id });
});
this.onEvent('socket.disconnected', async (data) => {
sauEvents.emit('sau.socket.disconnected', { instanceId: data.instanceId, connectionId: data.id });
});
this.onEvent('socket.connected', async (data) => {
sauEvents.emit('sau.socket.connected', { instanceId: data.instanceId, connectionId: data.id });
});
}
}

View File

@@ -0,0 +1,29 @@
import type { ISettingsService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { SettingValue } from '@rocket.chat/core-typings';
import { Settings } from '@rocket.chat/models';
import { notifyOnSettingChangedById } from '../../lib/notifyListener';
import { settings } from '../../settings';
import { verifyFingerPrint } from '../../settings/misc';
export class SettingsService extends ServiceClassInternal implements ISettingsService {
protected name = 'settings';
async get<T extends SettingValue>(settingId: string): Promise<T> {
return settings.get<T>(settingId);
}
async set<T extends SettingValue>(settingId: string, value: T): Promise<void> {
const update = await Settings.updateValueById(settingId, value);
if (update.modifiedCount) {
void notifyOnSettingChangedById(settingId);
}
}
override async started() {
settings.change('Site_Url', () => {
void verifyFingerPrint();
});
}
}

View File

@@ -0,0 +1,84 @@
import { api } from '@rocket.chat/core-services';
import { Logger } from '@rocket.chat/logger';
import { OmnichannelTranscript, QueueWorker } from '@rocket.chat/omnichannel-services';
import { MongoInternals } from 'meteor/mongo';
import { isRunningMs } from '../lib/isRunningMs';
import { AISearchService } from './ai-search/service';
import { AnalyticsService } from './analytics/service';
import { AppsEngineService } from './apps-engine/service';
import { BannerService } from './banner/service';
import { CalendarService } from './calendar/service';
import { CallHistoryService } from './call-history/service';
import { DeviceManagementService } from './device-management/service';
import { MediaService } from './image/service';
import { ImportService } from './import/service';
import { LDAPService } from './ldap/service';
import { MediaCallService } from './media-call/service';
import { MessageService } from './messages/service';
import { MeteorService } from './meteor/service';
import { NPSService } from './nps/service';
import { OmnichannelService } from './omnichannel/service';
import { OmnichannelAnalyticsService } from './omnichannel-analytics/service';
import { OmnichannelIntegrationService } from './omnichannel-integrations/service';
import { PushService } from './push/service';
import { RoomService } from './room/service';
import { SAUMonitorService } from './sauMonitor/service';
import { SettingsService } from './settings/service';
import { StatusVisibilityService } from './statusVisibility/service';
import { TeamService } from './team/service';
import { UiKitCoreAppService } from './uikit-core-app/service';
import { UploadService } from './upload/service';
import { UserService } from './user/service';
import { VideoConfService } from './video-conference/service';
import { i18n } from '../lib/i18n';
import { AuthorizationLivechat } from '../lib/omnichannel/roomAccessValidator.internalService';
export const registerServices = async (): Promise<void> => {
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
api.registerService(new AppsEngineService());
api.registerService(new AnalyticsService());
api.registerService(new AuthorizationLivechat());
api.registerService(new BannerService());
api.registerService(new CalendarService());
api.registerService(new LDAPService());
api.registerService(new MediaService());
api.registerService(new MeteorService());
api.registerService(new NPSService());
api.registerService(new RoomService());
api.registerService(new SAUMonitorService());
api.registerService(new OmnichannelService());
api.registerService(new TeamService());
api.registerService(new UiKitCoreAppService());
api.registerService(new PushService());
api.registerService(new DeviceManagementService());
api.registerService(new VideoConfService());
api.registerService(new UploadService());
api.registerService(new MessageService());
api.registerService(new SettingsService());
api.registerService(new StatusVisibilityService());
api.registerService(new OmnichannelIntegrationService());
api.registerService(new ImportService());
api.registerService(new OmnichannelAnalyticsService());
api.registerService(new UserService());
api.registerService(new MediaCallService());
api.registerService(new CallHistoryService());
api.registerService(new AISearchService());
// if the process is running in micro services mode we don't need to register services that will run separately
if (!isRunningMs()) {
const { Presence } = await import('@rocket.chat/presence');
const { Authorization } = await import('./authorization/service');
api.registerService(new Presence());
api.registerService(new Authorization());
// Run EE services defined outside of the main repo
// Otherwise, monolith would ignore them :(
// Always register the service and manage licensing inside the service (tbd)
api.registerService(new QueueWorker(db, Logger));
api.registerService(new OmnichannelTranscript(Logger, i18n));
}
};

View File

@@ -0,0 +1,156 @@
import { StatusVisibilityService } from './service';
const getSetting = jest.fn();
const broadcast = jest.fn();
const findPresenceUsersByIds = jest.fn();
const findWithStatusVisibilityConfig = jest.fn();
jest.mock('@rocket.chat/core-services', () => ({
api: { broadcast: (...args: unknown[]) => broadcast(...args) },
Settings: { get: (key: string) => getSetting(key) },
ServiceClassInternal: class {
onSettingChanged() {
// no-op
}
},
}));
jest.mock('@rocket.chat/models', () => ({
Users: {
findPresenceUsersByIds: (...args: unknown[]) => findPresenceUsersByIds(...args),
findWithStatusVisibilityConfig: (...args: unknown[]) => findWithStatusVisibilityConfig(...args),
},
}));
const cursor = (users: object[]) => ({ toArray: async () => users });
const flush = () => new Promise((resolve) => setImmediate(resolve));
const blocking = (id: string, blocked: string[]) => ({ _id: id, settings: { preferences: { statusVisibilityDenied: blocked } } });
describe('status visibility service', () => {
let service: StatusVisibilityService;
beforeEach(async () => {
jest.resetAllMocks();
getSetting.mockReturnValue(true);
broadcast.mockResolvedValue(undefined);
findPresenceUsersByIds.mockReturnValue(cursor([]));
findWithStatusVisibilityConfig.mockReturnValue(cursor([]));
service = new StatusVisibilityService();
await service.refresh();
});
it('hides a target from the viewers they blocked, and only from them', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh();
expect(await service.getHiddenFrom('bruno')).toEqual(['ana']);
expect(await service.getHiddenFrom('carla')).toEqual([]);
});
it('never hides a target from themselves nor from an anonymous viewer', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['ana', 'bruno'])]));
await service.refresh();
expect(await service.getHiddenFrom('ana')).toEqual([]);
expect(await service.getHiddenFrom(null)).toEqual([]);
});
it('drops an entry when a targeted refresh finds the block list gone', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh(['ana']);
expect(await service.getHiddenFrom('bruno')).toEqual(['ana']);
findWithStatusVisibilityConfig.mockReturnValue(cursor([]));
findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }]));
const affected = await service.refresh(['ana']);
expect(await service.getHiddenFrom('bruno')).toEqual([]);
expect(affected.map(({ _id }) => _id)).toEqual(['ana']);
});
it('flags users with an active block list, only while the feature is on', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh();
expect(await service.hasRestrictions('ana')).toBe(true);
expect(await service.hasRestrictions('carla')).toBe(false);
getSetting.mockReturnValue(false);
await service.refresh();
expect(await service.hasRestrictions('ana')).toBe(false);
});
it('reports the viewers on both sides of a block list change', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh();
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['carla'])]));
await service.invalidate(['ana']);
const [, payload] = broadcast.mock.calls.at(-1) as [string, { viewers?: string[] }];
expect(payload.viewers?.sort()).toEqual(['bruno', 'carla']);
});
it('leaves the viewers unscoped when the whole mirror is invalidated', async () => {
await service.invalidate();
const [, payload] = broadcast.mock.calls.at(-1) as [string, { viewers?: string[] }];
expect(payload.viewers).toBeUndefined();
});
it('clears everything when the setting is off and reports who became visible', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh();
getSetting.mockReturnValue(false);
findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }]));
const affected = await service.refresh();
expect(affected.map(({ _id }) => _id)).toEqual(['ana']);
expect(await service.getHiddenFrom('bruno')).toEqual([]);
});
it('reports every user whose visibility may have changed, configured before or after', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno'])]));
await service.refresh();
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('carla', ['bruno'])]));
findPresenceUsersByIds.mockReturnValue(cursor([{ _id: 'ana' }]));
const affected = await service.refresh();
expect(affected.map(({ _id }) => _id).sort()).toEqual(['ana', 'carla']);
});
it('applies overlapping refreshes in order, so an older read never wins', async () => {
const reads: Array<(users: object[]) => void> = [];
findWithStatusVisibilityConfig.mockImplementation(() => ({
toArray: () => new Promise((resolve) => reads.push(resolve as (users: object[]) => void)),
}));
const stale = service.refresh();
const fresh = service.refresh(['ana']);
await flush();
expect(reads).toHaveLength(1);
reads[0]([]);
await stale;
await flush();
expect(reads).toHaveLength(2);
reads[1]([blocking('ana', ['bruno'])]);
await fresh;
expect(await service.getHiddenFrom('bruno')).toEqual(['ana']);
});
it('lists who hid their status from a given viewer', async () => {
findWithStatusVisibilityConfig.mockReturnValue(cursor([blocking('ana', ['bruno']), blocking('carla', ['bruno', 'diego'])]));
await service.refresh();
expect((await service.getHiddenFrom('bruno')).sort()).toEqual(['ana', 'carla']);
expect(await service.getHiddenFrom('diego')).toEqual(['carla']);
expect(await service.getHiddenFrom('elena')).toEqual([]);
});
});

View File

@@ -0,0 +1,113 @@
import type { IStatusVisibilityService } from '@rocket.chat/core-services';
import { api, ServiceClassInternal, Settings } from '@rocket.chat/core-services';
import type { IUser, UserPresence } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
const logger = new Logger('StatusVisibility');
const PRESENCE_FIELDS = { username: 1, status: 1, statusText: 1, statusSource: 1, statusExpiresAt: 1 } as const;
export class StatusVisibilityService extends ServiceClassInternal implements IStatusVisibilityService {
protected name = 'status-visibility';
private enabled = false;
private hiddenFromByUser = new Map<IUser['_id'], Set<IUser['_id']>>();
private lock: Promise<unknown> = Promise.resolve();
constructor() {
super();
this.onSettingChanged('Accounts_StatusVisibility_Enabled', async () => {
await this.invalidate();
});
}
override async started(): Promise<void> {
await this.refresh();
}
async getHiddenFrom(viewerId: IUser['_id'] | null | undefined): Promise<IUser['_id'][]> {
if (!this.enabled || !viewerId) {
return [];
}
return [...this.hiddenFromByUser]
.filter(([targetId, viewers]) => targetId !== viewerId && viewers.has(viewerId))
.map(([targetId]) => targetId);
}
async hasRestrictions(targetId: IUser['_id']): Promise<boolean> {
return this.enabled && this.hiddenFromByUser.has(targetId);
}
async getRestrictedUsers(): Promise<IUser['_id'][]> {
return [...this.hiddenFromByUser.keys()];
}
async refresh(targets?: IUser['_id'][]): Promise<UserPresence[]> {
const result = this.lock.then(() => this.rebuildHiddenUsers(targets));
this.lock = result.catch(() => undefined);
return result;
}
private async rebuildHiddenUsers(targets?: IUser['_id'][]): Promise<UserPresence[]> {
this.enabled = (await Settings.get<boolean>('Accounts_StatusVisibility_Enabled')) === true;
if (!this.enabled) {
const previous = [...this.hiddenFromByUser.keys()];
this.hiddenFromByUser.clear();
return previous.length ? Users.findPresenceUsersByIds(previous, { projection: PRESENCE_FIELDS }).toArray() : [];
}
const previous = targets ?? [...this.hiddenFromByUser.keys()];
const users = await Users.findWithStatusVisibilityConfig(targets).toArray();
if (targets) {
targets.forEach((uid) => this.hiddenFromByUser.delete(uid));
} else {
this.hiddenFromByUser.clear();
}
for (const { _id, settings: userSettings } of users) {
const viewers = userSettings?.preferences?.statusVisibilityDenied;
if (viewers?.length) {
this.hiddenFromByUser.set(_id, new Set(viewers));
}
}
const dropped = previous.filter((uid) => !this.hiddenFromByUser.has(uid));
if (dropped.length) {
users.push(...(await Users.findPresenceUsersByIds(dropped, { projection: PRESENCE_FIELDS }).toArray()));
}
return users;
}
private viewersOf(targets: IUser['_id'][]): IUser['_id'][] {
const viewers = new Set<IUser['_id']>();
for (const target of targets) {
this.hiddenFromByUser.get(target)?.forEach((viewer) => viewers.add(viewer));
}
return [...viewers];
}
async invalidate(targets?: IUser['_id'][]): Promise<UserPresence[]> {
const previousViewers = targets && this.viewersOf(targets);
const affected = await this.refresh(targets);
const viewers = previousViewers && [...new Set([...previousViewers, ...this.viewersOf(targets)])];
void api
.broadcast('presence.invalidateVisibility', { targets, viewers })
.catch((err) => logger.error({ msg: 'Status visibility invalidation failed', err, targets }));
return affected;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type {
IUiKitCoreApp,
IUiKitCoreAppService,
UiKitCoreAppBlockActionPayload,
UiKitCoreAppViewClosedPayload,
UiKitCoreAppViewSubmitPayload,
} from '@rocket.chat/core-services';
const registeredApps = new Map<string, IUiKitCoreApp>();
const getAppModule = (appId: string) => {
const module = registeredApps.get(appId);
if (typeof module === 'undefined') {
throw new Error('invalid service name');
}
return module;
};
export const registerCoreApp = (module: IUiKitCoreApp): void => {
registeredApps.set(module.appId, module);
};
export class UiKitCoreAppService extends ServiceClassInternal implements IUiKitCoreAppService {
protected name = 'uikit-core-app';
async isRegistered(appId: string): Promise<boolean> {
return registeredApps.has(appId);
}
async blockAction(payload: UiKitCoreAppBlockActionPayload) {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) return undefined;
return service.blockAction?.(payload);
}
async viewClosed(payload: UiKitCoreAppViewClosedPayload) {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) return undefined;
return service.viewClosed?.(payload);
}
async viewSubmit(payload: UiKitCoreAppViewSubmitPayload) {
const { appId } = payload;
const service = getAppModule(appId);
if (!service) {
return;
}
return service.viewSubmit?.(payload);
}
}

View File

@@ -0,0 +1,229 @@
import fs from 'node:fs';
import type Stream from 'node:stream';
import type { IUploadDetails } from '@rocket.chat/apps-engine/definition/uploads/IUploadDetails';
import { api, ServiceClassInternal } from '@rocket.chat/core-services';
import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services';
import type { IUpload, IUser, FilesAndAttachments, IMessage, AtLeast } from '@rocket.chat/core-typings';
import { isFileAttachment } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Uploads, Users } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import sharp from 'sharp';
import { NOTIFICATION_ATTACHMENT_COLOR } from '../../../lib/constants';
import { canAccessRoomIdAsync } from '../../lib/authorization/canAccessRoom';
import { canDeleteMessageAsync } from '../../lib/authorization/canDeleteMessage';
import { i18n } from '../../lib/i18n';
import { FileUpload } from '../../lib/media/file-upload';
import { updateMessage } from '../../lib/messages/updateMessage';
import { setUserAvatar } from '../../lib/users/setUserAvatar';
import { parseFileIntoMessageAttachments, sendFileMessage } from '../../meteor-methods/messages/sendFileMessage';
import { sendFileLivechatMessage } from '../../meteor-methods/omnichannel/sendFileLivechatMessage';
import { UploadFS } from '../../ufs';
const logger = new Logger('UploadService');
export class UploadService extends ServiceClassInternal implements IUploadService {
protected name = 'upload';
async uploadFile({ buffer, details, federation }: IUploadFileParams): Promise<IUpload> {
const fileStore = FileUpload.getStore('Uploads');
return fileStore.insert({ ...details, ...(federation && { federation }) }, buffer);
}
async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise<boolean | undefined> {
return sendFileMessage(userId, { roomId, file, msgData: message });
}
async sendFileLivechatMessage({ roomId, visitorToken, file, message }: ISendFileLivechatMessageParams): Promise<boolean> {
return sendFileLivechatMessage({ roomId, visitorToken, file, msgData: message });
}
async getFileBuffer({ file }: { file: IUpload }): Promise<Buffer> {
const buffer = await FileUpload.getBuffer(file);
if (!(buffer instanceof Buffer)) {
throw new Error('Unknown error');
}
return buffer;
}
async extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }> {
return FileUpload.extractMetadata(file);
}
async parseFileIntoMessageAttachments(file: Partial<IUpload>, roomId: string, user: IUser): Promise<FilesAndAttachments> {
return parseFileIntoMessageAttachments(file, roomId, user);
}
async canDeleteFile(
user: Pick<IUser, '_id' | 'username'>,
file: Pick<IUpload, '_id' | 'userId' | 'rid' | 'expiresAt' | 'uploadedAt'>,
msg: IMessage | null,
): Promise<boolean> {
if (msg) {
return canDeleteMessageAsync(user, msg);
}
if (!file.userId || !file.rid) {
return false;
}
// If file is not confirmed and was sent by the same user
if (file.expiresAt && file.userId === user._id) {
return canAccessRoomIdAsync(file.rid, user._id);
}
// It's a confirmed file but it has no message, so use data from the file to run message delete permission checks
const msgForValidation = { u: { _id: file.userId }, ts: file.uploadedAt, rid: file.rid };
return canDeleteMessageAsync(user, msgForValidation);
}
async deleteFile(user: IUser, fileId: IUpload['_id'], msg: IMessage | null): Promise<{ deletedFiles: IUpload['_id'][] }> {
// Find every file that is derived from the file that is being deleted (its thumbnails)
const additionalFiles = await Uploads.findAllByOriginalFileId(fileId, { projection: { _id: 1 } })
.map(({ _id }) => _id)
.toArray();
const allFiles = [fileId, ...additionalFiles];
if (msg) {
await this.updateMessageRemovingFiles(msg, allFiles, user);
}
return this.removeFileAndDerivates(fileId, additionalFiles);
}
private async removeFileAndDerivates(
fileId: IUpload['_id'],
additionalFiles: IUpload['_id'][],
): Promise<{ deletedFiles: IUpload['_id'][] }> {
const store = FileUpload.getStore('Uploads');
// Delete the main file first;
await store.deleteById(fileId);
// The main file is already deleted; From here forward we'll return a success response even if some sub-process fails
const deletedFiles: IUpload['_id'][] = [fileId];
// Delete them one by one as the store may include requests to external services
for await (const id of additionalFiles) {
try {
await store.deleteById(id);
deletedFiles.push(id);
} catch (err) {
logger.error({ msg: 'Failed to delete derived file', fileId: id, originalFileId: fileId, err });
}
}
return { deletedFiles };
}
private async updateMessageRemovingFiles(msg: IMessage, filesToRemove: IUpload['_id'][], user: IUser): Promise<void> {
const text = `_${i18n.t('File_removed')}_`;
const color = NOTIFICATION_ATTACHMENT_COLOR;
const newFiles = msg.files?.filter((file) => !filesToRemove.includes(file._id));
const newAttachments = msg.attachments?.map((attachment) => {
if (!isFileAttachment(attachment)) {
return attachment;
}
// If the attachment doesn't have a `fileId`, we assume it's an old message with only one file, in which case checking the id is not needed
if (attachment.fileId && !filesToRemove.includes(attachment.fileId)) {
return attachment;
}
return { type: 'removed-file', color, text, ...(attachment.fileId && { fileId: attachment.fileId }) };
});
const newFile = msg.file?._id && !filesToRemove.includes(msg.file._id) ? msg.file : newFiles?.[0];
const editedMessage = {
...msg,
files: newFiles,
attachments: newAttachments,
file: newFile,
};
await updateMessage(editedMessage, user, msg);
}
async streamUploadedFile({
file,
imageResizeOpts,
}: {
file: IUpload;
imageResizeOpts?: { width: number; height: number };
}): Promise<Stream.Readable> {
const stream = await FileUpload.getStore('Uploads')._store.getReadStream(file._id, file);
if (!stream) {
throw new Error('error-file-not-found');
}
if (file?.type?.includes('image') && imageResizeOpts) {
const { width, height } = imageResizeOpts;
const transformer = sharp().resize({ width, height, fit: 'contain' });
stream.on('error', (err) => transformer.destroy(err));
return stream.pipe(transformer);
}
return stream;
}
async uploadFileFromStream({
streamParam,
details,
}: {
streamParam: Stream.Readable;
details: Omit<IUploadDetails, 'size'>;
}): Promise<IUpload> {
const resolver = Promise.withResolvers<IUpload>();
const tempFilePath = UploadFS.getTempFilePath(Random.id());
const writeStream = fs.createWriteStream(tempFilePath);
streamParam.pipe(writeStream);
const cleanup = (err: unknown) => {
fs.promises.unlink(tempFilePath).catch(() => undefined);
resolver.reject(err);
};
writeStream.on('finish', async () => {
FileUpload.getStore('Uploads')
.insert(
{
...details,
size: writeStream.bytesWritten,
},
tempFilePath,
)
.then(resolver.resolve)
.catch(cleanup);
});
streamParam.on('error', async (err) => {
writeStream.destroy();
cleanup(err);
});
writeStream.on('error', cleanup);
return resolver.promise;
}
async setUserAvatar(user: Pick<IUser, '_id' | 'username'>, buffer: Buffer, contentType: string, service: 'rest'): Promise<void> {
return setUserAvatar(user, buffer, contentType, service);
}
async resetUserAvatar(user: AtLeast<IUser, '_id' | 'username'>): Promise<void> {
if (!user.username) {
throw new Error('Username is required to reset avatar');
}
await FileUpload.getStore('Avatars').deleteByName(user.username);
await Users.unsetAvatarData(user._id);
void api.broadcast('user.avatarUpdate', { username: user.username, avatarETag: undefined });
}
}

View File

@@ -0,0 +1,12 @@
import type { IRole } from '@rocket.chat/core-typings';
import { parseCSV } from '../../../../lib/utils/parseCSV';
import { settings } from '../../../settings';
export function getNewUserRoles(previousRoles?: IRole['_id'][]): IRole['_id'][] {
const currentRoles = previousRoles ?? [];
const defaultUserRoles = parseCSV(settings.get<string>('Accounts_Registration_Users_Default_Roles') || '');
return [...new Set([...currentRoles, ...defaultUserRoles])];
}

View File

@@ -0,0 +1,20 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IUserService } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import { getMaxLoginTokens } from '../../lib/getMaxLoginTokens';
// TODO merge this service with Account service
export class UserService extends ServiceClassInternal implements IUserService {
protected name = 'user';
async ensureLoginTokensLimit(uid: string): Promise<void> {
const [{ tokens } = { tokens: [] }] = await Users.findAllResumeTokensByUserId(uid);
if (tokens.length < getMaxLoginTokens()) {
return;
}
const oldestDate = tokens.reverse()[getMaxLoginTokens() - 1];
await Users.removeOlderResumeTokensByUserId(uid, oldestDate.when);
}
}

File diff suppressed because it is too large Load Diff