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

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

View File

@@ -0,0 +1,24 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Subscriptions } from '@rocket.chat/models';
import { callbacks } from '../lib/callbacks';
import { notifyOnSubscriptionChangedByUserId } from '../lib/notifyListener';
import { unarchiveUserSubscriptions } from '../lib/users/unarchiveUserSubscriptions';
const handleDeactivateUser = async (user: IUser): Promise<void> => {
const { modifiedCount } = await Subscriptions.setArchivedByUserId(user._id, true);
if (modifiedCount) {
void notifyOnSubscriptionChangedByUserId(user._id);
}
};
const handleActivateUser = async (user: IUser): Promise<void> => {
const unarchived = await unarchiveUserSubscriptions(user._id);
if (unarchived) {
void notifyOnSubscriptionChangedByUserId(user._id);
}
};
callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'subscription-archive-on-deactivate');
callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'subscription-unarchive-on-activate');

View File

@@ -0,0 +1,30 @@
import { Accounts } from 'meteor/accounts-base';
import type { ILoginAttempt } from '../../lib/auth/ILoginAttempt';
import { logFailedLoginAttempts } from '../../lib/auth/logLoginAttempts';
import { saveFailedLoginAttempts, saveSuccessfulLogin } from '../../lib/auth/restrictLoginAttempts';
import { callbacks } from '../../lib/callbacks';
import { settings } from '../../settings';
const ignoredErrorTypes = ['totp-required', 'error-login-blocked-for-user'];
Accounts.onLoginFailure(async (login: ILoginAttempt) => {
// do not save specific failed login attempts
if (
settings.get('Block_Multiple_Failed_Logins_Enabled') &&
login.error?.error &&
!ignoredErrorTypes.includes(String(login.error.error))
) {
await saveFailedLoginAttempts(login);
}
logFailedLoginAttempts(login);
});
callbacks.add('afterValidateLogin', (login: ILoginAttempt) => {
if (!settings.get('Block_Multiple_Failed_Logins_Enabled')) {
return;
}
return saveSuccessfulLogin(login);
});

View File

@@ -0,0 +1,2 @@
import './sauMonitorHooks';
import './userLogoutCleanUp';

View File

@@ -0,0 +1,61 @@
import { Message } from '@rocket.chat/core-services';
import type { IMessage, IUser, IRoom } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Rooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import type { SendMessageOptions } from '../../lib/messages/sendMessage';
export async function afterSaveMessage(
message: IMessage,
room: IRoom,
user: IUser,
{
roomUpdater,
options,
}: {
roomUpdater?: Updater<IRoom>;
options?: SendMessageOptions;
} = {},
): Promise<IMessage> {
const updater = roomUpdater ?? Rooms.getUpdater();
const data: IMessage = (await callbacks.run('afterSaveMessage', message, {
room,
user,
roomUpdater: updater,
options,
})) as unknown as IMessage;
if (!roomUpdater && updater.hasChanges()) {
await Rooms.updateFromUpdater({ _id: room._id }, updater);
}
void Message.afterSave({ message: data });
// TODO: Fix type - callback configuration needs to be updated
return data;
}
export function afterSaveMessageAsync(
message: IMessage,
room: IRoom,
user: IUser,
{
roomUpdater: updater,
options,
}: {
roomUpdater?: Updater<IRoom>;
options?: SendMessageOptions;
} = {},
): void {
const roomUpdater = updater ?? Rooms.getUpdater();
callbacks.runAsync('afterSaveMessage', message, { room, user, roomUpdater, options });
if (roomUpdater.hasChanges()) {
void Rooms.updateFromUpdater({ _id: room._id }, roomUpdater);
}
void Message.afterSave({ message });
}

View File

@@ -0,0 +1,140 @@
import { api } from '@rocket.chat/core-services';
import type { IMessage } from '@rocket.chat/core-typings';
import { isDirectMessageRoom, isEditedMessage, isOmnichannelRoom, isRoomFederated, getUserDisplayName } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import type { ActionsBlock } from '@rocket.chat/ui-kit';
import moment from 'moment';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { callbacks } from '../../lib/callbacks';
import { i18n } from '../../lib/i18n';
import { settings } from '../../settings';
const APP_ID = 'mention-core';
const getBlocks = (mentions: IMessage['mentions'], messageId: string, lng: string | undefined) => {
const stringifiedMentions = JSON.stringify(mentions);
return {
addUsersBlock: {
type: 'button',
appId: APP_ID,
blockId: messageId,
value: stringifiedMentions,
actionId: 'add-users',
text: {
type: 'plain_text',
text: i18n.t('Add_them', { lng }),
},
},
dismissBlock: {
type: 'button',
appId: APP_ID,
blockId: messageId,
value: stringifiedMentions,
actionId: 'dismiss',
text: {
type: 'plain_text',
text: i18n.t('Do_nothing', { lng }),
},
},
dmBlock: {
type: 'button',
appId: APP_ID,
value: stringifiedMentions,
blockId: messageId,
actionId: 'share-message',
text: {
type: 'plain_text',
text: i18n.t('Let_them_know', { lng }),
},
},
} as const;
};
callbacks.add(
'afterSaveMessage',
async (message, { room }) => {
// TODO: check if I need to test this 60 second rule.
// If the message was edited, or is older than 60 seconds (imported)
// the notifications will be skipped, so we can also skip this validation
if (isEditedMessage(message) || (message.ts && Math.abs(moment(message.ts).diff(moment())) > 60000) || !message.mentions) {
return message;
}
const mentions = message.mentions.filter(({ _id, type }) => _id !== 'all' && _id !== 'here' && type !== 'team');
if (!mentions.length) {
return message;
}
if (isDirectMessageRoom(room) || isRoomFederated(room) || isOmnichannelRoom(room)) {
return message;
}
const subs = await Subscriptions.findByRoomIdAndUserIds(
message.rid,
mentions.map(({ _id }) => _id),
{ projection: { u: 1 } },
).toArray();
// get all users that are mentioned but not in the channel
const mentionsUsersNotInChannel = mentions.filter(({ _id }) => !subs.some((sub) => sub.u._id === _id));
if (!mentionsUsersNotInChannel.length) {
return message;
}
const canAddUsersToThisRoom = await hasPermissionAsync(message.u._id, 'add-user-to-joined-room', message.rid);
const canAddToAnyRoom = await (room.t === 'c'
? hasPermissionAsync(message.u._id, 'add-user-to-any-c-room')
: hasPermissionAsync(message.u._id, 'add-user-to-any-p-room'));
const canDMUsers = await hasPermissionAsync(message.u._id, 'create-d'); // TODO: Perhaps check if user has DM with mentioned user (might be too expensive)
const canAddUsers = canAddUsersToThisRoom || canAddToAnyRoom;
const { language } = (await Users.findOneById(message.u._id)) || {};
const actionBlocks = getBlocks(mentionsUsersNotInChannel, message._id, language);
const elements: ActionsBlock['elements'] = [
canAddUsers && actionBlocks.addUsersBlock,
(canAddUsers || canDMUsers) && actionBlocks.dismissBlock,
canDMUsers && actionBlocks.dmBlock,
].filter(isTruthy);
const messageLabel = canAddUsers
? 'You_mentioned___mentions__but_theyre_not_in_this_room'
: 'You_mentioned___mentions__but_theyre_not_in_this_room_You_can_ask_a_room_admin_to_add_them';
const useRealName = settings.get<boolean>('UI_Use_Real_Name');
const usernamesOrNames = mentionsUsersNotInChannel.map(({ username, name }) => `*${getUserDisplayName(name, username, useRealName)}*`);
const mentionsText = usernamesOrNames.join(', ');
// TODO: Mentions style
void api.broadcast('notify.ephemeralMessage', message.u._id, message.rid, {
msg: '',
mentions: mentionsUsersNotInChannel,
tmid: message.tmid,
blocks: [
{
appId: APP_ID,
type: 'section',
text: {
type: 'mrkdwn',
text: i18n.t(messageLabel, { mentions: mentionsText, lng: language }),
},
} as const,
Boolean(elements.length) &&
({
type: 'actions',
appId: APP_ID,
elements,
} as const),
].filter(isTruthy),
private: true,
});
return message;
},
callbacks.priority.LOW,
'mention-user-not-in-channel',
);

View File

