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

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

View File

@@ -0,0 +1,22 @@
import { Abac } from '@rocket.chat/core-services';
import { License } from '@rocket.chat/license';
import { beforeAddUserToRoom } from '../../../../server/hooks/rooms/beforeAddUserToRoom';
import { settings } from '../../../../server/settings';
beforeAddUserToRoom.patch(async (prev, users, room, actor) => {
await prev(users, room, actor);
const validUsers = users.filter(Boolean);
// No need to check ABAC when theres no users or when room is not private or when room is not ABAC managed
if (!validUsers.length || room.t !== 'p' || !room?.abacAttributes?.length) {
return;
}
// Throw error (prevent add) if ABAC is disabled (setting, license) but room is ABAC managed
if (!settings.get('ABAC_Enabled') || !License.hasModule('abac')) {
throw new Error('error-room-is-abac-managed');
}
await Abac.checkUsernamesMatchAttributes(validUsers as string[], room.abacAttributes, room);
});

View File

@@ -0,0 +1,2 @@
import './beforeAddUserToRoom';
import './scopeAdminRoomsForAbac';

View File

@@ -0,0 +1,30 @@
import { Abac } from '@rocket.chat/core-services';
import type { IRoom, IRoomAbacRedaction } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { Users } from '@rocket.chat/models';
import { scopeAdminRoomsForAbac } from '../../../../server/api/lib/scopeAdminRoomsForAbac';
import { isABACManagedRoom } from '../../../../server/lib/authorization/isABACManagedRoom';
const redact = <T extends Pick<IRoom, 't' | 'abacAttributes'>>(rooms: T[]): Array<T & IRoomAbacRedaction> =>
rooms.map((room) => (isABACManagedRoom(room) ? { ...room, abacAttributes: [], abacAttributesRedacted: true } : room));
scopeAdminRoomsForAbac.patch(async (next, rooms, uid) => {
const managed = License.hasModule('abac') ? rooms.filter(isABACManagedRoom) : [];
if (!managed.length) {
return next(rooms, uid);
}
try {
const user = await Users.findOneById(uid, { projection: { _id: 1, username: 1, name: 1 } });
if (!user) {
return redact(rooms);
}
const scoped = await Abac.scopeRoomsForAdmin(managed, user);
const scopedById = new Map(scoped.map((room) => [room._id, room]));
return rooms.map((room) => scopedById.get(room._id) ?? room);
} catch (err) {
return redact(rooms);
}
});

View File

@@ -0,0 +1,66 @@
import { MeteorError } from '@rocket.chat/core-services';
import { License } from '@rocket.chat/license';
import { callbacks } from '../../../../server/lib/callbacks';
import { i18n } from '../../../../server/lib/i18n';
import { validateUserRoles } from '../../lib/authorization/validateUserRoles';
License.onInstall(() => {
callbacks.add(
'beforeSaveUser',
async ({ user, oldUser }) => validateUserRoles(user, oldUser),
callbacks.priority.HIGH,
'validateUserRoles',
);
callbacks.add(
'afterSaveUser',
async (user) => {
await License.shouldPreventAction('activeUsers');
return user;
},
callbacks.priority.HIGH,
'validateUserRoles',
);
callbacks.add(
'afterDeleteUser',
async (user) => {
await License.shouldPreventAction('activeUsers');
return user;
},
callbacks.priority.HIGH,
'validateUserRoles',
);
callbacks.add(
'afterDeactivateUser',
async (user) => {
await License.shouldPreventAction('activeUsers');
return user;
},
callbacks.priority.HIGH,
'validateUserStatus',
);
callbacks.add(
'beforeActivateUser',
async () => {
if (await License.shouldPreventAction('activeUsers')) {
throw new MeteorError('error-license-user-limit-reached', i18n.t('error-license-user-limit-reached'));
}
return undefined;
},
callbacks.priority.HIGH,
'validateUserStatus',
);
});
License.onInvalidate(() => {
callbacks.remove('beforeSaveUser', 'validateUserRoles');
callbacks.remove('afterSaveUser', 'validateUserRoles');
callbacks.remove('afterDeleteUser', 'validateUserRoles');
callbacks.remove('afterDeactivateUser', 'validateUserStatus');
callbacks.remove('beforeActivateUser', 'validateUserStatus');
});

View File

@@ -0,0 +1,20 @@
import { License } from '@rocket.chat/license';
import { settings } from '../../../../server/settings';
import { BeforeSaveCannedResponse } from '../messages/BeforeSaveCannedResponse';
void License.onToggledFeature('canned-responses', {
up: () => {
// when the license is enabled, we need to check if the feature is enabled
BeforeSaveCannedResponse.enabled = settings.get('Canned_Responses_Enable');
},
down: () => {
// when the license is disabled, we can just disable the feature
BeforeSaveCannedResponse.enabled = false;
},
});
// we also need to check if the feature is enabled via setting, which is only possible when there is a license
settings.watch<boolean>('Canned_Responses_Enable', (value) => {
BeforeSaveCannedResponse.enabled = value;
});

View File

@@ -0,0 +1,19 @@
import { CannedResponse } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import notifications from '../../../../server/lib/notifications/core/lib/Notifications';
callbacks.add(
'livechat.removeAgentDepartment',
async (options: Record<string, any>): Promise<any> => {
const { departmentId, agentsId } = options;
await CannedResponse.findByDepartmentId(departmentId, { projection: { _id: 1 } }).forEach((response: any) => {
const { _id } = response;
notifications.streamCannedResponses.emit('canned-responses', { type: 'removed', _id }, { agentsId });
});
return options;
},
callbacks.priority.HIGH,
'canned-responses-on-remove-agent-department',
);

View File

@@ -0,0 +1,18 @@
import { CannedResponse } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import notifications from '../../../../server/lib/notifications/core/lib/Notifications';
callbacks.add(
'livechat.saveAgentDepartment',
async (options: Record<string, any>): Promise<any> => {
const { departmentId, agentsId } = options;
await CannedResponse.findByDepartmentId(departmentId, {}).forEach((response) => {
notifications.streamCannedResponses.emit('canned-responses', { type: 'changed', ...response }, { agentsId });
});
return options;
},
callbacks.priority.HIGH,
'canned-responses-on-save-agent-department',
);

View File

