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:
22
apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts
Normal file
22
apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts
Normal 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);
|
||||
});
|
||||
2
apps/meteor/ee/server/hooks/abac/index.ts
Normal file
2
apps/meteor/ee/server/hooks/abac/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './beforeAddUserToRoom';
|
||||
import './scopeAdminRoomsForAbac';
|
||||
30
apps/meteor/ee/server/hooks/abac/scopeAdminRoomsForAbac.ts
Normal file
30
apps/meteor/ee/server/hooks/abac/scopeAdminRoomsForAbac.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
66
apps/meteor/ee/server/hooks/auth/callback.ts
Normal file
66
apps/meteor/ee/server/hooks/auth/callback.ts
Normal 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');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
398
apps/meteor/ee/server/hooks/federation/index.ts
Normal file
398
apps/meteor/ee/server/hooks/federation/index.ts
Normal 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',
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
14
apps/meteor/ee/server/hooks/messages/afterDeleteRoom.ts
Normal file
14
apps/meteor/ee/server/hooks/messages/afterDeleteRoom.ts
Normal 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',
|
||||
);
|
||||
25
apps/meteor/ee/server/hooks/messages/afterReadMessages.ts
Normal file
25
apps/meteor/ee/server/hooks/messages/afterReadMessages.ts
Normal 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',
|
||||
);
|
||||
21
apps/meteor/ee/server/hooks/messages/afterSaveMessage.ts
Normal file
21
apps/meteor/ee/server/hooks/messages/afterSaveMessage.ts
Normal 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',
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
36
apps/meteor/ee/server/hooks/omnichannel/afterOnHold.ts
Normal file
36
apps/meteor/ee/server/hooks/omnichannel/afterOnHold.ts
Normal 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');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
14
apps/meteor/ee/server/hooks/omnichannel/afterTagRemoved.ts
Normal file
14
apps/meteor/ee/server/hooks/omnichannel/afterTagRemoved.ts
Normal 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',
|
||||
);
|
||||
54
apps/meteor/ee/server/hooks/omnichannel/afterTakeInquiry.ts
Normal file
54
apps/meteor/ee/server/hooks/omnichannel/afterTakeInquiry.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
34
apps/meteor/ee/server/hooks/omnichannel/beforeJoinRoom.ts
Normal file
34
apps/meteor/ee/server/hooks/omnichannel/beforeJoinRoom.ts
Normal 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',
|
||||
);
|
||||
10
apps/meteor/ee/server/hooks/omnichannel/beforeListTags.ts
Normal file
10
apps/meteor/ee/server/hooks/omnichannel/beforeListTags.ts
Normal 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',
|
||||
);
|
||||
52
apps/meteor/ee/server/hooks/omnichannel/beforeNewInquiry.ts
Normal file
52
apps/meteor/ee/server/hooks/omnichannel/beforeNewInquiry.ts
Normal 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',
|
||||
);
|
||||
35
apps/meteor/ee/server/hooks/omnichannel/beforeNewRoom.ts
Normal file
35
apps/meteor/ee/server/hooks/omnichannel/beforeNewRoom.ts
Normal 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);
|
||||
73
apps/meteor/ee/server/hooks/omnichannel/beforeRoutingChat.ts
Normal file
73
apps/meteor/ee/server/hooks/omnichannel/beforeRoutingChat.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
});
|
||||
26
apps/meteor/ee/server/hooks/omnichannel/index.ts
Normal file
26
apps/meteor/ee/server/hooks/omnichannel/index.ts
Normal 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';
|
||||
@@ -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');
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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) }));
|
||||
39
apps/meteor/ee/server/hooks/omnichannel/onCloseLivechat.ts
Normal file
39
apps/meteor/ee/server/hooks/omnichannel/onCloseLivechat.ts
Normal 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',
|
||||
);
|
||||
34
apps/meteor/ee/server/hooks/omnichannel/onLoadConfigApi.ts
Normal file
34
apps/meteor/ee/server/hooks/omnichannel/onLoadConfigApi.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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',
|
||||
);
|
||||
53
apps/meteor/ee/server/hooks/omnichannel/onSaveVisitorInfo.ts
Normal file
53
apps/meteor/ee/server/hooks/omnichannel/onSaveVisitorInfo.ts
Normal 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',
|
||||
);
|
||||
85
apps/meteor/ee/server/hooks/omnichannel/onTransferFailure.ts
Normal file
85
apps/meteor/ee/server/hooks/omnichannel/onTransferFailure.ts
Normal 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);
|
||||
71
apps/meteor/ee/server/hooks/omnichannel/resumeOnHold.ts
Normal file
71
apps/meteor/ee/server/hooks/omnichannel/resumeOnHold.ts
Normal 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',
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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',
|
||||
);
|
||||
Reference in New Issue
Block a user