@@ -0,0 +1,231 @@
import type { IMessage, IRoom, IUser, RoomType } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { Subscriptions, Rooms } from '@rocket.chat/models';
import moment from 'moment';
import { callbacks } from '../../lib/callbacks';
import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight';
import {
notifyOnSubscriptionChanged,
notifyOnSubscriptionChangedByRoomIdAndUserId,
notifyOnSubscriptionChangedByRoomIdAndUserIds,
} from '../../lib/notifyListener';
import { settings } from '../../settings';
export async function getMentions(message: IMessage): Promise<{ toAll: boolean; toHere: boolean; mentionIds: string[] }> {
const {
mentions,
u: { _id: senderId },
} = message;
if (!mentions) {
return {
toAll: false,
toHere: false,
mentionIds: [],
};
}
const toAll = mentions.some(({ _id }) => _id === 'all');
const toHere = mentions.some(({ _id }) => _id === 'here');
const teamsMentions = mentions.filter((mention) => mention.type === 'team');
const filteredMentions = mentions
.filter((mention) => !mention.type || mention.type === 'user')
.filter(({ _id }) => _id !== senderId && !['all', 'here'].includes(_id))
.map(({ _id }) => _id);
const mentionIds = await callbacks.run('beforeGetMentions', filteredMentions, teamsMentions);
return {
toAll,
toHere,
mentionIds,
};
}
type UnreadCountType = 'all_messages' | 'user_mentions_only' | 'group_mentions_only' | 'user_and_group_mentions_only';
const getGroupMentions = (roomType: RoomType, unreadCount: Exclude<UnreadCountType, 'user_mentions_only'>): number => {
const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
return roomType === 'd' || roomType === 'l' || incUnreadByGroup ? 1 : 0;
};
const getUserMentions = (roomType: RoomType, unreadCount: Exclude<UnreadCountType, 'group_mentions_only'>): number => {
const incUnreadByUser = ['all_messages', 'user_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount);
return roomType === 'd' || roomType === 'l' || incUnreadByUser ? 1 : 0;
};
export const getUserIdsFromHighlights = async (rid: IRoom['_id'], message: IMessage): Promise<string[]> => {
const highlightOptions = { projection: { 'userHighlights': 1, 'u._id': 1 } };
const subs = await Subscriptions.findByRoomWithUserHighlights(rid, highlightOptions).toArray();
return subs
.filter(
({ userHighlights, u: { _id: uid } }) => userHighlights && messageContainsHighlight(message, userHighlights) && uid !== message.u._id,
)
.map(({ u: { _id: uid } }) => uid);
};
const getUnreadSettingCount = (roomType: RoomType): UnreadCountType => {
let unreadSetting = 'Unread_Count';
switch (roomType) {
case 'd': {
unreadSetting = 'Unread_Count_DM';
break;
}
case 'l': {
unreadSetting = 'Unread_Count_Omni';
break;
}
}
return settings.get(unreadSetting);
};
async function updateUsersSubscriptions(message: IMessage, room: IRoom): Promise<void> {
if (!room || message.tmid) {
return;
}
const [mentions, highlightIds] = await Promise.all([getMentions(message), getUserIdsFromHighlights(room._id, message)]);
const { toAll, toHere, mentionIds } = mentions;
const userIds = [...new Set([...mentionIds, ...highlightIds])];
const unreadCount = getUnreadSettingCount(room.t);
const unreadAllMessages = unreadCount === 'all_messages';
const userMentionInc = getUserMentions(room.t, unreadCount as Exclude<UnreadCountType, 'group_mentions_only'>);
const groupMentionInc = getGroupMentions(room.t, unreadCount as Exclude<UnreadCountType, 'user_mentions_only'>);
// find all subscriptions that will need to be notified after the update.
// we need to use toArray() here and keep results in memory because we'll update the them later
const subs = await Subscriptions.findByRoomIdAndNotAlertOrOpenExcludingUserIds({
roomId: room._id,
uidsExclude: [message.u._id],
uidsInclude: userIds,
onlyRead: !toAll && !toHere && !unreadAllMessages,
}).toArray();
// Give priority to user mentions over group mentions
if (userIds.length) {
await Subscriptions.incUserMentionsAndUnreadForRoomIdAndUserIds(room._id, userIds, 1, userMentionInc);
} else if (toAll || toHere) {
await Subscriptions.incGroupMentionsAndUnreadForRoomIdExcludingUserId(room._id, message.u._id, 1, groupMentionInc);
}
if (!toAll && !toHere && unreadAllMessages) {
await Subscriptions.incUnreadForRoomIdExcludingUserIds(room._id, [...userIds, message.u._id], 1);
}
// update subscriptions of other members of the room
await Promise.all([
Subscriptions.setAlertForRoomIdExcludingUserId(message.rid, message.u._id),
Subscriptions.setOpenForRoomIdExcludingUserId(message.rid, message.u._id),
]);
subs.forEach((sub) => {
const hasUserMention = userIds.includes(sub.u._id);
const shouldIncUnread = hasUserMention || toAll || toHere || unreadAllMessages;
void notifyOnSubscriptionChanged(
{
...sub,
alert: true,
open: true,
...(shouldIncUnread && { unread: sub.unread + 1 }),
...(hasUserMention && { userMentions: sub.userMentions + 1 }),
...((toAll || toHere) && { groupMentions: sub.groupMentions + 1 }),
},
'updated',
);
});
// update subscription of the message sender
const setAsReadResponse = await Subscriptions.setAsReadByRoomIdAndUserId(message.rid, message.u._id);
if (setAsReadResponse.modifiedCount) {
void notifyOnSubscriptionChangedByRoomIdAndUserId(message.rid, message.u._id);
}
}
export async function updateThreadUsersSubscriptions(message: IMessage, replies: IUser['_id'][]): Promise<void> {
// Don't increase unread counter on thread messages
const repliesPlusSender = [...new Set([message.u._id, ...replies])];
const responses = await Promise.all([
Subscriptions.setAlertForRoomIdAndUserIds(message.rid, replies),
Subscriptions.setOpenForRoomIdAndUserIds(message.rid, repliesPlusSender),
Subscriptions.setLastReplyForRoomIdAndUserIds(message.rid, repliesPlusSender, new Date()),
]);
responses.some((response) => response?.modifiedCount) &&
void notifyOnSubscriptionChangedByRoomIdAndUserIds(message.rid, repliesPlusSender);
}
export async function notifyUsersOnMessage(message: IMessage, room: IRoom, roomUpdater: Updater<IRoom>): Promise<IMessage> {
// Skips this callback if the message was edited and increments it if the edit was way in the past (aka imported)
if (isEditedMessage(message)) {
if (Math.abs(moment(message.editedAt).diff(Date.now())) > 60000) {
// TODO: Review as I am not sure how else to get around this as the incrementing of the msgs count shouldn't be in this callback
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
return message;
}
// Only updates last message if it was edited (skip rest of callback)
if (
settings.get('Store_Last_Message') &&
(!message.tmid || message.tshow) &&
(!room.lastMessage || room.lastMessage._id === message._id)
) {
Rooms.getLastMessageUpdateQuery(message, roomUpdater);
}
return message;
}
if (message.ts && Math.abs(moment(message.ts).diff(Date.now())) > 60000) {
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
return message;
}
// If message sent ONLY on a thread, skips the rest as it is done on a callback specific to threads
if (message.tmid && !message.tshow) {
Rooms.getIncMsgCountUpdateQuery(1, roomUpdater);
return message;
}
// Update all the room activity tracker fields
Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater);
await updateUsersSubscriptions(message, room);
return message;
}
export async function notifyUsersOnSystemMessage(message: IMessage, room: IRoom): Promise<IMessage> {
const roomUpdater = Rooms.getUpdater();
Rooms.setIncMsgCountAndSetLastMessageUpdateQuery(1, message, !!settings.get('Store_Last_Message'), roomUpdater);
if (roomUpdater.hasChanges()) {
await Rooms.updateFromUpdater({ _id: room._id }, roomUpdater);
}
// TODO: Rewrite to use just needed calls from the function
await updateUsersSubscriptions(message, room);
return message;
}
callbacks.add(
'afterSaveMessage',
async (message, { room, roomUpdater }) => {
if (!roomUpdater) {
return message;
}
await notifyUsersOnMessage(message, room, roomUpdater);
return message;
},
callbacks.priority.MEDIUM,
'notifyUsersOnMessage',
);

View File