@@ -0,0 +1,398 @@
import { FederationMatrix, Message, MeteorError, Room } from '@rocket.chat/core-services';
import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated, isBannedSubscription } from '@rocket.chat/core-typings';
import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { isReservedByExclusiveBridge, validateFederatedUsername } from '@rocket.chat/federation-matrix';
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { afterBanFromRoomCallback } from '../../../../server/lib/callbacks/afterBanFromRoomCallback';
import { afterLeaveRoomCallback } from '../../../../server/lib/callbacks/afterLeaveRoomCallback';
import { afterRemoveFromRoomCallback } from '../../../../server/lib/callbacks/afterRemoveFromRoomCallback';
import { afterUnbanFromRoomCallback } from '../../../../server/lib/callbacks/afterUnbanFromRoomCallback';
import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../server/lib/callbacks/beforeAddUserToRoom';
import { beforeChangeRoomRole } from '../../../../server/lib/callbacks/beforeChangeRoomRole';
import { prepareCreateRoomCallback } from '../../../../server/lib/callbacks/beforeCreateRoomCallback';
import { checkUsernameAvailabilityCallback } from '../../../../server/lib/callbacks/checkUsernameAvailabilityCallback';
import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../../../../server/lib/notifyListener';
import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions';
// callbacks.add('federation-event-example', async () => FederationMatrix.handleExample(), callbacks.priority.MEDIUM, 'federation-event-example-handler');
// TODO: move this to the hooks folder
// Called BEFORE subscriptions are created - creates Matrix room so invites can be sent.
// The invites are sent by beforeAddUserToRoom callback.
callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner, originalMemberList: members }) => {
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
const federatedRoomId = room?.federation?.mrid;
if (!federatedRoomId) {
await FederationMatrix.createRoom(room, owner);
} else {
// TODO unify how to get server
// matrix room was already created and passed
const fromServer = federatedRoomId.split(':')[1];
await Rooms.setAsFederated(room._id, {
mrid: federatedRoomId,
origin: fromServer,
});
}
const federationRoom = await Rooms.findOneById(room._id);
if (!federationRoom || !isRoomNativeFederated(federationRoom)) {
throw new MeteorError('error-invalid-room', 'Invalid room');
}
// TODO this won't be neeeded once we receive all state events at ee/packages/federation-matrix/src/events/member.ts
await FederationMatrix.inviteUsersToRoom(
federationRoom,
members.filter((member) => member !== owner.username),
owner,
);
});
callbacks.add(
'afterSaveMessage',
async (message, { room, user }) => {
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
try {
// TODO: Check if message already exists in the database, if it does, don't send it to the federation to avoid loops
// If message is federated, it will save external_message_id like into the message object
// if this prop exists here it should not be sent to the federation to avoid loops
if (!message.federation?.eventId) {
await FederationMatrix.sendMessage(message, room, user);
}
} catch (error) {
// Log the error but don't prevent the message from being sent locally
console.error('[sendMessage] Failed to send message to Native Federation:', error);
}
},
callbacks.priority.HIGH,
'native-federation-after-room-message-sent',
);
callbacks.add(
'afterDeleteMessage',
async (message: IMessage, { room, user }) => {
if (!message.federation?.eventId) {
return;
}
// deletion came from federation — don't echo
if (isUserNativeFederated(user)) {
return;
}
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.deleteMessage(room.federation.mrid, message);
}
},
callbacks.priority.MEDIUM,
'native-federation-after-delete-message',
);
beforeAddUsersToRoom.add(async ({ usernames, inviter }, room) => {
if (!FederationActions.shouldPerformFederationAction(room) && inviter) {
// check if trying to invite a federated user to a non-federated room
const federatedUsernames = usernames.filter((u) => validateFederatedUsername(u));
if (federatedUsernames.length > 0) {
throw new MeteorError('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms');
}
return;
}
// we create local users before adding them to the room
await FederationMatrix.ensureFederatedUsersExistLocally(usernames);
});
beforeAddUserToRoom.add(
async ({ user, inviter }, room) => {
if (!user.username || !inviter) {
return;
}
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
// Check if user is already in room
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id);
if (subscription) {
if (!isBannedSubscription(subscription)) {
return;
}
// For federated rooms, unban requires a Matrix kick (leave) followed by a new invite.
// Remove the subscription so the unban propagates to Matrix via the afterUnbanFromRoom callback,
// then let the flow continue to create a new INVITED subscription via the beforeAddUserToRoom hook.
await Subscriptions.removeById(subscription._id);
await Message.saveSystemMessage('user-unbanned', room._id, user.username, inviter);
void notifyOnSubscriptionChanged(subscription, 'removed');
void notifyOnRoomChangedById(room._id);
await afterUnbanFromRoomCallback.run({ unbannedUser: user, userWhoUnbanned: inviter }, room);
}
if (!isUserNativeFederated(user) && !(await FederationMatrix.canUserAccessFederation(user))) {
throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation');
}
// If inviter is federated, the invite came from an external transaction.
// Don't propagate back to Matrix (it was already processed at origin server).
if (isUserNativeFederated(inviter)) {
return;
}
await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter);
// after invite is sent we create the invite subscriptions
// TODO this may be not needed if we receive the emit for the invite event from matrix
await Room.createUserSubscription({
ts: new Date(),
room,
userToBeAdded: user,
inviter,
status: 'INVITED',
});
},
callbacks.priority.MEDIUM,
'native-federation-on-before-add-users-to-room',
);
callbacks.add(
'afterSetReaction',
async (message: IMessage, params): Promise<void> => {
// Don't federate reactions that came from Matrix
if (params.user.username?.includes(':')) {
return;
}
if (FederationActions.shouldPerformFederationAction(params.room)) {
await FederationMatrix.sendReaction(message._id, params.reaction, params.user);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-set-reaction',
);
callbacks.add(
'afterUnsetReaction',
async (_message: IMessage, params): Promise<void> => {
// Don't federate reactions that came from Matrix
if (params.user.username?.includes(':')) {
return;
}
if (FederationActions.shouldPerformFederationAction(params.room)) {
await FederationMatrix.removeReaction(params.oldMessage._id, params.reaction, params.user, params.oldMessage);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-unset-reaction',
);
afterLeaveRoomCallback.add(
async ({ user, kicker }, room: IRoom): Promise<void> => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.leaveRoom(room._id, user, kicker);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-leave-room',
);
afterRemoveFromRoomCallback.add(
async (data: { removedUser: IUser; userWhoRemoved: IUser }, room: IRoom): Promise<void> => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.kickUser(room, data.removedUser, data.userWhoRemoved);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-remove-from-room',
);
afterBanFromRoomCallback.add(
async (data: { bannedUser: IUser; userWhoBanned: IUser }, room: IRoom): Promise<void> => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.banUser(room, data.bannedUser, data.userWhoBanned);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-ban-from-room',
);
afterUnbanFromRoomCallback.add(
async (data: { unbannedUser: IUser; userWhoUnbanned: IUser }, room: IRoom): Promise<void> => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.unbanUser(room, data.unbannedUser, data.userWhoUnbanned);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-unban-from-room',
);
callbacks.add(
'afterRoomNameChange',
async ({ room, name, user }) => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.updateRoomName(room._id, name, user);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-room-name-changed',
);
callbacks.add(
'afterRoomTopicChange',
async (_, { room, topic, user }) => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.updateRoomTopic(room, topic, user);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-room-topic-changed',
);
callbacks.add(
'afterSaveMessage',
async (message: IMessage, { room, user }) => {
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
if (!isEditedMessage(message)) {
return;
}
// editor is remote -> edit came from federation, don't echo
if (isUserNativeFederated(user)) {
return;
}
await FederationMatrix.updateMessage(room, message);
},
callbacks.priority.HIGH,
'federation-matrix-after-room-message-updated',
);
beforeChangeRoomRole.add(
async (params: { fromUserId: string; userId: string; room: IRoom; role: 'moderator' | 'owner' | 'leader' | 'user' }) => {
if (FederationActions.shouldPerformFederationAction(params.room)) {
await FederationMatrix.addUserRoleRoomScoped(params.room, params.fromUserId, params.userId, params.role);
}
},
callbacks.priority.HIGH,
'federation-matrix-before-change-room-role',
);
callbacks.add(
'beforeCreateDirectRoom',
async (members, room): Promise<void> => {
if (FederationActions.shouldPerformFederationAction(room)) {
await FederationMatrix.ensureFederatedUsersExistLocally(members);
}
},
callbacks.priority.HIGH,
'federation-matrix-before-create-direct-room',
);
callbacks.add('federation.beforeCreateDirectMessage', async (roomUsers, extraData) => {
// TODO: use a shared helper to check whether a user is federated
// since the DM creation API doesn't tell us if the room is federated (unlike normal channels),
// we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated
const hasFederatedMembers = roomUsers.some((user: unknown) => typeof user === 'string' && user.includes(':') && user.includes('@'));
if (hasFederatedMembers) {
extraData.federated = true;
extraData.federation = {
version: 1,
};
}
});
callbacks.add(
'afterCreateDirectRoom',
async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id'] }): Promise<void> => {
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
// as per federation.beforeCreateDirectMessage we create a DM without federation data because we still don't have it.
if (!room.federation.mrid) {
// so after the DM is created we call the federation to create the DM on Matrix side and then updated the reference here
await FederationMatrix.createDirectMessageRoom(room, params.members, params.creatorId);
}
},
callbacks.priority.HIGH,
'federation-matrix-after-create-direct-room',
);
prepareCreateRoomCallback.add(async ({ extraData }) => {
if (!extraData.federated || isRoomNativeFederated(extraData)) {
return;
}
// when we receive extraData.federated, we need to prepare the room to be considered IRoomNativeFederated.
// according to isRoomNativeFederated for a room to be considered IRoomNativeFederated it is enough to have
// only an empty "federation" object
(extraData as IRoomNativeFederated).federation = { version: 1 } as any;
});
callbacks.add(
'afterReadMessages',
async (room: IRoom, params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => {
if (!FederationActions.shouldPerformFederationAction(room)) {
return;
}
const user = await Users.findOneById(params.uid);
if (!user) {
return;
}
if (isUserNativeFederated(user)) {
// if the user is federated, it means the read receipt came from Matrix, so we don't need to notify Matrix again
return;
}
await FederationMatrix.notifyRoomRead({ room, userId: params.uid, threadId: params.tmid });
},
callbacks.priority.MEDIUM,
'federation-read-receipt',
);
callbacks.add('afterSaveUser', async ({ user: userUpdated, oldUser: oldUserData }) => {
if (!userUpdated || !oldUserData) {
return;
}
if (isUserNativeFederated(userUpdated)) {
// if the user is federated, it means the update came from Matrix, so we don't need to notify Matrix again
return;
}
if ('name' in userUpdated && userUpdated.name !== oldUserData.name) {
void FederationMatrix.updateUserName(userUpdated);
}
});
// Reserve handles that fall within a bridge's exclusive namespace, so a regular user cannot
// register or rename into a localpart only the bridge is allowed to own. Usernames are checked
// against the bridge's exclusive `users` namespace; room/team names against its `aliases`/`rooms`
// namespaces. Inert when federation is disabled (the helper matches against loaded appservice
// registrations, of which there are none). Every handle assignment path — user creation,
// SSO/LDAP assignment and renames, plus room renames and team creation — funnels through
// `checkUsernameAvailability`.
checkUsernameAvailabilityCallback.add(
(name, type) => {
if (isReservedByExclusiveBridge(type, name)) {
throw new MeteorError('error-username-reserved-by-bridge', 'Name is reserved by a federation bridge');
}
},
callbacks.priority.HIGH,
'federation-bridge-namespace',
);

View File

@@ -0,0 +1,93 @@
import { isILivechatVisitor, isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users } from '@rocket.chat/models';
import get from 'lodash.get';
import mem from 'mem';
const placeholderFields = {
'contact.name': {
from: 'visitor',
dataKey: 'name',
},
'contact.email': {
from: 'visitor',
dataKey: 'visitorEmails[0].address',
},
'contact.phone': {
from: 'visitor',
dataKey: 'phone[0].phoneNumber',
},
'agent.name': {
from: 'agent',
dataKey: 'name',
},
'agent.email': {
from: 'agent',
dataKey: 'emails[0].address',
},
};
export class BeforeSaveCannedResponse {
static enabled = false;
private getUser = mem((userId: string) => Users.findOneById(userId, { projection: { name: 1, _id: 1, emails: 1 } }), {
maxAge: 1000 * 30,
});
private getVisitor = mem((visitorId: string) => LivechatVisitors.findOneEnabledById(visitorId), {
maxAge: 1000 * 30,
});
async replacePlaceholders({
message,
room,
user,
}: {
message: IMessage;
room: IRoom;
user: Pick<IUser, '_id' | 'username' | 'name' | 'emails' | 'language'>;
}): Promise<IMessage> {
// If the feature is disabled, return the message as is
if (!BeforeSaveCannedResponse.enabled) {
return message;
}
if (!message.msg || message.msg === '') {
return message;
}
if (!isOmnichannelRoom(room)) {
return message;
}
// do not replace placeholders for visitors
if (!user || isILivechatVisitor(user)) {
return message;
}
const agentId = room?.servedBy?._id;
if (!agentId) {
return message;
}
const getAgent = (agentId: string) => {
if (agentId === user._id) {
return user;
}
return this.getUser(agentId);
};
message.msg = await Object.keys(placeholderFields).reduce(async (messageText, field) => {
const placeholderConfig = placeholderFields[field as keyof typeof placeholderFields];
const from = placeholderConfig.from === 'agent' ? await getAgent(agentId) : await this.getVisitor(room.v._id);
const data = get(from, placeholderConfig.dataKey, '');
return (await messageText).replace(new RegExp(`{{${field}}}`, 'g'), data);
}, Promise.resolve(message.msg));
return message;
}
}

View File

@@ -0,0 +1,14 @@
import { ReadReceipts, ReadReceiptsArchive } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'afterDeleteRoom',
async (rid) => {
await ReadReceipts.removeByRoomId(rid);
await ReadReceiptsArchive.removeByRoomId(rid);
return rid;
},
callbacks.priority.LOW,
'DeleteReadReceipts',
);

View File

@@ -0,0 +1,25 @@
import { MessageReads } from '@rocket.chat/core-services';
import { type IUser, type IRoom, type IMessage } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
import { ReadReceipt } from '../../lib/message-read-receipt/ReadReceipt';
callbacks.add(
'afterReadMessages',
async (room: IRoom, params: { uid: IUser['_id']; lastSeen?: Date; tmid?: IMessage['_id'] }) => {
if (!settings.get('Message_Read_Receipt_Enabled')) {
return;
}
const { uid, lastSeen, tmid } = params;
if (tmid) {
await MessageReads.readThread(uid, tmid);
} else if (lastSeen) {
await ReadReceipt.markMessagesAsRead(room._id, uid, lastSeen);
}
},
callbacks.priority.MEDIUM,
'message-read-receipt-afterReadMessages',
);

View File

@@ -0,0 +1,21 @@
import { isEditedMessage } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { ReadReceipt } from '../../lib/message-read-receipt/ReadReceipt';
callbacks.add(
'afterSaveMessage',
async (message, { room }) => {
// skips this callback if the message was edited
if (isEditedMessage(message)) {
return message;
}
// mark message as read as well
await ReadReceipt.markMessageAsReadBySender(message, room, message.u._id);
return message;
},
callbacks.priority.MEDIUM,
'message-read-receipt-afterSaveMessage',
);

View File

@@ -0,0 +1,22 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
import { onNewRoom } from '../../../../server/lib/omnichannel/hooks';
onNewRoom.patch(async (originalFn, room) => {
await originalFn(room);
if (!room.departmentId) {
return;
}
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'ancestors'>>(room.departmentId, {
projection: { ancestors: 1 },
});
if (!department?.ancestors) {
return;
}
const { ancestors } = department;
await LivechatRooms.updateDepartmentAncestorsById(room._id, ancestors);
});

View File

@@ -0,0 +1,19 @@
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.afterForwardChatToAgent',
async (options: { rid?: string } = {}) => {
const { rid } = options;
if (!rid) {
return options;
}
await LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(rid);
return options;
},
callbacks.priority.MEDIUM,
'livechat-after-forward-room-to-department',
);

View File

@@ -0,0 +1,44 @@
import type { ILivechatDepartment, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { cbLogger } from '../../lib/omnichannel/logger';
callbacks.add(
'livechat.afterForwardChatToDepartment',
async (options) => {
const { rid, newDepartmentId } = options;
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, '_id' | 'departmentAncestors'>>(rid, {
projection: { departmentAncestors: 1 },
});
if (!room) {
return options;
}
await LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id);
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'ancestors'>>(newDepartmentId, {
projection: { ancestors: 1 },
});
if (!department) {
return options;
}
const { departmentAncestors } = room;
const { ancestors } = department;
if (!ancestors && !departmentAncestors) {
return options;
}
cbLogger.debug({
msg: 'Updating department ancestors for room',
departmentId: newDepartmentId,
roomId: rid,
});
await LivechatRooms.updateDepartmentAncestorsById(room._id, ancestors);
return options;
},
callbacks.priority.MEDIUM,
'livechat-after-forward-room-to-department',
);

View File

@@ -0,0 +1,22 @@
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import moment from 'moment';
import { afterInquiryQueued } from '../../../../server/lib/omnichannel/hooks';
import { settings } from '../../../../server/settings';
import { OmnichannelQueueInactivityMonitor } from '../../lib/omnichannel/QueueInactivityMonitor';
export const afterInquiryQueuedFunc = async (inquiry: ILivechatInquiryRecord) => {
const timer = settings.get<number>('Livechat_max_queue_wait_time');
if (!inquiry?._id || !inquiry?._updatedAt || timer <= 0) {
return void 0;
}
// schedule individual jobs instead of property for close inactivty
const newQueueTime = moment(inquiry._updatedAt).add(timer, 'minutes');
await OmnichannelQueueInactivityMonitor.scheduleInquiry(inquiry._id, new Date(newQueueTime.format()));
};
afterInquiryQueued.patch(async (originalFn: any, inquiry: ILivechatInquiryRecord) => {
await originalFn();
return afterInquiryQueuedFunc(inquiry);
});

View File

@@ -0,0 +1,36 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { i18n } from '../../../../server/lib/i18n';
import { settings } from '../../../../server/settings';
import { AutoCloseOnHoldScheduler } from '../../lib/omnichannel/AutoCloseOnHoldScheduler';
import { cbLogger } from '../../lib/omnichannel/logger';
let autoCloseOnHoldChatTimeout = 0;
const handleAfterOnHold = async (room: Pick<IOmnichannelRoom, '_id'>): Promise<any> => {
const { _id: rid } = room;
if (!rid) {
return;
}
if (!autoCloseOnHoldChatTimeout || autoCloseOnHoldChatTimeout <= 0) {
return;
}
cbLogger.debug({ msg: 'Scheduling room to be closed', rid, autoCloseOnHoldChatTimeout });
const closeComment =
settings.get<string>('Livechat_auto_close_on_hold_chats_custom_message') ||
i18n.t('Closed_automatically_because_chat_was_onhold_for_seconds', {
onHoldTime: autoCloseOnHoldChatTimeout,
});
await AutoCloseOnHoldScheduler.scheduleRoom(rid, autoCloseOnHoldChatTimeout, closeComment);
};
settings.watch<number>('Livechat_auto_close_on_hold_chats_timeout', (value) => {
autoCloseOnHoldChatTimeout = value as number;
if (!value || value <= 0) {
callbacks.remove('livechat:afterOnHold', 'livechat-auto-close-on-hold');
}
callbacks.add('livechat:afterOnHold', handleAfterOnHold, callbacks.priority.HIGH, 'livechat-auto-close-on-hold');
});

View File

@@ -0,0 +1,27 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { AutoCloseOnHoldScheduler } from '../../lib/omnichannel/AutoCloseOnHoldScheduler';
import { cbLogger } from '../../lib/omnichannel/logger';
type IRoom = Pick<IOmnichannelRoom, '_id'>;
const handleAfterOnHoldChatResumed = async (room: IRoom): Promise<IRoom> => {
if (!room?._id) {
return room;
}
const { _id: roomId } = room;
cbLogger.debug({ msg: 'Removing current on hold timers for room', roomId });
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId);
return room;
};
callbacks.add(
'livechat:afterOnHoldChatResumed',
handleAfterOnHoldChatResumed,
callbacks.priority.HIGH,
'livechat-after-on-hold-chat-resumed',
);

View File

@@ -0,0 +1,35 @@
import type { AtLeast, ILivechatAgent, ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatUnit } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { cbLogger } from '../../lib/omnichannel/logger';
const afterRemoveDepartment = async (options: {
department: AtLeast<ILivechatDepartment, '_id' | 'businessHourId' | 'parentId'>;
agentsId: ILivechatAgent['_id'][];
}) => {
if (!options?.department) {
cbLogger.warn({ msg: 'No department found in options', options });
return options;
}
const { department } = options;
cbLogger.debug({
msg: 'Post removal actions on EE code for department',
department,
});
await Promise.all([
LivechatDepartment.removeDepartmentFromForwardListById(department._id),
...(department.parentId ? [LivechatUnit.decrementDepartmentsCount(department.parentId)] : []),
]);
return options;
};
callbacks.add(
'livechat.afterRemoveDepartment',
(options) => afterRemoveDepartment(options),
callbacks.priority.HIGH,
'livechat-after-remove-department',
);

View File

@@ -0,0 +1,24 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
settings.watch('Livechat_abandoned_rooms_action', (value) => {
if (!value || value === 'none') {
callbacks.remove('livechat:afterReturnRoomAsInquiry', 'livechat-after-return-room-as-inquiry');
return;
}
callbacks.add(
'livechat:afterReturnRoomAsInquiry',
({ room }: { room: IOmnichannelRoom }) => {
if (!room?._id || !room?.omnichannel?.predictedVisitorAbandonmentAt) {
return;
}
return LivechatRooms.unsetPredictedVisitorAbandonmentByRoomId(room._id);
},
callbacks.priority.HIGH,
'livechat-after-return-room-as-inquiry',
);
});

View File

@@ -0,0 +1,14 @@
import { CannedResponse } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.afterTagRemoved',
async (tag) => {
const { name } = tag;
await CannedResponse.removeTagFromCannedResponses(name);
},
callbacks.priority.MEDIUM,
'on-tag-removed-remove-references',
);

