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
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:
434
apps/meteor/server/services/ai-search/service.ts
Normal file
434
apps/meteor/server/services/ai-search/service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/meteor/server/services/analytics/service.ts
Normal file
22
apps/meteor/server/services/analytics/service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
250
apps/meteor/server/services/apps-engine/service.ts
Normal file
250
apps/meteor/server/services/apps-engine/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
139
apps/meteor/server/services/authorization/canAccessRoom.ts
Normal file
139
apps/meteor/server/services/authorization/canAccessRoom.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
25
apps/meteor/server/services/authorization/canReadRoom.ts
Normal file
25
apps/meteor/server/services/authorization/canReadRoom.ts
Normal 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;
|
||||
};
|
||||
244
apps/meteor/server/services/authorization/service.ts
Normal file
244
apps/meteor/server/services/authorization/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
189
apps/meteor/server/services/banner/service.ts
Normal file
189
apps/meteor/server/services/banner/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
390
apps/meteor/server/services/calendar/service.ts
Normal file
390
apps/meteor/server/services/calendar/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
39
apps/meteor/server/services/call-history/service.ts
Normal file
39
apps/meteor/server/services/call-history/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
apps/meteor/server/services/device-management/events.ts
Normal file
5
apps/meteor/server/services/device-management/events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
|
||||
export const deviceManagementEvents = new Emitter<{
|
||||
'device-login': { userId: string; userAgent: string; clientAddress: string };
|
||||
}>();
|
||||
23
apps/meteor/server/services/device-management/service.ts
Normal file
23
apps/meteor/server/services/device-management/service.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
156
apps/meteor/server/services/federation/Settings.ts
Normal file
156
apps/meteor/server/services/federation/Settings.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
22
apps/meteor/server/services/federation/utils.ts
Normal file
22
apps/meteor/server/services/federation/utils.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
108
apps/meteor/server/services/image/service.ts
Normal file
108
apps/meteor/server/services/image/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
423
apps/meteor/server/services/import/service.spec.ts
Normal file
423
apps/meteor/server/services/import/service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
182
apps/meteor/server/services/import/service.ts
Normal file
182
apps/meteor/server/services/import/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
apps/meteor/server/services/ldap/service.ts
Normal file
25
apps/meteor/server/services/ldap/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
3
apps/meteor/server/services/media-call/logger.ts
Normal file
3
apps/meteor/server/services/media-call/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('MediaCall');
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
473
apps/meteor/server/services/media-call/service.ts
Normal file
473
apps/meteor/server/services/media-call/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
379
apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts
Normal file
379
apps/meteor/server/services/messages/hooks/AfterSaveOEmbed.ts
Normal 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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
196
apps/meteor/server/services/messages/lib/oembed/providers.ts
Normal file
196
apps/meteor/server/services/messages/lib/oembed/providers.ts
Normal 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;
|
||||
};
|
||||
352
apps/meteor/server/services/messages/service.ts
Normal file
352
apps/meteor/server/services/messages/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
218
apps/meteor/server/services/meteor/service.ts
Normal file
218
apps/meteor/server/services/meteor/service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
136
apps/meteor/server/services/meteor/userReactivity.ts
Normal file
136
apps/meteor/server/services/meteor/userReactivity.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
};
|
||||
71
apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts
Normal file
71
apps/meteor/server/services/nps/getAndCreateNpsSurvey.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
54
apps/meteor/server/services/nps/notification.ts
Normal file
54
apps/meteor/server/services/nps/notification.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
37
apps/meteor/server/services/nps/sendNpsResults.ts
Normal file
37
apps/meteor/server/services/nps/sendNpsResults.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
192
apps/meteor/server/services/nps/service.ts
Normal file
192
apps/meteor/server/services/nps/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
411
apps/meteor/server/services/omnichannel-analytics/AgentData.ts
Normal file
411
apps/meteor/server/services/omnichannel-analytics/AgentData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
154
apps/meteor/server/services/omnichannel-analytics/ChartData.ts
Normal file
154
apps/meteor/server/services/omnichannel-analytics/ChartData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const serviceLogger = new Logger('OmnichannelAnalyticsService');
|
||||
175
apps/meteor/server/services/omnichannel-analytics/service.ts
Normal file
175
apps/meteor/server/services/omnichannel-analytics/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
apps/meteor/server/services/omnichannel-analytics/utils.ts
Normal file
30
apps/meteor/server/services/omnichannel-analytics/utils.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
3
apps/meteor/server/services/omnichannel/logger.ts
Normal file
3
apps/meteor/server/services/omnichannel/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const queueLogger = new Logger('OmnichannelQueue');
|
||||
281
apps/meteor/server/services/omnichannel/queue.ts
Normal file
281
apps/meteor/server/services/omnichannel/queue.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
apps/meteor/server/services/omnichannel/service.ts
Normal file
59
apps/meteor/server/services/omnichannel/service.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
3
apps/meteor/server/services/push/logger.ts
Normal file
3
apps/meteor/server/services/push/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('Push');
|
||||
57
apps/meteor/server/services/push/service.ts
Normal file
57
apps/meteor/server/services/push/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
33
apps/meteor/server/services/room/getNameForDMs.ts
Normal file
33
apps/meteor/server/services/room/getNameForDMs.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
394
apps/meteor/server/services/room/service.ts
Normal file
394
apps/meteor/server/services/room/service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/meteor/server/services/sauMonitor/events.ts
Normal file
16
apps/meteor/server/services/sauMonitor/events.ts
Normal 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 };
|
||||
}>();
|
||||
39
apps/meteor/server/services/sauMonitor/service.ts
Normal file
39
apps/meteor/server/services/sauMonitor/service.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
29
apps/meteor/server/services/settings/service.ts
Normal file
29
apps/meteor/server/services/settings/service.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
84
apps/meteor/server/services/startup.ts
Normal file
84
apps/meteor/server/services/startup.ts
Normal 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));
|
||||
}
|
||||
};
|
||||
156
apps/meteor/server/services/statusVisibility/service.spec.ts
Normal file
156
apps/meteor/server/services/statusVisibility/service.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
113
apps/meteor/server/services/statusVisibility/service.ts
Normal file
113
apps/meteor/server/services/statusVisibility/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1110
apps/meteor/server/services/team/service.ts
Normal file
1110
apps/meteor/server/services/team/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
61
apps/meteor/server/services/uikit-core-app/service.ts
Normal file
61
apps/meteor/server/services/uikit-core-app/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
229
apps/meteor/server/services/upload/service.ts
Normal file
229
apps/meteor/server/services/upload/service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
apps/meteor/server/services/user/lib/getNewUserRoles.ts
Normal file
12
apps/meteor/server/services/user/lib/getNewUserRoles.ts
Normal 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])];
|
||||
}
|
||||
20
apps/meteor/server/services/user/service.ts
Normal file
20
apps/meteor/server/services/user/service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1256
apps/meteor/server/services/video-conference/service.ts
Normal file
1256
apps/meteor/server/services/video-conference/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user