@@ -0,0 +1,95 @@
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { updateThreadUsersSubscriptions, getMentions } from './notifyUsersOnMessage';
import { sendMessageNotifications } from './sendNotificationsOnMessage';
import { callbacks } from '../../lib/callbacks';
import type { SendMessageOptions } from '../../lib/messages/sendMessage';
import { reply } from '../../lib/messaging/threads/functions';
import { notifyOnMessageChange } from '../../lib/notifyListener';
import { settings } from '../../settings';
async function notifyUsersOnReply(message: IMessage, replies: IUser['_id'][]) {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
await updateThreadUsersSubscriptions(message, replies);
return message;
}
async function metaData(message: IMessage, parentMessage: IMessage, followers: string[]) {
await reply({ tmid: message.tmid }, message, parentMessage, followers);
return message;
}
const notification = async (message: IMessage, room: IRoom, replies: string[]) => {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
// will send a notification to everyone who replied/followed the thread except the owner of the message
await sendMessageNotifications(message, room, replies);
return message;
};
export async function processThreads(message: IMessage, room: IRoom, options?: SendMessageOptions) {
if (!message.tmid) {
return message;
}
const parentMessage = await Messages.findOneById(message.tmid);
if (!parentMessage) {
return message;
}
const { mentionIds } = await getMentions(message);
const replies = [
...new Set([
...((!parentMessage.tcount ? [parentMessage.u._id] : parentMessage.replies) || []),
...(!parentMessage.tcount && room.t === 'd' && room.uids ? room.uids : []),
...mentionIds,
]),
].filter((userId) => userId !== message.u._id);
await notifyUsersOnReply(message, replies);
await metaData(message, parentMessage, replies);
if (!isEditedMessage(message)) {
callbacks.runAsync('afterReadMessages', room, { uid: message.u._id, tmid: message.tmid });
}
if (!options?.skipNotifications) {
await notification(message, room, replies);
}
void notifyOnMessageChange({
id: message.tmid,
});
return message;
}
Meteor.startup(() => {
settings.watch<boolean>('Threads_enabled', (value) => {
if (!value) {
callbacks.remove('afterSaveMessage', 'threads-after-save-message');
return;
}
callbacks.add(
'afterSaveMessage',
async (message, { room, options }) => {
return processThreads(message, room, options);
},
callbacks.priority.LOW,
'threads-after-save-message',
);
});
});

View File

@@ -0,0 +1,134 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Messages, Rooms, VideoConference } from '@rocket.chat/models';
import type { Updater } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { updateAndNotifyParentRoomWithParentMessage } from '../../lib/messaging/discussions/updateAndNotifyParentRoomWithParentMessage';
import { deleteRoom } from '../../lib/rooms/deleteRoom';
/**
* The messages count and the last message timestamp of the room are only written to the database
* once every `afterSaveMessage` callback ran, so the changes staged for the message being saved
* have to be applied on top of the stored room, otherwise the discussion metadata would always
* be left one message behind.
*/
const withPendingRoomChanges = (
room: Pick<IRoom, '_id' | 'msgs' | 'lm'>,
roomUpdater?: Updater<IRoom>,
): Pick<IRoom, '_id' | 'msgs' | 'lm'> => {
const { $inc, $set } = roomUpdater?.getRawUpdateFilter() ?? {};
const pendingMsgs = typeof $inc?.msgs === 'number' ? $inc.msgs : 0;
const pendingLm = $set?.lm instanceof Date ? $set.lm : undefined;
return {
...room,
msgs: room.msgs + pendingMsgs,
lm: pendingLm ?? room.lm,
};
};
/**
* We need to propagate the writing of new message in a discussion to the linking
* system message
*/
callbacks.add(
'afterSaveMessage',
async (message, { room: { _id, prid }, roomUpdater }) => {
if (!prid) {
return message;
}
const room = await Rooms.findOneById(_id, {
projection: {
msgs: 1,
lm: 1,
sysMes: 1,
},
});
if (!room) {
return message;
}
await updateAndNotifyParentRoomWithParentMessage(withPendingRoomChanges(room, roomUpdater));
return message;
},
callbacks.priority.LOW,
'PropagateDiscussionMetadata',
);
callbacks.add(
'afterDeleteMessage',
async (message, { room: { _id, prid } }) => {
if (prid) {
const room = await Rooms.findOneById(_id, {
projection: {
msgs: 1,
lm: 1,
sysMes: 1,
},
});
if (room) {
await updateAndNotifyParentRoomWithParentMessage(room);
}
}
if (message.drid) {
await deleteRoom(message.drid);
}
return message;
},
callbacks.priority.LOW,
'PropagateDiscussionMetadata',
);
callbacks.add(
'afterDeleteRoom',
async (rid) => {
for await (const { _id } of Rooms.find({ prid: rid }, { projection: { _id: 1 } })) {
await deleteRoom(_id);
}
return rid;
},
callbacks.priority.LOW,
'DeleteDiscussionChain',
);
// TODO discussions define new fields
callbacks.add(
'afterRoomNameChange',
async (roomConfig) => {
const {
room: { _id: rid },
name,
oldName,
} = roomConfig;
await Rooms.updateMany({ prid: rid, ...(oldName && { topic: oldName }) }, { $set: { topic: name } });
return roomConfig;
},
callbacks.priority.LOW,
'updateTopicDiscussion',
);
callbacks.add(
'afterDeleteRoom',
async (drid) => {
await Messages.updateMany(
{ drid },
{
$unset: {
dcount: 1,
dlm: 1,
drid: 1,
},
},
);
await VideoConference.unsetDiscussionRid(drid);
return drid;
},
callbacks.priority.LOW,
'CleanDiscussionMessage',
);

View File