View File

@@ -0,0 +1,54 @@
import type { InquiryWithAgentInfo, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatVisitors } from '@rocket.chat/models';
import { RoutingManager } from '../../../../server/lib/omnichannel/RoutingManager';
import { afterTakeInquiry } from '../../../../server/lib/omnichannel/hooks';
import { settings } from '../../../../server/settings';
import { AutoTransferChatScheduler } from '../../lib/omnichannel/AutoTransferChatScheduler';
import { debouncedDispatchWaitingQueueStatus } from '../../lib/omnichannel/Helper';
import { OmnichannelQueueInactivityMonitor } from '../../lib/omnichannel/QueueInactivityMonitor';
import { cbLogger } from '../../lib/omnichannel/logger';
afterTakeInquiry.patch(
async (
originalFn,
{ inquiry, room, agent }: { inquiry: InquiryWithAgentInfo; room: IOmnichannelRoom; agent: { agentId: string; username: string } },
) => {
await originalFn({ inquiry, room, agent });
if (!inquiry) {
return;
}
if (settings.get('Livechat_waiting_queue')) {
const { department } = inquiry;
void debouncedDispatchWaitingQueueStatus(department);
cbLogger.debug({
msg: 'Queue status updated',
queue: department || 'public',
});
}
if (settings.get<boolean>('Livechat_last_chatted_agent_routing') && agent && RoutingManager.getConfig()?.autoAssignAgent) {
const { v: { token } = {} } = inquiry;
if (token) {
await LivechatVisitors.updateLastAgentByToken(token, { ...agent, ts: new Date() });
cbLogger.info({
msg: 'Updated last agent of visitor',
token,
newAgent: { _id: agent.agentId, username: agent.username },
});
}
}
const autoTransferTimeout = settings.get<number>('Livechat_auto_transfer_chat_timeout');
if (autoTransferTimeout && !room?.autoTransferredAt && !room?.autoTransferOngoing) {
await AutoTransferChatScheduler.scheduleRoom(inquiry.rid, autoTransferTimeout);
}
const maxQueueWaitTime = settings.get<number>('Livechat_max_queue_wait_time');
if (maxQueueWaitTime > 0) {
await OmnichannelQueueInactivityMonitor.stopInquiry(inquiry._id);
}
},
);

