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:
24
apps/meteor/server/hooks/afterUserActions.ts
Normal file
24
apps/meteor/server/hooks/afterUserActions.ts
Normal 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');
|
||||
30
apps/meteor/server/hooks/auth/login.ts
Normal file
30
apps/meteor/server/hooks/auth/login.ts
Normal 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);
|
||||
});
|
||||
2
apps/meteor/server/hooks/index.ts
Normal file
2
apps/meteor/server/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './sauMonitorHooks';
|
||||
import './userLogoutCleanUp';
|
||||
61
apps/meteor/server/hooks/messages/afterSaveMessage.ts
Normal file
61
apps/meteor/server/hooks/messages/afterSaveMessage.ts
Normal 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 });
|
||||
}
|
||||
140
apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts
Normal file
140
apps/meteor/server/hooks/messages/mentionUserNotInChannel.ts
Normal 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',
|
||||
);
|
||||
231
apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts
Normal file
231
apps/meteor/server/hooks/messages/notifyUsersOnMessage.ts
Normal 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',
|
||||
);
|
||||
95
apps/meteor/server/hooks/messages/processThreads.ts
Normal file
95
apps/meteor/server/hooks/messages/processThreads.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
134
apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts
Normal file
134
apps/meteor/server/hooks/messages/propagateDiscussionMetadata.ts
Normal 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',
|
||||
);
|
||||
432
apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts
Normal file
432
apps/meteor/server/hooks/messages/sendNotificationsOnMessage.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
41
apps/meteor/server/hooks/omnichannel/afterAgentRemoved.ts
Normal file
41
apps/meteor/server/hooks/omnichannel/afterAgentRemoved.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
52
apps/meteor/server/hooks/omnichannel/afterUserActions.ts
Normal file
52
apps/meteor/server/hooks/omnichannel/afterUserActions.ts
Normal 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');
|
||||
69
apps/meteor/server/hooks/omnichannel/leadCapture.ts
Normal file
69
apps/meteor/server/hooks/omnichannel/leadCapture.ts
Normal 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',
|
||||
);
|
||||
30
apps/meteor/server/hooks/omnichannel/markRoomNotResponded.ts
Normal file
30
apps/meteor/server/hooks/omnichannel/markRoomNotResponded.ts
Normal 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',
|
||||
);
|
||||
79
apps/meteor/server/hooks/omnichannel/markRoomResponded.ts
Normal file
79
apps/meteor/server/hooks/omnichannel/markRoomResponded.ts
Normal 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',
|
||||
);
|
||||
26
apps/meteor/server/hooks/omnichannel/offlineMessage.ts
Normal file
26
apps/meteor/server/hooks/omnichannel/offlineMessage.ts
Normal 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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
137
apps/meteor/server/hooks/omnichannel/processRoomAbandonment.ts
Normal file
137
apps/meteor/server/hooks/omnichannel/processRoomAbandonment.ts
Normal 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');
|
||||
90
apps/meteor/server/hooks/omnichannel/saveAnalyticsData.ts
Normal file
90
apps/meteor/server/hooks/omnichannel/saveAnalyticsData.ts
Normal 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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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');
|
||||
263
apps/meteor/server/hooks/omnichannel/sendToCRM.ts
Normal file
263
apps/meteor/server/hooks/omnichannel/sendToCRM.ts
Normal 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',
|
||||
);
|
||||
6
apps/meteor/server/hooks/rooms/beforeAddUserToRoom.ts
Normal file
6
apps/meteor/server/hooks/rooms/beforeAddUserToRoom.ts
Normal 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
|
||||
});
|
||||
59
apps/meteor/server/hooks/sauMonitorHooks.ts
Normal file
59
apps/meteor/server/hooks/sauMonitorHooks.ts
Normal 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 });
|
||||
});
|
||||
21
apps/meteor/server/hooks/userLogoutCleanUp.ts
Normal file
21
apps/meteor/server/hooks/userLogoutCleanUp.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user