@@ -0,0 +1,432 @@
import {
type IMessage,
type ISubscription,
type IUser,
type IRoom,
type NotificationItem,
isEditedMessage,
type AtLeast,
} from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import moment from 'moment';
import type { RootFilterOperators } from 'mongodb';
import { getMentions } from './notifyUsersOnMessage';
import { shortnameToUnicode } from '../../../app/emoji-native/lib/shortnameToUnicode';
import { hasPermissionAsync } from '../../lib/authorization/hasPermission';
import { callbacks } from '../../lib/callbacks';
import { parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../../lib/notifications/message';
import { notifyDesktopUser, shouldNotifyDesktop } from '../../lib/notifications/message/desktop';
import { getEmailData, shouldNotifyEmail } from '../../lib/notifications/message/email';
import { messageContainsHighlight } from '../../lib/notifications/message/messageContainsHighlight';
import { getPushData, shouldNotifyMobile } from '../../lib/notifications/message/mobile';
import { Notification } from '../../lib/notifications/queue/NotificationQueue';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import { settings } from '../../settings';
type SubscriptionAggregation = {
receiver: [Pick<IUser, 'active' | 'emails' | 'language' | 'status' | 'statusConnection' | 'username' | 'settings'> | null];
} & Pick<
ISubscription,
| 'desktopNotifications'
| 'emailNotifications'
| 'mobilePushNotifications'
| 'muteGroupMentions'
| 'name'
| 'rid'
| 'userHighlights'
| 'u'
| 'audioNotificationValue'
>;
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
export const sendNotification = async ({
subscription,
sender,
hasReplyToThread,
hasMentionToAll,
hasMentionToHere,
message,
notificationMessage,
room,
mentionIds,
disableAllMessageNotifications,
}: {
subscription: SubscriptionAggregation;
sender: Pick<IUser, '_id' | 'name' | 'username'>;
hasReplyToThread: boolean;
hasMentionToAll: boolean;
hasMentionToHere: boolean;
message: AtLeast<IMessage, '_id' | 'u' | 'msg' | 't' | 'attachments'>;
notificationMessage: string;
room: IRoom;
mentionIds: string[];
disableAllMessageNotifications: boolean;
}) => {
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
return;
}
// don't notify the sender
if (subscription.u._id === sender._id) {
return;
}
const hasMentionToUser = mentionIds.includes(subscription.u._id);
// mute group notifications (@here and @all) if not directly mentioned as well
if (!hasMentionToUser && !hasReplyToThread && subscription.muteGroupMentions && (hasMentionToAll || hasMentionToHere)) {
return;
}
if (!subscription.receiver) {
subscription.receiver = [
await Users.findOneById(subscription.u._id, {
projection: {
'active': 1,
'emails': 1,
'language': 1,
'status': 1,
'statusConnection': 1,
'username': 1,
'settings.preferences.enableMobileRinging': 1,
},
}),
];
}
const [receiver] = subscription.receiver;
if (!receiver) {
throw new Error('receiver not found');
}
const roomType = room.t;
// If the user doesn't have permission to view direct messages, don't send notification of direct messages.
if (roomType === 'd' && !(await hasPermissionAsync(subscription.u._id, 'view-d-room'))) {
return;
}
const isThread = !!message.tmid && !message.tshow;
const isVideoConf = message.t === 'videoconf';
notificationMessage = await parseMessageTextPerUser(notificationMessage, message, receiver);
const isHighlighted = messageContainsHighlight(message, subscription.userHighlights);
const { desktopNotifications, mobilePushNotifications, emailNotifications } = subscription;
// busy users don't receive desktop notification
if (
shouldNotifyDesktop({
disableAllMessageNotifications,
status: receiver.status ?? 'offline',
statusConnection: receiver.statusConnection ?? 'offline',
desktopNotifications,
hasMentionToAll,
hasMentionToHere,
isHighlighted,
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
})
) {
await notifyDesktopUser({
notificationMessage,
userId: subscription.u._id,
user: sender,
message,
room,
audioNotificationValue: subscription.audioNotificationValue,
});
}
const queueItems: NotificationItem[] = [];
if (
shouldNotifyMobile({
disableAllMessageNotifications,
mobilePushNotifications,
hasMentionToAll,
isHighlighted,
hasMentionToUser,
hasReplyToThread,
roomType,
isThread,
isVideoConf,
userPreferences: receiver.settings?.preferences,
roomUids: room.uids,
})
) {
queueItems.push({
type: 'push',
data: await getPushData({
notificationMessage,
room,
message,
userId: subscription.u._id,
senderUsername: sender.username,
senderName: sender.name,
receiver,
}),
});
}
if (
receiver.emails &&
shouldNotifyEmail({
disableAllMessageNotifications,
statusConnection: receiver.statusConnection,
emailNotifications,
isHighlighted,
hasMentionToUser,
hasMentionToAll,
hasReplyToThread,
roomType,
isThread,
})
) {
const messageWithUnicode = message.msg ? shortnameToUnicode(message.msg) : message.msg;
const firstAttachment = message.attachments?.[0];
const convertedFirstAttachment = firstAttachment
? {
...firstAttachment,
description: typeof firstAttachment.description === 'string' ? shortnameToUnicode(firstAttachment.description) : undefined,
text: typeof firstAttachment.text === 'string' ? shortnameToUnicode(firstAttachment.text) : undefined,
}
: undefined;
const attachments = convertedFirstAttachment ? [convertedFirstAttachment, ...(message.attachments?.slice(1) ?? [])] : [];
for (const email of receiver.emails) {
if (email.verified) {
queueItems.push({
type: 'email',
data: await getEmailData({
message: {
...message,
msg: messageWithUnicode,
...(attachments.length > 0 ? { attachments } : {}),
},
receiver,
sender,
subscription,
room,
emailAddress: email.address,
hasMentionToUser,
}),
});
break;
}
}
}
if (queueItems.length) {
void Notification.scheduleItem({
user: receiver,
uid: subscription.u._id,
rid: room._id,
mid: message._id,
items: queueItems,
});
}
};
const project = {
$project: {
'desktopNotifications': 1,
'emailNotifications': 1,
'mobilePushNotifications': 1,
'muteGroupMentions': 1,
'name': 1,
'rid': 1,
'userHighlights': 1,
'u._id': 1,
'receiver.active': 1,
'receiver.emails': 1,
'receiver.language': 1,
'receiver.status': 1,
'receiver.statusConnection': 1,
'receiver.username': 1,
'receiver.settings.preferences.enableMobileRinging': 1,
'audioNotificationValue': 1,
},
} as const;
const filter = {
$match: {
'receiver.active': true,
},
} as const;
const lookup = {
$lookup: {
from: 'users',
localField: 'u._id',
foreignField: '_id',
as: 'receiver',
},
} as const;
export async function sendMessageNotifications(message: IMessage, room: IRoom, usersInThread: string[] = []) {
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
return;
}
const sender = await roomCoordinator.getRoomDirectives(room.t).getMsgSender(message);
if (!sender) {
return message;
}
const { toAll: hasMentionToAll, toHere: hasMentionToHere, mentionIds } = await getMentions(message);
const mentionIdsWithoutGroups = [...mentionIds];
// getMentions removes `all` and `here` from mentionIds so we need to add them back for compatibility
if (hasMentionToAll) {
mentionIds.push('all');
}
if (hasMentionToHere) {
mentionIds.push('here');
}
// add users in thread to mentions array because they follow the same rules
mentionIds.push(...usersInThread);
let notificationMessage = await callbacks.run('beforeSendMessageNotifications', message.msg);
if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) {
notificationMessage = replaceMentionedUsernamesWithFullNames(message.msg, message.mentions ?? []);
}
// Don't fetch all users if room exceeds max members
const maxMembersForNotification = settings.get<number>('Notifications_Max_Room_Members');
const roomMembersCount = await Users.countRoomMembers(room._id);
const disableAllMessageNotifications = roomMembersCount > maxMembersForNotification && maxMembersForNotification !== 0;
const query: WithRequiredProperty<RootFilterOperators<ISubscription>, '$or'> = {
rid: room._id,
ignored: { $ne: sender._id },
disableNotifications: { $ne: true },
$or: [{ 'userHighlights.0': { $exists: 1 } }, ...(usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [])],
} as const;
(['desktop', 'mobile', 'email'] as const).forEach((kind) => {
const notificationField = kind === 'mobile' ? 'mobilePush' : `${kind}Notifications`;
query.$or.push({
[notificationField]: 'all',
...(disableAllMessageNotifications ? { [`${kind}PrefOrigin`]: { $ne: 'user' } } : {}),
});
if (disableAllMessageNotifications) {
return;
}
if (room.t === 'd') {
query.$or.push({
[notificationField]: 'mentions',
});
} else if (mentionIdsWithoutGroups.length > 0) {
query.$or.push({
[notificationField]: 'mentions',
'u._id': { $in: mentionIdsWithoutGroups },
});
}
const serverField = kind === 'email' ? 'emailNotificationMode' : `${kind}Notifications`;
const serverPreference = settings.get(`Accounts_Default_User_Preferences_${serverField}`);
if (serverPreference === 'all' || hasMentionToAll || hasMentionToHere || room.t === 'd') {
query.$or.push({
[notificationField]: { $exists: false },
});
} else if (serverPreference === 'mentions' && mentionIdsWithoutGroups.length > 0) {
query.$or.push({
[notificationField]: { $exists: false },
'u._id': { $in: mentionIdsWithoutGroups },
});
}
});
// the find below is crucial. All subscription records returned will receive at least one kind of notification.
// the query is defined by the server's default values and Notifications_Max_Room_Members setting.
const subscriptions = await Subscriptions.col.aggregate<SubscriptionAggregation>([{ $match: query }, lookup, filter, project]).toArray();
subscriptions.forEach(
(subscription) =>
void sendNotification({
subscription,
sender,
hasMentionToAll,
hasMentionToHere,
message,
notificationMessage,
room,
mentionIds,
disableAllMessageNotifications,
hasReplyToThread: usersInThread?.includes(subscription.u._id),
}),
);
return {
sender,
hasMentionToAll,
hasMentionToHere,
notificationMessage,
mentionIds,
mentionIdsWithoutGroups,
};
}
export async function sendAllNotifications(message: IMessage, room: IRoom) {
if (settings.get<boolean>('Troubleshoot_Disable_Notifications') === true) {
return message;
}
// threads
if (message.tmid) {
return message;
}
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
if (message.ts && Math.abs(moment(message.ts).diff(new Date())) > 60000) {
return message;
}
if (room?.t == null) {
return message;
}
await sendMessageNotifications(message, room);
return message;
}
settings.watch('Troubleshoot_Disable_Notifications', (value) => {
if (value) {
return callbacks.remove('afterSaveMessage', 'sendNotificationsOnMessage');
}
callbacks.add(
'afterSaveMessage',
(message, { room, options }) => {
if (options?.skipNotifications) {
return message;
}
return sendAllNotifications(message, room);
},
callbacks.priority.LOW,
'sendNotificationsOnMessage',
);
});

View File