View File

@@ -0,0 +1,23 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { FilterOperators } from 'mongodb';
import { callbacks } from '../../../../server/lib/callbacks';
import { restrictQuery } from '../../lib/omnichannel/restrictQuery';
callbacks.add(
'livechat.applyRoomRestrictions',
async (
originalQuery: FilterOperators<IOmnichannelRoom> = {},
{
unitsFilter,
userId,
}: {
unitsFilter?: string[];
userId?: string;
} = {},
) => {
return restrictQuery({ originalQuery, unitsFilter, userId });
},
callbacks.priority.HIGH,
'livechat-apply-room-restrictions',
);

View File

@@ -0,0 +1,44 @@
import type { ILivechatDepartment, AvailableAgentsAggregation } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import type { Filter } from 'mongodb';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
export async function getChatLimitsQuery(departmentId?: string): Promise<Filter<AvailableAgentsAggregation>> {
const limitFilter: Filter<AvailableAgentsAggregation> = [];
if (departmentId) {
const departmentLimit =
(
await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'maxNumberSimultaneousChat'>>(departmentId, {
projection: { maxNumberSimultaneousChat: 1 },
})
)?.maxNumberSimultaneousChat || 0;
if (departmentLimit > 0) {
limitFilter.push({ 'queueInfo.chatsForDepartment': { $gte: Number(departmentLimit) } });
}
}
limitFilter.push({
$and: [{ maxChatsForAgent: { $gt: 0 } }, { $expr: { $gte: ['$queueInfo.chats', '$maxChatsForAgent'] } }],
});
const maxChatsPerSetting = settings.get<number>('Livechat_maximum_chats_per_agent');
if (maxChatsPerSetting > 0) {
limitFilter.push({
$and: [{ maxChatsForAgent: { $eq: 0 } }, { 'queueInfo.chats': { $gte: maxChatsPerSetting } }],
});
}
return { $match: { $or: limitFilter } };
}
callbacks.add(
'livechat.applySimultaneousChatRestrictions',
async (_: any, { departmentId }: { departmentId?: string } = {}) => {
return getChatLimitsQuery(departmentId);
},
callbacks.priority.HIGH,
'livechat-apply-simultaneous-restrictions',
);

View File