@@ -0,0 +1,41 @@
import { LivechatDepartment, Users, LivechatDepartmentAgents, LivechatVisitors } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { notifyOnLivechatDepartmentAgentChanged, notifyOnUserChange } from '../../lib/notifyListener';
callbacks.add('livechat.afterAgentRemoved', async ({ agent }) => {
const departments = await LivechatDepartmentAgents.findByAgentId(agent._id).toArray();
const [{ modifiedCount }, { deletedCount }] = await Promise.all([
Users.removeAgent(agent._id),
LivechatDepartmentAgents.removeByAgentId(agent._id),
agent.username && LivechatVisitors.removeContactManagerByUsername(agent.username),
departments.length && LivechatDepartment.decreaseNumberOfAgentsByIds(departments.map(({ departmentId }) => departmentId)),
]);
if (modifiedCount > 0) {
void notifyOnUserChange({
id: agent._id,
clientAction: 'updated',
diff: {
operator: false,
livechat: null,
statusLivechat: null,
openBusinessHours: null,
},
});
}
if (deletedCount > 0) {
departments.forEach((depAgent) => {
void notifyOnLivechatDepartmentAgentChanged(
{
_id: depAgent._id,
agentId: agent._id,
departmentId: depAgent.departmentId,
},
'removed',
);
});
}
});

View File

@@ -0,0 +1,24 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
callbacks.add(
'afterSaveMessage',
async (message, { room }) => {
if (!isOmnichannelRoom(room)) {
return message;
}
const updater = LivechatRooms.getUpdater();
const result = await callbacks.run('afterOmnichannelSaveMessage', message, { room, roomUpdater: updater });
if (updater.hasChanges()) {
await LivechatRooms.updateFromUpdater({ _id: room._id }, updater);
}
return result;
},
callbacks.priority.MEDIUM,
'after-omnichannel-save-message',
);

View File

@@ -0,0 +1,52 @@
import type { IUser } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { afterAgentUserActivated, afterAgentAdded, afterRemoveAgent } from '../../lib/omnichannel/hooks';
type IAfterSaveUserProps = {
user: IUser;
oldUser: IUser | null;
};
const wasAgent = (user: Pick<IUser, 'roles'> | null) => user?.roles?.includes('livechat-agent');
const isAgent = (user: Pick<IUser, 'roles'> | null) => user?.roles?.includes('livechat-agent');
const handleAgentUpdated = async (userData: IAfterSaveUserProps) => {
const { user: newUser, oldUser } = userData;
if (wasAgent(oldUser) && !isAgent(newUser)) {
await afterRemoveAgent(newUser);
}
if (!wasAgent(oldUser) && isAgent(newUser)) {
await afterAgentAdded(newUser);
}
};
const handleAgentCreated = async (user: IUser) => {
// created === no prev roles :)
if (isAgent(user)) {
await afterAgentAdded(user);
}
};
const handleDeactivateUser = async (user: IUser) => {
if (wasAgent(user)) {
await Users.makeAgentUnavailable(user._id);
}
};
const handleActivateUser = async (user: IUser) => {
if (isAgent(user) && user.username) {
await afterAgentUserActivated(user);
}
};
callbacks.add('afterCreateUser', handleAgentCreated, callbacks.priority.LOW, 'livechat-after-create-user-update-agent');
callbacks.add('afterSaveUser', handleAgentUpdated, callbacks.priority.LOW, 'livechat-after-save-user-update-agent');
callbacks.add('afterDeactivateUser', handleDeactivateUser, callbacks.priority.LOW, 'livechat-after-deactivate-user-remove-agent');
callbacks.add('afterActivateUser', handleActivateUser, callbacks.priority.LOW, 'livechat-after-activate-user-add-agent');

View File

@@ -0,0 +1,69 @@
import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { isTruthy } from '@rocket.chat/tools';
import { callbacks } from '../../lib/callbacks';
import { settings } from '../../settings';
function validateMessage(message: IMessage, room: IOmnichannelRoom) {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return false;
}
// message valid only if it is a livechat room
if (!(typeof room.t !== 'undefined' && room.t === 'l' && room.v && room.v.token)) {
return false;
}
// if the message hasn't a token, it was NOT sent from the visitor, so ignore it
if (!message.token) {
return false;
}
// if the message has a type means it is a special message (like the closing comment), so skips
if (message.t) {
return false;
}
return true;
}
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room }) => {
if (!validateMessage(message, room)) {
return message;
}
const phoneRegexSetting = settings.get<string>('Livechat_lead_phone_regex');
const emailRegexSetting = settings.get<string>('Livechat_lead_email_regex');
const safeMatch = (pattern: string, flags: string, text: string): string[] => {
if (!pattern) {
return [];
}
try {
const re = new RegExp(pattern, flags);
return text.match(re)?.filter(isTruthy) ?? [];
} catch {
return [];
}
};
const uniq = (arr: string[]) => [...new Set(arr.filter(isTruthy))];
const matchedPhones = uniq(safeMatch(phoneRegexSetting, 'g', message.msg));
const matchedEmails = uniq(safeMatch(emailRegexSetting, 'gi', message.msg));
if (matchedEmails.length || matchedPhones.length) {
await LivechatVisitors.saveGuestEmailPhoneById(room.v._id, matchedEmails, matchedPhones);
await callbacks.run('livechat.leadCapture', room);
}
return message;
},
callbacks.priority.LOW,
'leadCapture',
);

View File

@@ -0,0 +1,30 @@
import { isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
callbacks.add(
'afterOmnichannelSaveMessage',
(message, { room, roomUpdater }) => {
// skips this callback if the message was edited
if (!message || isEditedMessage(message)) {
return message;
}
// if the message has not a token, it was sent by the agent, so ignore it
if (!message.token) {
return message;
}
// check if room is yet awaiting for response
if (room.waitingResponse) {
return message;
}
LivechatRooms.getNotResponseByRoomIdUpdateQuery(roomUpdater);
return message;
},
callbacks.priority.LOW,
'markRoomNotResponded',
);

View File

@@ -0,0 +1,79 @@
import type { IOmnichannelRoom, IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings';
import type { Updater } from '@rocket.chat/models';
import { LivechatRooms, LivechatContacts, LivechatInquiry } from '@rocket.chat/models';
import moment from 'moment';
import { callbacks } from '../../lib/callbacks';
import { notifyOnLivechatInquiryChanged } from '../../lib/notifyListener';
import { isMessageFromBot } from '../../lib/omnichannel/isMessageFromBot';
import { settings } from '../../settings';
export async function markRoomResponded(
message: IMessage,
room: IOmnichannelRoom,
roomUpdater: Updater<IOmnichannelRoom>,
): Promise<IOmnichannelRoom['responseBy'] | undefined> {
if (
isSystemMessage(message) ||
isEditedMessage(message) ||
isMessageFromVisitor(message) ||
(settings.get<boolean>('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message)))
) {
return;
}
const monthYear = moment().format('YYYY-MM');
const isContactActive = await LivechatContacts.isContactActiveOnPeriod({ visitorId: room.v._id, source: room.source }, monthYear);
// Case: agent answers & visitor is not active, we mark visitor as active
if (!isContactActive) {
await LivechatContacts.markContactActiveForPeriod({ visitorId: room.v._id, source: room.source }, monthYear);
}
if (!room.v?.activity?.includes(monthYear)) {
LivechatRooms.getVisitorActiveForPeriodUpdateQuery(monthYear, roomUpdater, room.v?.activity);
const livechatInquiry = await LivechatInquiry.markInquiryActiveForPeriod(room._id, monthYear);
if (livechatInquiry) {
void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { v: livechatInquiry.v });
}
}
if (!room.waitingResponse) {
// case where agent sends second message or any subsequent message in a room before visitor responds to the first message
// in this case, we just need to update the lastMessageTs of the responseBy object
if (room.responseBy) {
LivechatRooms.getAgentLastMessageTsUpdateQuery(roomUpdater);
}
return room.responseBy;
}
// Since we're updating the whole object anyways, we re-use the same values from object (or from message if not present)
// And then we update the lastMessageTs, which is the only thing that should be updating here
const { responseBy: { _id, username, firstResponseTs } = {} } = room;
const responseBy: IOmnichannelRoom['responseBy'] = {
_id: _id || message.u._id,
username: username || message.u.username,
firstResponseTs: firstResponseTs || new Date(message.ts),
lastMessageTs: new Date(message.ts),
};
LivechatRooms.getResponseByRoomIdUpdateQuery(responseBy, roomUpdater);
return responseBy;
}
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room, roomUpdater }) => {
if (!message || isEditedMessage(message) || isMessageFromVisitor(message) || isSystemMessage(message)) {
return;
}
await markRoomResponded(message, room, roomUpdater);
},
callbacks.priority.HIGH,
'markRoomResponded',
);