@@ -0,0 +1,36 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.beforeForwardRoomToDepartment',
async (options) => {
const { room, transferData } = options;
if (!room || !transferData) {
return options;
}
const { departmentId } = room;
if (!departmentId) {
return options;
}
const { department: departmentToTransfer } = transferData;
const currentDepartment = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'departmentsAllowedToForward'>>(departmentId, {
projection: { departmentsAllowedToForward: 1 },
});
if (!currentDepartment) {
return options;
}
const { departmentsAllowedToForward } = currentDepartment;
const isAllowedToTransfer =
!departmentsAllowedToForward?.length ||
(Array.isArray(departmentsAllowedToForward) && departmentsAllowedToForward.includes(departmentToTransfer._id));
if (isAllowedToTransfer) {
return options;
}
throw new Meteor.Error('error-forwarding-department-target-not-allowed', 'The forwarding to the target department is not allowed.');
},
callbacks.priority.HIGH,
'livechat-before-forward-room-to-department',
);

View File

@@ -0,0 +1,34 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { Users } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
import { isAgentWithinChatLimits } from '../../lib/omnichannel/Helper';
callbacks.add(
'beforeJoinRoom',
async (user: IUser, room?: IRoom): Promise<IUser> => {
if (!settings.get('Livechat_waiting_queue')) {
return user;
}
if (!room || !isOmnichannelRoom(room)) {
return user;
}
const { departmentId } = room;
const userSubs = await Users.getAgentAndAmountOngoingChats(user._id, departmentId);
if (!userSubs) {
return user;
}
const { queueInfo: { chats = 0, chatsForDepartment = 0 } = {} } = userSubs;
if (await isAgentWithinChatLimits({ agentId: user._id, departmentId, totalChats: chats, departmentChats: chatsForDepartment })) {
return user;
}
throw new Error('error-max-number-simultaneous-chats-reached');
},
callbacks.priority.MEDIUM,
'livechat-before-join-room',
);

View File

@@ -0,0 +1,10 @@
import { LivechatTag } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.beforeListTags',
() => LivechatTag.find({}, { projection: { name: 1, departments: 1 } }).toArray(),
callbacks.priority.LOW,
'livechat-before-list-tags',
);

View File

@@ -0,0 +1,52 @@
import type { ILivechatInquiryRecord, ILivechatPriority, IOmnichannelServiceLevelAgreements } from '@rocket.chat/core-typings';
import { LivechatPriority, OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.beforeInquiry',
async (extraData) => {
const { sla: slaSearchTerm, priority: prioritySearchTerm, ...props } = extraData;
if (!slaSearchTerm && !prioritySearchTerm) {
return extraData;
}
let sla: IOmnichannelServiceLevelAgreements | null = null;
let priority: ILivechatPriority | null = null;
if (slaSearchTerm) {
sla = await OmnichannelServiceLevelAgreements.findOneByIdOrName(slaSearchTerm, {
projection: { dueTimeInMinutes: 1 },
});
if (!sla) {
throw new Meteor.Error('error-invalid-sla', 'Invalid sla', {
function: 'livechat.beforeInquiry',
});
}
}
if (prioritySearchTerm) {
priority = await LivechatPriority.findOneByIdOrName(prioritySearchTerm, {
projection: { _id: 1, sortItem: 1 },
});
if (!priority) {
throw new Meteor.Error('error-invalid-priority', 'Invalid priority', {
function: 'livechat.beforeInquiry',
});
}
}
const ts = new Date();
const changes: Partial<ILivechatInquiryRecord> = {
ts,
};
if (sla) {
changes.slaId = sla._id;
changes.estimatedWaitingTimeQueue = sla.dueTimeInMinutes;
}
if (priority) {
changes.priorityId = priority._id;
changes.priorityWeight = priority.sortItem;
}
return { ...props, ...changes };
},
callbacks.priority.MEDIUM,
'livechat-before-new-inquiry',
);

View File