View File

@@ -0,0 +1,26 @@
import { callbacks } from '../../lib/callbacks';
import { sendRequest } from '../../lib/omnichannel/webhooks';
import { settings } from '../../settings';
callbacks.add(
'livechat.offlineMessage',
async (data) => {
if (!settings.get('Livechat_webhook_on_offline_msg')) {
return data;
}
const postData = {
type: 'LivechatOfflineMessage',
sentAt: new Date(),
visitor: {
name: data.name,
email: data.email,
},
message: data.message,
};
await sendRequest(postData);
},
callbacks.priority.MEDIUM,
'livechat-send-email-offline-message',
);

View File

@@ -0,0 +1,70 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment, Users, Rooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { i18n } from '../../lib/i18n';
import { sendMessage } from '../../lib/messages/sendMessage';
import { settings } from '../../settings';
callbacks.add(
'livechat.offlineMessage',
async (data) => {
if (!settings.get('Livechat_OfflineMessageToChannel_enabled')) {
return data;
}
let channelName = settings.get<string>('Livechat_OfflineMessageToChannel_channel_name');
let departmentName;
const { name, email, department, message: text, host } = data;
if (department && department !== '') {
const dept = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'name' | 'offlineMessageChannelName'>>(
department,
{
projection: { name: 1, offlineMessageChannelName: 1 },
},
);
departmentName = dept?.name;
if (dept?.offlineMessageChannelName) {
channelName = dept.offlineMessageChannelName;
}
}
if (!channelName || channelName === '') {
return data;
}
const room = await Rooms.findOneByName(channelName, { projection: { t: 1, archived: 1 } });
if (!room || room.archived || (isOmnichannelRoom(room) && room.closedAt)) {
return data;
}
const user = await Users.findOneById('rocket.cat', { projection: { username: 1 } });
if (!user) {
return data;
}
const lng = settings.get<string>('Language') || 'en';
let msg = `${i18n.t('New_Livechat_offline_message_has_been_sent', { lng })}: \n`;
if (host && host !== '') {
msg = msg.concat(`${i18n.t('Sent_from', { lng })}: ${host} \n`);
}
msg = msg.concat(`${i18n.t('Visitor_Name', { lng })}: ${name} \n`);
msg = msg.concat(`${i18n.t('Visitor_Email', { lng })}: ${email} \n`);
if (departmentName) {
msg = msg.concat(`${i18n.t('Department', { lng })}: ${departmentName} \n`);
}
msg = msg.concat(`${i18n.t('Message', { lng })}: ${text} \n`);
const message = {
rid: room._id,
msg,
groupable: false,
};
await sendMessage(user, message, room, { upsert: true });
},
callbacks.priority.MEDIUM,
'livechat-send-email-offline-message-to-channel',
);

View File

@@ -0,0 +1,137 @@
import type { IOmnichannelRoom, IMessage, IBusinessHourWorkHour, ILivechatDepartment } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatBusinessHours, LivechatDepartment, Messages, LivechatRooms } from '@rocket.chat/models';
import moment from 'moment';
import { callbacks } from '../../lib/callbacks';
import { businessHourManager } from '../../lib/omnichannel/business-hour';
import type { CloseRoomParams } from '../../lib/omnichannel/localTypes';
import { settings } from '../../settings';
export const getSecondsWhenOfficeHoursIsDisabled = (room: IOmnichannelRoom, agentLastMessage: IMessage) =>
moment(new Date(room.closedAt || new Date())).diff(moment(new Date(agentLastMessage.ts)), 'seconds');
export const parseDays = (
acc: Record<string, { start: { day: string; time: string }; finish: { day: string; time: string }; open: boolean }>,
day: IBusinessHourWorkHour,
) => {
acc[day.day] = {
start: { day: day.start.utc.dayOfWeek, time: day.start.utc.time },
finish: { day: day.finish.utc.dayOfWeek, time: day.finish.utc.time },
open: day.open,
};
return acc;
};
export const getSecondsSinceLastAgentResponse = async (room: IOmnichannelRoom, agentLastMessage: IMessage) => {
if (!settings.get('Livechat_enable_business_hours')) {
return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage);
}
let officeDays;
const department = room.departmentId
? await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'businessHourId'>>(room.departmentId, {
projection: { businessHourId: 1 },
})
: null;
if (department?.businessHourId) {
const businessHour = await LivechatBusinessHours.findOneById(department.businessHourId);
if (!businessHour) {
return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage);
}
officeDays = (await businessHourManager.getBusinessHour(businessHour._id, businessHour.type))?.workHours.reduce(parseDays, {});
} else {
officeDays = (await businessHourManager.getBusinessHour())?.workHours.reduce(parseDays, {});
}
// Empty object we assume invalid config
if (!officeDays || !Object.keys(officeDays).length) {
return getSecondsWhenOfficeHoursIsDisabled(room, agentLastMessage);
}
let totalSeconds = 0;
const endOfConversation = moment.utc(new Date(room.closedAt || new Date()));
const startOfInactivity = moment.utc(new Date(agentLastMessage.ts));
const daysOfInactivity = endOfConversation.clone().startOf('day').diff(startOfInactivity.clone().startOf('day'), 'days');
const inactivityDay = moment.utc(new Date(agentLastMessage.ts));
for (let index = 0; index <= daysOfInactivity; index++) {
const today = inactivityDay.clone().format('dddd');
const officeDay = officeDays[today];
if (!officeDay) {
inactivityDay.add(1, 'days');
continue;
}
if (!officeDay.open) {
inactivityDay.add(1, 'days');
continue;
}
if (!officeDay?.start?.time || !officeDay?.finish?.time) {
inactivityDay.add(1, 'days');
continue;
}
const [officeStartHour, officeStartMinute] = officeDay.start.time.split(':');
const [officeCloseHour, officeCloseMinute] = officeDay.finish.time.split(':');
// We should only take in consideration the time where the office is open and the conversation was inactive
const todayStartOfficeHours = inactivityDay
.clone()
.set({ hour: parseInt(officeStartHour, 10), minute: parseInt(officeStartMinute, 10) });
const todayEndOfficeHours = inactivityDay.clone().set({ hour: parseInt(officeCloseHour, 10), minute: parseInt(officeCloseMinute, 10) });
// 1: Room was inactive the whole day, we add the whole time BH is inactive
if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) {
totalSeconds += todayEndOfficeHours.diff(todayStartOfficeHours, 'seconds');
}
// 2: Room was inactive before start but was closed before end of BH, we add the inactive time
if (startOfInactivity.isBefore(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) {
totalSeconds += endOfConversation.diff(todayStartOfficeHours, 'seconds');
}
// 3: Room was inactive after start and ended after end of BH, we add the inactive time
if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isAfter(todayEndOfficeHours)) {
totalSeconds += todayEndOfficeHours.diff(startOfInactivity, 'seconds');
}
// 4: Room was inactive after start and before end of BH, we add the inactive time
if (startOfInactivity.isAfter(todayStartOfficeHours) && endOfConversation.isBefore(todayEndOfficeHours)) {
totalSeconds += endOfConversation.diff(startOfInactivity, 'seconds');
}
inactivityDay.add(1, 'days');
}
return totalSeconds;
};
export const onCloseRoom = async (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => {
const { room } = params;
if (!isOmnichannelRoom(room)) {
return params;
}
const closedByAgent = room.closer !== 'visitor';
const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token;
if (!closedByAgent || !wasTheLastMessageSentByAgent) {
return params;
}
if (!room.v?.lastMessageTs) {
return params;
}
const agentLastMessage = await Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs);
if (!agentLastMessage) {
return params;
}
const secondsSinceLastAgentResponse = await getSecondsSinceLastAgentResponse(room, agentLastMessage);
await LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse);
return params;
};
callbacks.add('livechat.closeRoom', onCloseRoom, callbacks.priority.HIGH, 'process-room-abandonment');

View File