@@ -0,0 +1,35 @@
import type { IOmnichannelRoomInfo, IOmnichannelRoomExtraData, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
import { isPlainObject } from '../../../../lib/utils/isPlainObject';
import { beforeNewRoom } from '../../../../server/lib/omnichannel/hooks';
export const beforeNewRoomPatched = async (
_next: any,
roomInfo: IOmnichannelRoomInfo,
extraData?: IOmnichannelRoomExtraData,
): Promise<Partial<IOmnichannelRoom>> => {
if (!extraData) {
return roomInfo;
}
const { sla: searchTerm, customFields } = extraData;
const roomInfoWithExtraData = { ...roomInfo, ...(isPlainObject(customFields) && { customFields }) };
if (!searchTerm) {
return roomInfoWithExtraData;
}
const sla = await OmnichannelServiceLevelAgreements.findOneByIdOrName(searchTerm);
if (!sla) {
throw new Meteor.Error('error-invalid-sla', 'Invalid sla', {
function: 'livechat.beforeRoom',
});
}
const { _id: slaId } = sla;
return { ...roomInfoWithExtraData, slaId };
};
beforeNewRoom.patch(beforeNewRoomPatched);

View File

@@ -0,0 +1,73 @@
import type { ILivechatInquiryRecord, SelectedAgent, ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatInquiry, LivechatRooms } from '@rocket.chat/models';
import { notifyOnLivechatInquiryChanged, notifyOnRoomChangedById } from '../../../../server/lib/notifyListener';
import { allowAgentSkipQueue } from '../../../../server/lib/omnichannel/Helper';
import { saveQueueInquiry } from '../../../../server/lib/omnichannel/QueueManager';
import { setDepartmentForGuest } from '../../../../server/lib/omnichannel/departmentsLib';
import { beforeRouteChat } from '../../../../server/lib/omnichannel/hooks';
import { online } from '../../../../server/lib/omnichannel/service-status';
import { settings } from '../../../../server/settings';
import { cbLogger } from '../../lib/omnichannel/logger';
beforeRouteChat.patch(
async (
originalFn: (inquiry: ILivechatInquiryRecord, _agent?: SelectedAgent | null) => Promise<ILivechatInquiryRecord | null | undefined>,
inquiry: ILivechatInquiryRecord,
agent?: SelectedAgent | null,
) => {
await originalFn(inquiry, agent);
if (!inquiry) {
return inquiry;
}
if (inquiry?.department && !(await online(inquiry.department, true, true))) {
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'fallbackForwardDepartment'>>(
inquiry.department,
{
projection: { fallbackForwardDepartment: 1 },
},
);
if (!department) {
return inquiry;
}
if (department.fallbackForwardDepartment) {
cbLogger.info({
msg: 'Moving inquiry to fallback department',
originalDepartment: inquiry.department,
fallbackDepartment: department.fallbackForwardDepartment,
inquiryId: inquiry._id,
});
const [, updatedLivechatInquiry] = await Promise.all([
setDepartmentForGuest({
visitorId: inquiry.v._id,
department: department.fallbackForwardDepartment,
}),
LivechatInquiry.setDepartmentByInquiryId(inquiry._id, department.fallbackForwardDepartment),
LivechatRooms.setDepartmentByRoomId(inquiry.rid, department.fallbackForwardDepartment),
]);
if (updatedLivechatInquiry) {
void notifyOnLivechatInquiryChanged(updatedLivechatInquiry, 'updated', { department: updatedLivechatInquiry.department });
}
void notifyOnRoomChangedById(inquiry.rid, 'updated');
inquiry = updatedLivechatInquiry ?? inquiry;
}
}
if (!settings.get('Livechat_waiting_queue')) {
return inquiry;
}
if (agent && (await allowAgentSkipQueue(agent))) {
return inquiry;
}
return saveQueueInquiry(inquiry);
},
);

View File

@@ -0,0 +1,65 @@
import { Users } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { allowAgentSkipQueue } from '../../../../server/lib/omnichannel/Helper';
import { checkOnlineAgents } from '../../../../server/lib/omnichannel/service-status';
import { settings } from '../../../../server/settings';
import { isAgentWithinChatLimits } from '../../lib/omnichannel/Helper';
import { cbLogger } from '../../lib/omnichannel/logger';
const validateMaxChats = async ({
agent,
inquiry,
}: {
agent: {
agentId: string;
};
inquiry: {
_id: string;
department: string;
};
options: {
forwardingToDepartment?: {
oldDepartmentId: string;
transferData: any;
};
clientAction?: boolean;
};
}) => {
if (!inquiry?._id || !agent?.agentId) {
cbLogger.debug('No inquiry or agent provided');
throw new Error('No inquiry or agent provided');
}
const { agentId } = agent;
if (!(await checkOnlineAgents(undefined, agent))) {
cbLogger.debug('Provided agent is not online');
throw new Error('Provided agent is not online');
}
if (!settings.get('Livechat_waiting_queue')) {
cbLogger.info({ msg: 'Chat can be taken by Agent: waiting queue is disabled', agentId });
return agent;
}
if (await allowAgentSkipQueue(agent)) {
cbLogger.info({ msg: 'Chat can be taken by Agent: agent can skip queue', agentId });
return agent;
}
const { department: departmentId } = inquiry;
const user = await Users.getAgentAndAmountOngoingChats(agentId, departmentId);
if (!user) {
cbLogger.debug({ msg: 'No valid agent found', agentId });
throw new Error('No valid agent found');
}
const { queueInfo: { chats = 0, chatsForDepartment = 0 } = {} } = user;
if (await isAgentWithinChatLimits({ agentId, departmentId, totalChats: chats, departmentChats: chatsForDepartment })) {
return user;
}
throw new Error('error-max-number-simultaneous-chats-reached');
};
callbacks.add('livechat.checkAgentBeforeTakeInquiry', validateMaxChats, callbacks.priority.MEDIUM, 'livechat-before-take-inquiry');

View File

@@ -0,0 +1,144 @@
import type { IUser, SelectedAgent } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatContacts, LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { notifyOnLivechatInquiryChanged } from '../../../../server/lib/notifyListener';
import { RoutingManager } from '../../../../server/lib/omnichannel/RoutingManager';
import { migrateVisitorIfMissingContact } from '../../../../server/lib/omnichannel/contacts/migrateVisitorIfMissingContact';
import { checkDefaultAgentOnNewRoom } from '../../../../server/lib/omnichannel/hooks';
import { settings } from '../../../../server/settings';
const normalizeDefaultAgent = (agent?: Pick<IUser, '_id' | 'username'> | null): SelectedAgent | undefined => {
if (!agent) {
return undefined;
}
const { _id: agentId, username } = agent;
return { agentId, username };
};
const getDefaultAgent = async ({ username, id }: { username?: string; id?: string }): Promise<SelectedAgent | undefined> => {
if (!username && !id) {
return undefined;
}
if (id) {
const agent = await Users.findOneOnlineAgentById(
id,
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
{
projection: { _id: 1, username: 1 },
},
);
if (agent) {
return normalizeDefaultAgent(agent);
}
const offlineAgent = await Users.findOneAgentById(id, { projection: { _id: 1, username: 1 } });
if (offlineAgent && settings.get('Livechat_accept_chats_with_no_agents')) {
return normalizeDefaultAgent(offlineAgent);
}
return undefined;
}
return normalizeDefaultAgent(
await Users.findOneOnlineAgentByUserList(
username || [],
{ projection: { _id: 1, username: 1 } },
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
),
);
};
settings.watch<boolean>('Livechat_last_chatted_agent_routing', (value) => {
if (!value) {
callbacks.remove('livechat.onMaxNumberSimultaneousChatsReached', 'livechat-on-max-number-simultaneous-chats-reached');
return;
}
callbacks.add(
'livechat.onMaxNumberSimultaneousChatsReached',
async (inquiry) => {
if (!inquiry?.defaultAgent) {
return inquiry;
}
if (!RoutingManager.getConfig()?.autoAssignAgent) {
return inquiry;
}
await LivechatInquiry.removeDefaultAgentById(inquiry._id);
void notifyOnLivechatInquiryChanged(inquiry, 'updated', {
defaultAgent: undefined,
});
return LivechatInquiry.findOneById(inquiry._id);
},
callbacks.priority.MEDIUM,
'livechat-on-max-number-simultaneous-chats-reached',
);
});
checkDefaultAgentOnNewRoom.patch(async (_next, defaultAgent, { visitorId, source } = {}) => {
if (!visitorId || !source) {
return defaultAgent;
}
const guest = await LivechatVisitors.findOneEnabledById(visitorId, {
projection: { lastAgent: 1, token: 1, contactManager: 1 },
});
if (!guest) {
return defaultAgent;
}
const hasDivergentContactManager = defaultAgent?.agentId !== guest?.contactManager;
if (!hasDivergentContactManager && defaultAgent) {
return defaultAgent;
}
const contactId = await migrateVisitorIfMissingContact(visitorId, source);
const contact = contactId ? await LivechatContacts.findOneEnabledById(contactId, { projection: { contactManager: 1 } }) : undefined;
const contactManagerPreferred = settings.get<boolean>('Omnichannel_contact_manager_routing');
const guestManager = contactManagerPreferred && (await getDefaultAgent({ id: contact?.contactManager }));
if (guestManager) {
return guestManager;
}
if (!settings.get<boolean>('Livechat_last_chatted_agent_routing')) {
return defaultAgent;
}
const { lastAgent, token } = guest;
const guestAgent = await getDefaultAgent({ username: lastAgent?.username });
if (guestAgent) {
return guestAgent;
}
const room = await LivechatRooms.findOneLastServedAndClosedByVisitorToken(token, {
projection: { servedBy: 1 },
});
if (!room?.servedBy) {
return defaultAgent;
}
const {
servedBy: { username: usernameByRoom },
} = room;
if (!usernameByRoom) {
return defaultAgent;
}
const lastRoomAgent = normalizeDefaultAgent(
await Users.findOneOnlineAgentByUserList(
usernameByRoom,
{ projection: { _id: 1, username: 1 } },
settings.get<boolean>('Livechat_enabled_when_agent_idle'),
settings.get<boolean>('Livechat_accept_chats_with_no_agents'),
),
);
return lastRoomAgent;
});

View File

@@ -0,0 +1,26 @@
import './addDepartmentAncestors';
import './afterForwardChatToDepartment';
import './beforeListTags';
import './beforeJoinRoom';
import './setPredictedVisitorAbandonmentTime';
import './beforeForwardRoomToDepartment';
import './afterRemoveDepartment';
import './onLoadForwardDepartmentRestrictions';
import './afterTakeInquiry';
import './beforeNewInquiry';
import './beforeNewRoom';
import './beforeRoutingChat';
import './checkAgentBeforeTakeInquiry';
import './onCloseLivechat';
import './onSaveVisitorInfo';
import './onBusinessHourStart';
import './onAgentAssignmentFailed';
import './afterOnHoldChatResumed';
import './afterReturnRoomAsInquiry';
import './afterForwardChatToAgent';
import './applySimultaneousChatsRestrictions';
import './afterInquiryQueued';
import './sendPdfTranscriptOnClose';
import './applyRoomRestrictions';
import './afterTagRemoved';
import './manageDepartmentUnit';

View File

@@ -0,0 +1,59 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatUnit } from '@rocket.chat/models';
import { getUnitsFromUser } from '@rocket.chat/omni-core-ee';
import { hasAnyRoleAsync } from '../../../../server/lib/authorization/hasRole';
import { callbacks } from '../../../../server/lib/callbacks';
export const manageDepartmentUnit = async ({ userId, departmentId, unitId }: { userId: string; departmentId: string; unitId: string }) => {
const accessibleUnits = await getUnitsFromUser(userId);
const isLivechatManager = await hasAnyRoleAsync(userId, ['admin', 'livechat-manager']);
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'ancestors' | 'parentId'>>(departmentId, {
projection: { ancestors: 1, parentId: 1 },
});
const isDepartmentAlreadyInUnit = unitId && department?.ancestors?.includes(unitId);
if (!department || isDepartmentAlreadyInUnit) {
return;
}
const currentDepartmentUnitId = department.parentId;
const canManageNewUnit = !unitId || isLivechatManager || (Array.isArray(accessibleUnits) && accessibleUnits.includes(unitId));
const canManageCurrentUnit =
!currentDepartmentUnitId || isLivechatManager || (Array.isArray(accessibleUnits) && accessibleUnits.includes(currentDepartmentUnitId));
if (!canManageNewUnit || !canManageCurrentUnit) {
return;
}
if (unitId) {
const unit = await LivechatUnit.findOneById(
unitId,
{
projection: { ancestors: 1 },
},
{
unitsFromUser: accessibleUnits,
},
);
if (!unit) {
return;
}
if (currentDepartmentUnitId) {
await LivechatUnit.decrementDepartmentsCount(currentDepartmentUnitId);
}
await LivechatDepartment.addDepartmentToUnit(departmentId, unitId, [unitId, ...(unit.ancestors || [])]);
await LivechatUnit.incrementDepartmentsCount(unitId);
return;
}
if (currentDepartmentUnitId) {
await LivechatUnit.decrementDepartmentsCount(currentDepartmentUnitId);
}
await LivechatDepartment.removeDepartmentFromUnit(departmentId);
};
callbacks.add('livechat.manageDepartmentUnit', manageDepartmentUnit, callbacks.priority.HIGH, 'livechat-manage-department-unit');

View File

@@ -0,0 +1,46 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
const handleOnAgentAssignmentFailed = async (
room: IOmnichannelRoom,
{
inquiry,
options,
}: {
inquiry: any;
options: {
forwardingToDepartment?: { oldDepartmentId?: string; transferData?: any };
clientAction?: boolean;
};
},
) => {
if (!inquiry || !room) {
return false;
}
if (!settings.get('Livechat_waiting_queue')) {
return false;
}
const { forwardingToDepartment: { oldDepartmentId } = {}, forwardingToDepartment } = options;
if (!forwardingToDepartment) {
return false;
}
const { department: newDepartmentId } = inquiry;
if (!newDepartmentId || !oldDepartmentId || newDepartmentId === oldDepartmentId) {
return false;
}
return { ...room, chatQueued: true } as IOmnichannelRoom & { chatQueued: boolean };
};
callbacks.add(
'livechat.onAgentAssignmentFailed',
handleOnAgentAssignmentFailed,
callbacks.priority.HIGH,
'livechat-agent-assignment-failed',
);

View File

@@ -0,0 +1,21 @@
import { LivechatBusinessHourBehaviors } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
import { MultipleBusinessHoursBehavior } from '../../lib/omnichannel/business-hour/Multiple';
callbacks.add(
'on-business-hour-start',
(options: any = {}) => {
const { BusinessHourBehaviorClass } = options;
if (!BusinessHourBehaviorClass) {
return options;
}
if (settings.get('Livechat_business_hour_type') === LivechatBusinessHourBehaviors.SINGLE) {
return options;
}
return { BusinessHourBehaviorClass: MultipleBusinessHoursBehavior };
},
callbacks.priority.HIGH,
'livechat-on-business-hour-start',
);

View File

@@ -0,0 +1,5 @@
import { Match } from 'meteor/check';
import { onCheckRoomParams } from '../../../../server/api/v1/omnichannel/lib/livechat';
onCheckRoomParams.patch((_: any, params) => ({ ...params, sla: Match.Maybe(String), priority: Match.Maybe(String) }));

View File

@@ -0,0 +1,39 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
import { AutoCloseOnHoldScheduler } from '../../lib/omnichannel/AutoCloseOnHoldScheduler';
import { debouncedDispatchWaitingQueueStatus } from '../../lib/omnichannel/Helper';
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
};
const onCloseLivechat = async (params: LivechatCloseCallbackParams) => {
const {
room,
room: { _id: roomId },
} = params;
await LivechatRooms.unsetOnHoldByRoomId(roomId);
if (settings.get('Livechat_auto_close_on_hold_chats_timeout')) {
await AutoCloseOnHoldScheduler.unscheduleRoom(roomId);
}
if (!settings.get('Livechat_waiting_queue')) {
return params;
}
const { departmentId } = room || {};
debouncedDispatchWaitingQueueStatus(departmentId);
return params;
};
callbacks.add(
'livechat.closeRoom',
(params: LivechatCloseCallbackParams) => onCloseLivechat(params),
callbacks.priority.HIGH,
'livechat-on-close-livechat-remove-on-hold-and-dispatch-waiting-queue',
);

View File

@@ -0,0 +1,34 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { getExtraConfigInfo } from '../../../../server/api/v1/omnichannel/lib/livechat';
import { getLivechatQueueInfo, getLivechatCustomFields } from '../../lib/omnichannel/Helper';
getExtraConfigInfo.patch(
async (
_: any,
options: {
room?: IOmnichannelRoom;
},
): Promise<{
queueInfo?: unknown;
customFields?: {
options?: string[] | undefined;
_id: string;
label: string;
regexp: string | undefined;
required: boolean;
type: string | undefined;
defaultValue: string | null;
}[];
room?: IOmnichannelRoom;
}> => {
const { room } = options;
const [queueInfo, customFields] = await Promise.all([getLivechatQueueInfo(room), getLivechatCustomFields()]);
return {
...(queueInfo && { queueInfo }),
...(customFields && { customFields }),
...options,
};
},
);

View File

@@ -0,0 +1,27 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
callbacks.add(
'livechat.onLoadForwardDepartmentRestrictions',
async (options) => {
const { departmentId } = options;
if (!departmentId) {
return options;
}
const department = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, 'departmentsAllowedToForward'>>(departmentId, {
projection: { departmentsAllowedToForward: 1 },
});
if (!department) {
return options;
}
const { departmentsAllowedToForward } = department;
if (!departmentsAllowedToForward) {
return options;
}
return Object.assign({ restrictions: { _id: { $in: departmentsAllowedToForward } } }, options);
},
callbacks.priority.MEDIUM,
'livechat-on-load-forward-department-restrictions',
);

View File

@@ -0,0 +1,53 @@
import type { IOmnichannelRoom, IOmnichannelServiceLevelAgreements, IUser } from '@rocket.chat/core-typings';
import { OmnichannelServiceLevelAgreements } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { removePriorityFromRoom, updateRoomPriority } from '../../api/v1/omnichannel/lib/priorities';
import { removeRoomSLA, updateRoomSLA } from '../../api/v1/omnichannel/lib/sla';
const updateSLA = async (room: IOmnichannelRoom, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>, slaId?: string) => {
if (!slaId) {
return removeRoomSLA(room._id, user);
}
const sla: Pick<IOmnichannelServiceLevelAgreements, '_id' | 'name' | 'dueTimeInMinutes'> | null =
await OmnichannelServiceLevelAgreements.findOneById(slaId, {
projection: { _id: 1, name: 1, dueTimeInMinutes: 1 },
});
if (!sla) {
throw new Error(`SLA not found with id: ${slaId}`);
}
await updateRoomSLA(room._id, user, sla);
};
const updatePriority = async (room: IOmnichannelRoom, user: Required<Pick<IUser, '_id' | 'username' | 'name'>>, priorityId?: string) => {
if (!priorityId) {
return removePriorityFromRoom(room._id, user);
}
await updateRoomPriority(room._id, user, priorityId);
};
callbacks.add(
'livechat.saveInfo',
async (room, { user, oldRoom }: any) => {
const { slaId: oldSlaId, priorityId: oldPriorityId } = oldRoom;
const { slaId: newSlaId, priorityId: newPriorityId } = room;
if (oldSlaId === newSlaId && oldPriorityId === newPriorityId) {
return room;
}
if (oldSlaId === newSlaId && oldPriorityId !== newPriorityId) {
await updatePriority(room, user, newPriorityId);
} else if (oldSlaId !== newSlaId && oldPriorityId === newPriorityId) {
await updateSLA(room, user, newSlaId);
} else {
await Promise.all([updateSLA(room, user, newSlaId), updatePriority(room, user, newPriorityId)]);
}
return room as any;
},
callbacks.priority.HIGH,
'livechat-on-save-room-info',
);

View File