@@ -0,0 +1,90 @@
import { isEditedMessage, isMessageFromVisitor, isSystemMessage } from '@rocket.chat/core-typings';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { isMessageFromBot } from '../../lib/omnichannel/isMessageFromBot';
import { normalizeMessageFileUpload } from '../../lib/utils/functions/normalizeMessageFileUpload';
import { settings } from '../../settings';
const getMetricValue = <T>(metric: T | undefined, defaultValue: T): T => metric ?? defaultValue;
const calculateTimeDifference = <T extends Date | number>(startTime: T, now: Date): number =>
(now.getTime() - new Date(startTime).getTime()) / 1000;
const calculateAvgResponseTime = (totalResponseTime: number, newResponseTime: number, responseCount: number) =>
(totalResponseTime + newResponseTime) / (responseCount + 1);
const getFirstResponseAnalytics = (
visitorLastQuery: Date,
agentJoinTime: Date,
totalResponseTime: number,
responseCount: number,
now: Date,
) => {
const responseTime = calculateTimeDifference(visitorLastQuery, now);
const reactionTime = calculateTimeDifference(agentJoinTime, now);
const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount);
return {
firstResponseDate: now,
firstResponseTime: responseTime,
responseTime,
avgResponseTime,
firstReactionDate: now,
firstReactionTime: reactionTime,
reactionTime,
};
};
const getSubsequentResponseAnalytics = (visitorLastQuery: Date, totalResponseTime: number, responseCount: number, now: Date) => {
const responseTime = calculateTimeDifference(visitorLastQuery, now);
const avgResponseTime = calculateAvgResponseTime(totalResponseTime, responseTime, responseCount);
return {
responseTime,
avgResponseTime,
reactionTime: responseTime,
};
};
const getAnalyticsData = (room: IOmnichannelRoom, now: Date): Record<string, string | number | Date> | undefined => {
const visitorLastQuery = getMetricValue(room.metrics?.v?.lq, room.ts);
const agentLastReply = getMetricValue(room.metrics?.servedBy?.lr, room.ts);
const agentJoinTime = getMetricValue(room.servedBy?.ts, room.ts);
const totalResponseTime = getMetricValue(room.metrics?.response?.tt, 0);
const responseCount = getMetricValue(room.metrics?.response?.total, 0);
if (agentLastReply === room.ts) {
return getFirstResponseAnalytics(visitorLastQuery, agentJoinTime, totalResponseTime, responseCount, now);
}
if (visitorLastQuery > agentLastReply) {
return getSubsequentResponseAnalytics(visitorLastQuery, totalResponseTime, responseCount, now);
}
};
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room, roomUpdater }) => {
if (!message || isEditedMessage(message) || isSystemMessage(message)) {
return message;
}
if (message.file) {
message = { ...(await normalizeMessageFileUpload(message)), ...{ _updatedAt: message._updatedAt } };
}
if (isMessageFromVisitor(message)) {
LivechatRooms.getAnalyticsUpdateQueryBySentByVisitor(room, message, roomUpdater);
} else {
if (settings.get<boolean>('Omnichannel_Metrics_Ignore_Automatic_Messages') && (await isMessageFromBot(message))) {
return message;
}
const analyticsData = getAnalyticsData(room, new Date());
LivechatRooms.getAnalyticsUpdateQueryBySentByAgent(room, message, analyticsData, roomUpdater);
}
return message;
},
callbacks.priority.LOW,
'saveAnalyticsData',
);

View File

@@ -0,0 +1,34 @@
import { isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatInquiry } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import { notifyOnLivechatInquiryChanged } from '../../lib/notifyListener';
import { RoutingManager } from '../../lib/omnichannel/RoutingManager';
import { settings } from '../../settings';
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room }) => {
if (isEditedMessage(message) || message.t) {
return message;
}
if (!RoutingManager.getConfig()?.showQueue) {
// since last message is only getting used on UI as preview message when queue is enabled
return message;
}
if (!settings.get('Store_Last_Message')) {
return message;
}
const livechatInquiry = await LivechatInquiry.setLastMessageByRoomId(room._id, message);
if (livechatInquiry) {
void notifyOnLivechatInquiryChanged(livechatInquiry, 'updated', { lastMessage: message });
}
return message;
},
callbacks.priority.LOW,
'save-last-message-to-inquiry',
);

View File

@@ -0,0 +1,19 @@
import { isMessageFromVisitor } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { roomUpdater }) => {
if (message.t || !isMessageFromVisitor(message)) {
return message;
}
await LivechatRooms.getVisitorLastMessageTsUpdateQueryByRoomId(message.ts, roomUpdater);
return message;
},
callbacks.priority.HIGH,
'save-last-visitor-message-timestamp',
);

View File

@@ -0,0 +1,63 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../lib/callbacks';
import type { CloseRoomParams } from '../../lib/omnichannel/localTypes';
import { sendTranscript } from '../../lib/omnichannel/sendTranscript';
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
options: CloseRoomParams['options'];
};
const sendEmailTranscriptOnClose = async (params: LivechatCloseCallbackParams): Promise<LivechatCloseCallbackParams> => {
const { room, options } = params;
if (!isOmnichannelRoom(room)) {
return params;
}
const { _id: rid, v: { token } = {} } = room;
if (!token) {
return params;
}
const transcriptData = resolveTranscriptData(room, options);
if (!transcriptData) {
return params;
}
const { email, subject, requestedBy: user } = transcriptData;
await Promise.all([sendTranscript({ token, rid, email, subject, user }), LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid)]);
delete room.transcriptRequest;
return {
room,
options,
};
};
const resolveTranscriptData = (
room: IOmnichannelRoom,
options: LivechatCloseCallbackParams['options'] = {},
): IOmnichannelRoom['transcriptRequest'] | undefined => {
const { transcriptRequest: roomTranscriptRequest } = room;
const { emailTranscript: optionsTranscriptRequest } = options;
// Note: options.emailTranscript will override the room.transcriptRequest check
// If options.emailTranscript is not set, then the room.transcriptRequest will be checked
if (optionsTranscriptRequest === undefined) {
return roomTranscriptRequest;
}
if (!optionsTranscriptRequest.sendToVisitor) {
return undefined;
}
return optionsTranscriptRequest.requestData;
};
callbacks.add('livechat.closeRoom', sendEmailTranscriptOnClose, callbacks.priority.HIGH, 'livechat-send-email-transcript-on-close-room');

View File