@@ -0,0 +1,85 @@
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IRoom, ILivechatVisitor, ILivechatDepartment, TransferData, AtLeast } from '@rocket.chat/core-typings';
import { LivechatDepartment } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { forwardRoomToDepartment } from '../../../../server/lib/omnichannel/Helper';
import { settings } from '../../../../server/settings';
import { cbLogger } from '../../lib/omnichannel/logger';
const onTransferFailure = async (
room: IRoom,
{
guest,
transferData,
department,
}: {
guest: ILivechatVisitor;
transferData: TransferData;
department: AtLeast<ILivechatDepartment, '_id' | 'fallbackForwardDepartment' | 'name'>;
},
) => {
if (!isOmnichannelRoom(room)) {
return false;
}
if (!department?.fallbackForwardDepartment?.length) {
return false;
}
// TODO: find enabled not archived here
const fallbackDepartment = await LivechatDepartment.findOneById<Pick<ILivechatDepartment, '_id' | 'name'>>(
department.fallbackForwardDepartment,
{
projection: { name: 1, _id: 1 },
},
);
if (!fallbackDepartment) {
return false;
}
const { hops: currentHops = 1 } = transferData;
const maxHops = settings.get<number>('Omnichannel_max_fallback_forward_depth');
if (currentHops > maxHops) {
cbLogger.debug({
msg: 'Max fallback forward depth reached. Chat wont be transfered',
roomId: room._id,
hops: currentHops,
maxHops,
departmentId: department._id,
});
return false;
}
const transferDataFallback = {
...transferData,
usingFallbackDep: true,
prevDepartment: department.name,
departmentId: department.fallbackForwardDepartment,
department: fallbackDepartment,
hops: currentHops + 1,
};
const forwardSuccess = await forwardRoomToDepartment(room, guest, transferDataFallback);
if (!forwardSuccess) {
cbLogger.debug({
msg: 'Fallback forward failed',
departmentId: department._id,
roomId: room._id,
fallbackDepartmentId: department.fallbackForwardDepartment,
});
return false;
}
cbLogger.info({
msg: 'Fallback forward success',
departmentId: department._id,
roomId: room._id,
fallbackDepartmentId: department.fallbackForwardDepartment,
});
return true;
};
callbacks.add('livechat:onTransferFailure', onTransferFailure, callbacks.priority.HIGH);

View File

@@ -0,0 +1,71 @@
import { OmnichannelEEService } from '@rocket.chat/core-services';
import type { ILivechatVisitor, IMessage, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { isMessageFromVisitor, isEditedMessage } from '@rocket.chat/core-typings';
import { LivechatRooms, LivechatVisitors, Users } from '@rocket.chat/models';
import { callbacks } from '../../../../server/lib/callbacks';
import { i18n } from '../../../../server/lib/i18n';
import { callbackLogger } from '../../../../server/lib/omnichannel/logger';
const resumeOnHoldCommentAndUser = async (room: IOmnichannelRoom): Promise<{ comment: string; resumedBy: IUser }> => {
const {
v: { _id: visitorId },
_id: rid,
} = room;
const visitor = await LivechatVisitors.findOneEnabledById<Pick<ILivechatVisitor, 'name' | 'username'>>(visitorId, {
projection: { name: 1, username: 1 },
});
if (!visitor) {
callbackLogger.error({ msg: '[afterOmnichannelSaveMessage] Visitor Not found for room while trying to resume on hold', rid });
throw new Error('Visitor not found while trying to resume on hold');
}
const guest = visitor.name || visitor.username;
const resumeChatComment = i18n.t('Omnichannel_on_hold_chat_automatically', { guest });
const resumedBy = await Users.findOneById('rocket.cat');
if (!resumedBy) {
callbackLogger.error({ msg: '[afterOmnichannelSaveMessage] User Not found for room while trying to resume on hold', rid });
throw new Error(`User not found while trying to resume on hold`);
}
return { comment: resumeChatComment, resumedBy };
};
callbacks.add(
'afterOmnichannelSaveMessage',
async (message: IMessage, { room }) => {
if (isEditedMessage(message) || message.t) {
return message;
}
const { _id: rid, v: roomVisitor } = room;
if (!roomVisitor?._id) {
return message;
}
// Need to read the room every time, the room object is not updated
const updatedRoom = await LivechatRooms.findOneById(rid);
if (!updatedRoom) {
return message;
}
if (isMessageFromVisitor(message) && room.onHold) {
callbackLogger.debug({ msg: '[afterOmnichannelSaveMessage] Room is on hold, resuming it now since visitor sent a message', rid });
try {
const { comment: resumeChatComment, resumedBy } = await resumeOnHoldCommentAndUser(updatedRoom);
await OmnichannelEEService.resumeRoomOnHold(updatedRoom, resumeChatComment, resumedBy);
} catch (error) {
callbackLogger.error({ msg: '[afterOmnichannelSaveMessage] Error while resuming room on hold', rid, err: error });
return message;
}
}
return message;
},
callbacks.priority.HIGH,
'livechat-resume-on-hold',
);

View File

@@ -0,0 +1,79 @@
import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import type { CloseRoomParams } from '../../../../server/lib/omnichannel/localTypes';
import { settings } from '../../../../server/settings';
import { AutoTransferChatScheduler } from '../../lib/omnichannel/AutoTransferChatScheduler';
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
options: CloseRoomParams['options'];
};
let autoTransferTimeout = 0;
const handleAfterCloseRoom = async (params: LivechatCloseCallbackParams): Promise<LivechatCloseCallbackParams> => {
const { room } = params;
const { _id: rid, autoTransferredAt, autoTransferOngoing } = room;
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return params;
}
if (autoTransferredAt) {
return params;
}
if (!autoTransferOngoing) {
return params;
}
await AutoTransferChatScheduler.unscheduleRoom(rid);
return params;
};
settings.watch('Livechat_auto_transfer_chat_timeout', (value) => {
autoTransferTimeout = value as number;
if (!autoTransferTimeout || autoTransferTimeout === 0) {
callbacks.remove('afterOmnichannelSaveMessage', 'livechat-cancel-auto-transfer-job-after-message');
callbacks.remove('livechat.closeRoom', 'livechat-cancel-auto-transfer-on-close-room');
return;
}
callbacks.add(
'afterOmnichannelSaveMessage',
async (message: IMessage, { room }): Promise<IMessage> => {
const { _id: rid, autoTransferredAt, autoTransferOngoing } = room;
const { token, t: messageType } = message;
if (messageType) {
// ignore system messages
return message;
}
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return message;
}
if (!message || token) {
// ignore messages from visitors
return message;
}
if (autoTransferredAt) {
return message;
}
if (!autoTransferOngoing) {
return message;
}
await AutoTransferChatScheduler.unscheduleRoom(rid);
return message;
},
callbacks.priority.HIGH,
'livechat-cancel-auto-transfer-job-after-message',
);
callbacks.add('livechat.closeRoom', handleAfterCloseRoom, callbacks.priority.HIGH, 'livechat-cancel-auto-transfer-on-close-room');
});

View File

@@ -0,0 +1,32 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../server/lib/callbacks';
import type { CloseRoomParams } from '../../../../server/lib/omnichannel/localTypes';
import { requestPdfTranscript } from '../../lib/omnichannel/requestPdfTranscript';
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
options: CloseRoomParams['options'];
};
const sendPdfTranscriptOnClose = async (params: LivechatCloseCallbackParams): Promise<LivechatCloseCallbackParams> => {
const { room, options } = params;
if (!isOmnichannelRoom(room)) {
return params;
}
const { pdfTranscript } = options || {};
if (!pdfTranscript) {
return params;
}
const { requestedBy } = pdfTranscript;
await requestPdfTranscript(room, requestedBy);
return params;
};
callbacks.add('livechat.closeRoom', sendPdfTranscriptOnClose, callbacks.priority.HIGH, 'livechat-send-pdf-transcript-on-close-room');

View File

@@ -0,0 +1,46 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isEditedMessage, isMessageFromVisitor } from '@rocket.chat/core-typings';
import moment from 'moment';
import { markRoomResponded } from '../../../../server/hooks/omnichannel/markRoomResponded';
import { callbacks } from '../../../../server/lib/callbacks';
import { settings } from '../../../../server/settings';
import { setPredictedVisitorAbandonmentTime } from '../../lib/omnichannel/Helper';
function shouldSaveInactivity(message: IMessage): boolean {
if (message.t || isEditedMessage(message) || isMessageFromVisitor(message)) {
return false;
}
const abandonedRoomsAction = settings.get('Livechat_abandoned_rooms_action');
const visitorInactivityTimeout = settings.get<number>('Livechat_visitor_inactivity_timeout');
if (!abandonedRoomsAction || abandonedRoomsAction === 'none' || visitorInactivityTimeout <= 0) {
return false;
}
return true;
}
callbacks.remove('afterOmnichannelSaveMessage', 'markRoomResponded');
callbacks.add(
'afterOmnichannelSaveMessage',
async (message, { room, roomUpdater }) => {
const responseBy = await markRoomResponded(message, room, roomUpdater);
if (!shouldSaveInactivity(message)) {
return message;
}
if (!responseBy) {
return;
}
if (moment(responseBy.firstResponseTs).isSame(moment(message.ts))) {
await setPredictedVisitorAbandonmentTime({ ...room, responseBy }, roomUpdater);
}
},
callbacks.priority.MEDIUM,
'save-visitor-inactivity',
);