@@ -0,0 +1,263 @@
import type { IOmnichannelRoom, IOmnichannelSystemMessage, IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage, isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, Messages } from '@rocket.chat/models';
import type { Response } from '@rocket.chat/server-fetch';
import { callbacks } from '../../lib/callbacks';
import { getLivechatRoomGuestInfo } from '../../lib/omnichannel/guests';
import { sendRequest } from '../../lib/omnichannel/webhooks';
import { normalizeMessageFileUpload } from '../../lib/utils/functions/normalizeMessageFileUpload';
import { settings } from '../../settings';
type AdditionalFields =
| Record<string, unknown>
| {
departmentId: IOmnichannelRoom['departmentId'];
}
| {
departmentId: IOmnichannelRoom['departmentId'];
servedBy: IOmnichannelRoom['servedBy'];
}
| {
departmentId: IOmnichannelRoom['departmentId'];
servedBy: IOmnichannelRoom['servedBy'];
oldDepartmentId: IOmnichannelRoom['departmentId'];
oldServedBy: { _id: string; ts: Date; username?: string };
}
| {
departmentId: IOmnichannelRoom['departmentId'];
servedBy: IOmnichannelRoom['servedBy'];
closedAt: IOmnichannelRoom['closedAt'];
closedBy: IOmnichannelRoom['closedBy'];
closer: IOmnichannelRoom['closer'];
};
type OmnichannelRoomWithExtraFields = IOmnichannelRoom & {
oldServedBy?: { _id: string; ts: Date; username?: string };
oldDepartmentId?: IOmnichannelRoom['departmentId'];
};
type CRMActions =
| 'LivechatSessionStart'
| 'LivechatSessionQueued'
| 'LivechatSession'
| 'LivechatSessionTaken'
| 'LivechatSessionForwarded'
| 'LivechatEdit'
| 'Message'
| 'LeadCapture';
const msgNavType = 'livechat_navigation_history';
const msgClosingType = 'livechat-close';
export const isOmnichannelNavigationMessage = (message: IMessage): message is IOmnichannelSystemMessage => {
return message.t === msgNavType;
};
export const isOmnichannelClosingMessage = (message: IMessage): message is IOmnichannelSystemMessage => {
return message.t === msgClosingType;
};
export const sendMessageType = (msgType: string): boolean => {
switch (msgType) {
case msgClosingType:
return true;
case msgNavType:
return (
settings.get<boolean>('Livechat_Visitor_navigation_as_a_message') &&
settings.get<boolean>('Send_visitor_navigation_history_livechat_webhook_request')
);
default:
return false;
}
};
export const getAdditionalFieldsByType = (type: CRMActions, room: OmnichannelRoomWithExtraFields): AdditionalFields => {
const { departmentId, servedBy, closedAt, closedBy, closer, oldServedBy, oldDepartmentId } = room;
switch (type) {
case 'LivechatSessionStart':
case 'LivechatSessionQueued':
return { departmentId };
case 'LivechatSession':
return { departmentId, servedBy, closedAt, closedBy, closer };
case 'LivechatSessionTaken':
return { departmentId, servedBy };
case 'LivechatSessionForwarded':
return { departmentId, servedBy, oldDepartmentId, oldServedBy };
default:
return {};
}
};
export async function sendToCRM(
type: CRMActions,
room: OmnichannelRoomWithExtraFields,
includeMessages: boolean | IOmnichannelSystemMessage[] = true,
): Promise<OmnichannelRoomWithExtraFields> {
if (!settings.get('Livechat_webhookUrl')) {
return room;
}
const postData: Awaited<ReturnType<typeof getLivechatRoomGuestInfo>> & {
type: string;
messages: IOmnichannelSystemMessage[];
} = {
...(await getLivechatRoomGuestInfo(room)),
type,
messages: [],
};
let messages: IOmnichannelSystemMessage[] | null = null;
if (typeof includeMessages === 'boolean' && includeMessages) {
messages = await Messages.findVisibleByRoomId<IOmnichannelSystemMessage>(room._id, { sort: { ts: 1 } }).toArray();
} else if (includeMessages instanceof Array) {
messages = includeMessages;
}
if (messages) {
for (const message of messages) {
if (message.t && !sendMessageType(message.t)) {
continue;
}
const msg = {
_id: message._id,
username: message.u.username,
msg: message.msg || JSON.stringify(message.blocks),
...(message.blocks && message.blocks.length > 0 ? { blocks: message.blocks } : {}),
ts: message.ts,
rid: message.rid,
...(isEditedMessage(message) && { editedAt: message.editedAt }),
...(message.u.username !== postData.visitor.username && { agentId: message.u._id }),
...(isOmnichannelNavigationMessage(message) && { navigation: message.navigation }),
...(isOmnichannelClosingMessage(message) && { closingMessage: true }),
...(message.file && { file: message.file, attachments: message.attachments }),
};
const { u } = message;
postData.messages.push({ ...(await normalizeMessageFileUpload({ u, ...msg })), ...{ _updatedAt: message._updatedAt } });
}
}
const additionalData = getAdditionalFieldsByType(type, room);
const responseData = Object.assign(postData, additionalData);
// do not wait for the request to be answered
// this will avoid blocking the process of saving the message
void sendRequest(responseData, 5, async (response: Response) => {
if (!response) {
return;
}
const responseData = await response.text();
await LivechatRooms.saveCRMDataByRoomId(room._id, responseData);
});
return room;
}
callbacks.add(
'livechat.closeRoom',
async (params) => {
const { room } = params;
if (!settings.get('Livechat_webhook_on_close')) {
return params;
}
await sendToCRM('LivechatSession', room);
return params;
},
callbacks.priority.MEDIUM,
'livechat-send-crm-close-room',
);
callbacks.add(
'livechat.afterForwardChatToAgent',
async (params) => {
const { rid, oldServedBy } = params;
if (!settings.get('Livechat_webhook_on_forward')) {
return params;
}
const originalRoom = await LivechatRooms.findOneById(rid);
if (!originalRoom) {
return params;
}
const room = Object.assign(originalRoom, { oldServedBy });
await sendToCRM('LivechatSessionForwarded', room);
return params;
},
callbacks.priority.MEDIUM,
'livechat-send-crm-room-forwarded-to-agent',
);
callbacks.add(
'livechat.afterForwardChatToDepartment',
async (params) => {
const { rid, oldDepartmentId } = params;
if (!settings.get('Livechat_webhook_on_forward')) {
return params;
}
const originalRoom = await LivechatRooms.findOneById(rid);
if (!originalRoom) {
return params;
}
const room = Object.assign(originalRoom, { oldDepartmentId });
await sendToCRM('LivechatSessionForwarded', room);
return params;
},
callbacks.priority.MEDIUM,
'livechat-send-crm-room-forwarded-to-department',
);
callbacks.add(
'livechat.saveInfo',
async (room) => {
// Do not send to CRM if the chat is still open
if (!isOmnichannelRoom(room) || room.open) {
return room;
}
return sendToCRM('LivechatEdit', room);
},
callbacks.priority.MEDIUM,
'livechat-send-crm-save-info',
);
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room }) => {
// if the message has a token, it was sent from the visitor
// if not, it was sent from the agent
if (message.token && !settings.get('Livechat_webhook_on_visitor_message')) {
return message;
}
if (!message.token && !settings.get('Livechat_webhook_on_agent_message')) {
return message;
}
// if the message has a type means it is a special message (like the closing comment), so skips
// unless the settings that handle with visitor navigation history are enabled
if (message.t && !sendMessageType(message.t)) {
return message;
}
await sendToCRM('Message', room, [message]);
return message;
},
callbacks.priority.MEDIUM,
'livechat-send-crm-message',
);
callbacks.add(
'livechat.leadCapture',
(room) => {
if (!settings.get('Livechat_webhook_on_capture')) {
return room;
}
return sendToCRM('LeadCapture', room, false);
},
callbacks.priority.MEDIUM,
'livechat-send-crm-lead-capture',
);

View File

@@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';
export const beforeAddUserToRoom = makeFunction(async (_users: IUser['username'][], _room: IRoom, _actor?: IUser) => {
// no op on CE
});

View File

@@ -0,0 +1,59 @@
import { hashLoginToken } from '@rocket.chat/account-utils';
import { InstanceStatus } from '@rocket.chat/instance-status';
import { getHeader } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { ILoginAttempt } from '../lib/auth/ILoginAttempt';
import { deviceManagementEvents } from '../services/device-management/events';
import { sauEvents } from '../services/sauMonitor/events';
Accounts.onLogin((info: ILoginAttempt) => {
const {
methodArguments,
connection: { httpHeaders },
} = info;
if (!info.user) {
return;
}
const { resume } = methodArguments.find((arg) => 'resume' in arg) ?? {};
const loginToken = resume ? hashLoginToken(resume) : '';
const instanceId = InstanceStatus.id();
const clientAddress = info.connection.clientAddress || getHeader(httpHeaders, 'x-real-ip');
const userAgent = getHeader(httpHeaders, 'user-agent');
const host = getHeader(httpHeaders, 'host');
sauEvents.emit('sau.accounts.login', {
userId: info.user._id,
instanceId,
userAgent,
loginToken,
connectionId: info.connection.id,
clientAddress,
host,
});
if (!loginToken) {
deviceManagementEvents.emit('device-login', { userId: info.user._id, userAgent, clientAddress });
}
});
Accounts.onLogout((info) => {
if (!info.user) {
return;
}
sauEvents.emit('sau.accounts.logout', { userId: info.user._id, sessionId: info.connection.id });
});
Meteor.onConnection((connection) => {
connection.onClose(async () => {
sauEvents.emit('sau.socket.disconnected', { connectionId: connection.id, instanceId: InstanceStatus.id() });
});
});
Meteor.onConnection((connection) => {
sauEvents.emit('sau.socket.connected', { instanceId: InstanceStatus.id(), connectionId: connection.id });
});

View File

@@ -0,0 +1,21 @@
import { AppEvents, Apps } from '@rocket.chat/apps';
import type { IUser } from '@rocket.chat/core-typings';
import { Accounts } from 'meteor/accounts-base';
import { afterLogoutCleanUpCallback } from '../lib/callbacks/afterLogoutCleanUpCallback';
export const runUserLogoutCleanUp = async (user: IUser): Promise<void> => {
setImmediate(() => {
void afterLogoutCleanUpCallback.run(user);
});
await Apps.self?.triggerEvent(AppEvents.IPostUserLoggedOut, user);
};
Accounts.onLogout(async ({ user }: { user?: IUser }) => {
if (!user) {
return;
}
await runUserLogoutCleanUp(user);
});