feat: Omnichannel MAC limitations (#30463)

pull/30493/head^2
Kevin Aleman 2 years ago committed by GitHub
parent 747ec6c70e
commit 5b9d6883bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      .changeset/seven-emus-pay.md
  2. 6
      apps/meteor/app/lib/server/methods/sendMessage.ts
  3. 9
      apps/meteor/app/livechat/server/api/v1/room.ts
  4. 9
      apps/meteor/app/livechat/server/api/v1/transcript.ts
  5. 11
      apps/meteor/app/livechat/server/api/v1/videoCall.ts
  6. 30
      apps/meteor/app/livechat/server/hooks/checkMAC.ts
  7. 1
      apps/meteor/app/livechat/server/index.ts
  8. 6
      apps/meteor/app/livechat/server/lib/Livechat.js
  9. 6
      apps/meteor/app/livechat/server/lib/LivechatTyped.ts
  10. 9
      apps/meteor/app/livechat/server/lib/QueueManager.ts
  11. 14
      apps/meteor/app/livechat/server/lib/RoutingManager.ts
  12. 5
      apps/meteor/app/livechat/server/methods/returnAsInquiry.ts
  13. 8
      apps/meteor/app/livechat/server/methods/takeInquiry.ts
  14. 5
      apps/meteor/app/livechat/server/methods/transfer.ts
  15. 2
      apps/meteor/client/contexts/OmnichannelContext.ts
  16. 6
      apps/meteor/client/hooks/omnichannel/useIsOverMacLimit.tsx
  17. 23
      apps/meteor/client/hooks/omnichannel/useIsRoomOverMacLimit.tsx
  18. 11
      apps/meteor/client/providers/OmnichannelProvider.tsx
  19. 4
      apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx
  20. 22
      apps/meteor/client/sidebar/badges/OmnichannelBadges.tsx
  21. 45
      apps/meteor/client/sidebar/sections/OmnichannelSection.tsx
  22. 21
      apps/meteor/client/sidebar/sections/OverMacLimitSection.tsx
  23. 21
      apps/meteor/client/views/omnichannel/currentChats/CurrentChatsPage.tsx
  24. 0
      apps/meteor/client/views/omnichannel/sidebarItems.tsx
  25. 12
      apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx
  26. 7
      apps/meteor/client/views/room/body/hooks/useFileUploadDropTarget.ts
  27. 12
      apps/meteor/client/views/room/composer/ComposerOmnichannel/ComposerOmnichannel.tsx
  28. 6
      apps/meteor/ee/app/license/server/startup.ts
  29. 20
      apps/meteor/ee/client/omnichannel/components/RoomActivityIcon/index.tsx
  30. 2
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.stories.tsx
  31. 9
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponse.tsx
  32. 4
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/CannedResponseList.tsx
  33. 5
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/Item.tsx
  34. 14
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/WrapCannedResponse.tsx
  35. 4
      apps/meteor/ee/client/omnichannel/components/contextualBar/CannedResponse/index.tsx
  36. 5
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  37. 1
      apps/meteor/packages/rocketchat-i18n/i18n/es.i18n.json
  38. 2
      apps/meteor/packages/rocketchat-i18n/i18n/pt-BR.i18n.json
  39. 8
      apps/meteor/server/modules/listeners/listeners.module.ts
  40. 11
      apps/meteor/server/services/omnichannel/queue.ts
  41. 28
      apps/meteor/server/services/omnichannel/service.ts
  42. 2
      apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts
  43. 69
      apps/meteor/tests/end-to-end/api/livechat/23-mac.ts
  44. 1
      ee/packages/ddp-client/src/types/streams.ts
  45. 2
      packages/core-services/src/Events.ts
  46. 3
      packages/core-services/src/types/IOmnichannelService.ts
  47. 1
      packages/core-typings/src/omnichannel/queue.ts

@ -0,0 +1,10 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/core-services": patch
"@rocket.chat/core-typings": patch
"@rocket.chat/rest-typings": patch
"@rocket.chat/ddp-client": patch
---
feat: Improve UI when MAC limits are reached
feat: Limit endpoints on MAC limit reached

@ -82,7 +82,7 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMe
const room = await canSendMessageAsync(rid, { uid, username: user.username, type: user.type });
metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736
return sendMessage(user, message, room, false, previewUrls);
return await sendMessage(user, message, room, false, previewUrls);
} catch (err: any) {
SystemLogger.error({ msg: 'Error sending message:', err });
@ -107,7 +107,7 @@ declare module '@rocket.chat/ui-contexts' {
}
Meteor.methods<ServerMethods>({
sendMessage(message, previewUrls) {
async sendMessage(message, previewUrls) {
check(message, Object);
const uid = Meteor.userId();
@ -118,7 +118,7 @@ Meteor.methods<ServerMethods>({
}
try {
return executeSendMessage(uid, message, previewUrls);
return await executeSendMessage(uid, message, previewUrls);
} catch (error: any) {
if ((error.error || error.message) === 'error-not-allowed') {
throw new Meteor.Error(error.error || error.message, error.reason, {

@ -1,3 +1,4 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { ILivechatAgent, IOmnichannelRoom, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users, LivechatRooms, Subscriptions, Messages } from '@rocket.chat/models';
@ -326,6 +327,10 @@ API.v1.addRoute(
throw new Error('This_conversation_is_already_closed');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
if (!guest) {
throw new Error('error-invalid-visitor');
@ -412,6 +417,10 @@ API.v1.addRoute(
throw new Error('error-invalid-room');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Error('error-not-allowed');
}

@ -1,3 +1,4 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms, Users } from '@rocket.chat/models';
import { isPOSTLivechatTranscriptParams, isPOSTLivechatTranscriptRequestParams } from '@rocket.chat/rest-typings';
@ -34,8 +35,8 @@ API.v1.addRoute(
{
async delete() {
const { rid } = this.urlParams;
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, 'open' | 'transcriptRequest'>>(rid, {
projection: { open: 1, transcriptRequest: 1 },
const room = await LivechatRooms.findOneById<Pick<IOmnichannelRoom, 'open' | 'transcriptRequest' | 'v'>>(rid, {
projection: { open: 1, transcriptRequest: 1, v: 1 },
});
if (!room?.open) {
@ -45,6 +46,10 @@ API.v1.addRoute(
throw new Error('error-transcript-not-requested');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid);
return API.v1.success();

@ -1,4 +1,5 @@
import { Message } from '@rocket.chat/core-services';
import { Message, Omnichannel } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Messages, Settings, Rooms } from '@rocket.chat/models';
import { isGETWebRTCCall, isPUTWebRTCCallId } from '@rocket.chat/rest-typings';
@ -27,6 +28,10 @@ API.v1.addRoute(
throw new Error('invalid-room');
}
if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) {
throw new Error('error-mac-limit-reached');
}
const webrtcCallingAllowed = rcSettings.get('WebRTC_Enabled') === true && rcSettings.get('Omnichannel_call_provider') === 'WebRTC';
if (!webrtcCallingAllowed) {
throw new Error('webRTC calling not enabled');
@ -79,6 +84,10 @@ API.v1.addRoute(
throw new Error('invalid-room');
}
if (!(await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom))) {
throw new Error('error-mac-limit-reached');
}
const call = await Messages.findOneById(callId);
if (!call || call.t !== 'livechat_webrtc_video_call') {
throw new Error('invalid-callId');

@ -0,0 +1,30 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { callbacks } from '../../../../lib/callbacks';
callbacks.add('beforeSaveMessage', async (message, room) => {
if (!room || room.t !== 'l') {
return message;
}
if (isEditedMessage(message)) {
return message;
}
if (message.token) {
return message;
}
if (message.t) {
return message;
}
const canSendMessage = await Omnichannel.isWithinMACLimit(room as IOmnichannelRoom);
if (!canSendMessage) {
throw new Error('error-mac-limit-reached');
}
return message;
});

@ -16,6 +16,7 @@ import './hooks/saveContactLastChat';
import './hooks/saveLastMessageToInquiry';
import './hooks/afterUserActions';
import './hooks/afterAgentRemoved';
import './hooks/checkMAC';
import './methods/addAgent';
import './methods/addManager';
import './methods/changeLivechatStatus';

@ -1,6 +1,6 @@
// Note: Please don't add any new methods to this file, since its still in js and we are migrating to ts
// Please add new methods to LivechatTyped.ts
import { Message } from '@rocket.chat/core-services';
import { Message, Omnichannel } from '@rocket.chat/core-services';
import { Logger } from '@rocket.chat/logger';
import {
LivechatVisitors,
@ -411,6 +411,10 @@ export const Livechat = {
throw new Meteor.Error('error-transcript-already-requested', 'Transcript already requested');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const { _id, username, name, utcOffset } = user;
const transcriptRequest = {
requestedAt: new Date(),

@ -1,7 +1,7 @@
import dns from 'dns';
import * as util from 'util';
import { Message, VideoConf, api } from '@rocket.chat/core-services';
import { Message, VideoConf, api, Omnichannel } from '@rocket.chat/core-services';
import type {
IOmnichannelRoom,
IOmnichannelRoomClosingInfo,
@ -521,6 +521,10 @@ class LivechatClass {
throw new Error('error-invalid-room');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const showAgentInfo = settings.get<string>('Livechat_show_agent_info');
const closingMessage = await Messages.findLivechatClosingMessage(rid, { projection: { ts: 1 } });
const ignoredMessageTypes: MessageTypesValues[] = [

@ -1,3 +1,4 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { ILivechatInquiryRecord, ILivechatVisitor, IMessage, IOmnichannelRoom, SelectedAgent } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
@ -20,6 +21,14 @@ export const queueInquiry = async (inquiry: ILivechatInquiryRecord, defaultAgent
logger.debug(`Delegating inquiry with id ${inquiry._id} to agent ${defaultAgent?.username}`);
await callbacks.run('livechat.beforeRouteChat', inquiry, inquiryAgent);
const room = await LivechatRooms.findOneById(inquiry.rid, { projection: { v: 1 } });
if (!room || !(await Omnichannel.isWithinMACLimit(room))) {
logger.error({ msg: 'MAC limit reached, not routing inquiry', inquiry });
// We'll queue these inquiries so when new license is applied, they just start rolling again
// Minimizing disruption
await saveQueueInquiry(inquiry);
return;
}
const dbInquiry = await LivechatInquiry.findOneById(inquiry._id);
if (!dbInquiry) {

@ -156,6 +156,11 @@ export const RoutingManager: Routing = {
await Promise.all([Message.saveSystemMessage('command', rid, 'connected', user), Message.saveSystemMessage('uj', rid, '', user)]);
}
if (!room) {
logger.debug(`Cannot assign agent to inquiry ${inquiry._id}: Room not found`);
throw new Meteor.Error('error-room-not-found', 'Room not found');
}
await dispatchAgentDelegated(rid, agent.agentId);
logger.debug(`Agent ${agent.agentId} assigned to inquriy ${inquiry._id}. Instances notified`);
@ -173,6 +178,10 @@ export const RoutingManager: Routing = {
return false;
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
if (departmentId && departmentId !== department) {
logger.debug(`Switching department for inquiry ${inquiry._id} [Current: ${department} | Next: ${departmentId}]`);
await updateChatDepartment({
@ -260,6 +269,11 @@ export const RoutingManager: Routing = {
},
async transferRoom(room, guest, transferData) {
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
logger.debug(`Transfering room ${room._id} by ${transferData.transferredBy._id}`);
if (transferData.departmentId) {
logger.debug(`Transfering room ${room._id} to department ${transferData.departmentId}`);
return forwardRoomToDepartment(room, guest, transferData);

@ -1,3 +1,4 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { ILivechatDepartment, IRoom } from '@rocket.chat/core-typings';
import { LivechatRooms } from '@rocket.chat/models';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
@ -29,6 +30,10 @@ Meteor.methods<ServerMethods>({
});
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:returnAsInquiry' });
}
if (!room.open) {
throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:returnAsInquiry' });
}

@ -1,4 +1,5 @@
import { LivechatInquiry, Users } from '@rocket.chat/models';
import { Omnichannel } from '@rocket.chat/core-services';
import { LivechatInquiry, LivechatRooms, Users } from '@rocket.chat/models';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
import { Meteor } from 'meteor/meteor';
@ -48,6 +49,11 @@ export const takeInquiry = async (
});
}
const room = await LivechatRooms.findOneById(inquiry.rid);
if (!room || !(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const agent = {
agentId: user._id,
username: user.username,

@ -1,3 +1,4 @@
import { Omnichannel } from '@rocket.chat/core-services';
import type { IUser } from '@rocket.chat/core-typings';
import { LivechatVisitors, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models';
import type { ServerMethods } from '@rocket.chat/ui-contexts';
@ -49,6 +50,10 @@ Meteor.methods<ServerMethods>({
throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:transfer' });
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Meteor.Error('error-mac-limit-reached', 'MAC limit reached', { method: 'livechat:transfer' });
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, uid, {
projection: { _id: 1 },
});

@ -8,6 +8,7 @@ export type OmnichannelContextValue = {
agentAvailable: boolean;
routeConfig?: OmichannelRoutingConfig;
showOmnichannelQueueLink: boolean;
isOverMacLimit: boolean;
livechatPriorities: {
data: Serialized<ILivechatPriority>[];
isLoading: boolean;
@ -22,6 +23,7 @@ export const OmnichannelContext = createContext<OmnichannelContextValue>({
isEnterprise: false,
agentAvailable: false,
showOmnichannelQueueLink: false,
isOverMacLimit: false,
livechatPriorities: {
data: [],
isLoading: false,

@ -0,0 +1,6 @@
import { useOmnichannel } from './useOmnichannel';
export const useIsOverMacLimit = (): boolean => {
const { isOverMacLimit } = useOmnichannel();
return isOverMacLimit;
};

@ -0,0 +1,23 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, type IOmnichannelGenericRoom, isVoipRoom } from '@rocket.chat/core-typings';
import { useIsOverMacLimit } from './useIsOverMacLimit';
const getPeriod = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
export const useIsRoomOverMacLimit = (room: IRoom) => {
const isOverMacLimit = useIsOverMacLimit();
if (!isOmnichannelRoom(room) && !isVoipRoom(room)) {
return false;
}
if (!room.open) {
return false;
}
const { v: { activity = [] } = {} } = room as IOmnichannelGenericRoom;
const currentPeriod = getPeriod(new Date());
return isOverMacLimit && !activity.includes(currentPeriod);
};

@ -19,6 +19,7 @@ import { useHasLicenseModule } from '../../ee/client/hooks/useHasLicenseModule';
import { ClientLogger } from '../../lib/ClientLogger';
import type { OmnichannelContextValue } from '../contexts/OmnichannelContext';
import { OmnichannelContext } from '../contexts/OmnichannelContext';
import { useLicense } from '../hooks/useLicense';
import { useReactiveValue } from '../hooks/useReactiveValue';
const emptyContextValue: OmnichannelContextValue = {
@ -27,6 +28,7 @@ const emptyContextValue: OmnichannelContextValue = {
isEnterprise: false,
agentAvailable: false,
showOmnichannelQueueLink: false,
isOverMacLimit: false,
livechatPriorities: {
enabled: false,
data: [],
@ -63,6 +65,7 @@ const OmnichannelProvider: FC = ({ children }) => {
const subscribe = useStream('notify-logged');
const queryClient = useQueryClient();
const isPrioritiesEnabled = isEnterprise && accessible;
const enabled = accessible && !!user && !!routeConfig;
const {
data: { priorities = [] } = {},
@ -73,6 +76,10 @@ const OmnichannelProvider: FC = ({ children }) => {
enabled: isPrioritiesEnabled,
});
const { data: { preventedActions } = {} } = useLicense();
const isOverMacLimit = Boolean(preventedActions?.monthlyActiveContacts);
useEffect(() => {
if (!isPrioritiesEnabled) {
return;
@ -102,7 +109,6 @@ const OmnichannelProvider: FC = ({ children }) => {
}
}, [accessible, getRoutingConfig, iceServersSetting, omnichannelRouting, setRouteConfig, voipCallAvailable]);
const enabled = accessible && !!user && !!routeConfig;
const manuallySelected =
enabled && canViewOmnichannelQueue && !!routeConfig && routeConfig.showQueue && !routeConfig.autoAssignAgent && agentAvailable;
@ -167,6 +173,7 @@ const OmnichannelProvider: FC = ({ children }) => {
voipCallAvailable,
routeConfig,
livechatPriorities,
isOverMacLimit,
};
}
@ -185,6 +192,7 @@ const OmnichannelProvider: FC = ({ children }) => {
: { enabled: false },
showOmnichannelQueueLink: showOmnichannelQueueLink && !!agentAvailable,
livechatPriorities,
isOverMacLimit,
};
}, [
enabled,
@ -199,6 +207,7 @@ const OmnichannelProvider: FC = ({ children }) => {
routeConfig,
queue,
showOmnichannelQueueLink,
isOverMacLimit,
]);
return <OmnichannelContext.Provider children={children} value={contextValue} />;

@ -7,10 +7,10 @@ import type { AllHTMLAttributes, ComponentType, ReactElement, ReactNode } from '
import React, { memo, useMemo } from 'react';
import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities';
import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon';
import { RoomIcon } from '../../components/RoomIcon';
import { roomCoordinator } from '../../lib/rooms/roomCoordinator';
import RoomMenu from '../RoomMenu';
import { OmnichannelBadges } from '../badges/OmnichannelBadges';
import type { useAvatarTemplate } from '../hooks/useAvatarTemplate';
import { normalizeSidebarMessage } from './normalizeSidebarMessage';
@ -170,7 +170,7 @@ function SideBarItemTemplateWithData({
{unread + tunread?.length}
</Badge>
)}
{isOmnichannelRoom(room) && isPriorityEnabled && <PriorityIcon level={room.priorityWeight} />}
{isOmnichannelRoom(room) && <OmnichannelBadges room={room} />}
</Margins>
);

@ -0,0 +1,22 @@
import type { IRoom, ISubscription } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import React from 'react';
import { RoomActivityIcon } from '../../../ee/client/omnichannel/components/RoomActivityIcon';
import { useOmnichannelPriorities } from '../../../ee/client/omnichannel/hooks/useOmnichannelPriorities';
import { PriorityIcon } from '../../../ee/client/omnichannel/priorities/PriorityIcon';
export const OmnichannelBadges = ({ room }: { room: ISubscription & IRoom }) => {
const { enabled: isPriorityEnabled } = useOmnichannelPriorities();
if (!isOmnichannelRoom(room)) {
return null;
}
return (
<>
{isPriorityEnabled ? <PriorityIcon level={room.priorityWeight} /> : null}
<RoomActivityIcon room={room} />
</>
);
};

@ -4,7 +4,9 @@ import { useLayout, useRoute, usePermission, useTranslation } from '@rocket.chat
import React, { memo } from 'react';
import { useIsCallEnabled, useIsCallReady } from '../../contexts/CallContext';
import { useIsOverMacLimit } from '../../hooks/omnichannel/useIsOverMacLimit';
import { useOmnichannelShowQueueLink } from '../../hooks/omnichannel/useOmnichannelShowQueueLink';
import { OverMacLimitSection } from './OverMacLimitSection';
import { OmniChannelCallDialPad, OmnichannelCallToggle, OmnichannelLivechatToggle } from './actions';
const OmnichannelSection = () => {
@ -16,6 +18,7 @@ const OmnichannelSection = () => {
const { sidebar } = useLayout();
const directoryRoute = useRoute('omnichannel-directory');
const queueListRoute = useRoute('livechat-queue');
const isWorkspaceOverMacLimit = useIsOverMacLimit();
const handleRoute = useMutableCallback((route) => {
sidebar.toggle();
@ -32,25 +35,29 @@ const OmnichannelSection = () => {
// The className is a paliative while we make TopBar.ToolBox optional on fuselage
return (
<Sidebar.TopBar.ToolBox className='omnichannel-sidebar'>
<Sidebar.TopBar.Title>{t('Omnichannel')}</Sidebar.TopBar.Title>
<Sidebar.TopBar.Actions>
{showOmnichannelQueueLink && (
<Sidebar.TopBar.Action icon='queue' data-tooltip={t('Queue')} onClick={(): void => handleRoute('queue')} />
)}
{isCallEnabled && <OmnichannelCallToggle />}
<OmnichannelLivechatToggle />
{hasPermissionToSeeContactCenter && (
<Sidebar.TopBar.Action
data-tooltip={t('Contact_Center')}
aria-label={t('Contact_Center')}
icon='address-book'
onClick={(): void => handleRoute('directory')}
/>
)}
{isCallReady && <OmniChannelCallDialPad />}
</Sidebar.TopBar.Actions>
</Sidebar.TopBar.ToolBox>
<>
{isWorkspaceOverMacLimit && <OverMacLimitSection />}
<Sidebar.TopBar.ToolBox className='omnichannel-sidebar'>
<Sidebar.TopBar.Title>{t('Omnichannel')}</Sidebar.TopBar.Title>
<Sidebar.TopBar.Actions>
{showOmnichannelQueueLink && (
<Sidebar.TopBar.Action icon='queue' data-tooltip={t('Queue')} onClick={(): void => handleRoute('queue')} />
)}
{isCallEnabled && <OmnichannelCallToggle />}
<OmnichannelLivechatToggle />
{hasPermissionToSeeContactCenter && (
<Sidebar.TopBar.Action
data-tooltip={t('Contact_Center')}
aria-label={t('Contact_Center')}
icon='address-book'
onClick={(): void => handleRoute('directory')}
/>
)}
{isCallReady && <OmniChannelCallDialPad />}
</Sidebar.TopBar.Actions>
</Sidebar.TopBar.ToolBox>
</>
);
};

@ -0,0 +1,21 @@
import { Icon, SidebarBanner } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
export const OverMacLimitSection = (): ReactElement => {
const t = useTranslation();
const handleClick = () => {
window.open('https://rocket.chat/pricing', '_blank');
};
return (
<SidebarBanner
text={t('You_have_reached_the_limit_active_costumers_this_month')}
description={t('Learn_more')}
addon={<Icon name='warning' color='danger' size='x24' />}
onClick={handleClick}
/>
);
};

@ -1,4 +1,4 @@
import { Pagination } from '@rocket.chat/fuselage';
import { Banner, Icon, Pagination } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import type { GETLivechatRoomsParams } from '@rocket.chat/rest-typings';
import { usePermission, useTranslation } from '@rocket.chat/ui-contexts';
@ -7,6 +7,7 @@ import moment from 'moment';
import type { ComponentProps, ReactElement } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { RoomActivityIcon } from '../../../../ee/client/omnichannel/components/RoomActivityIcon';
import { useOmnichannelPriorities } from '../../../../ee/client/omnichannel/hooks/useOmnichannelPriorities';
import { PriorityIcon } from '../../../../ee/client/omnichannel/priorities/PriorityIcon';
import GenericNoResults from '../../../components/GenericNoResults';
@ -22,6 +23,7 @@ import {
import { usePagination } from '../../../components/GenericTable/hooks/usePagination';
import { useSort } from '../../../components/GenericTable/hooks/useSort';
import Page from '../../../components/Page';
import { useIsOverMacLimit } from '../../../hooks/omnichannel/useIsOverMacLimit';
import CustomFieldsList from './CustomFieldsList';
import FilterByText from './FilterByText';
import RemoveChatButton from './RemoveChatButton';
@ -118,6 +120,7 @@ const currentChatQuery: useQueryType = (
};
const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: string) => void }): ReactElement => {
const isWorkspaceOverMacLimit = useIsOverMacLimit();
const { sortBy, sortDirection, setSort } = useSort<'fname' | 'departmentId' | 'servedBy' | 'priorityWeight' | 'ts' | 'lm' | 'open'>(
'ts',
'desc',
@ -165,7 +168,8 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s
});
const renderRow = useCallback(
({ _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight }) => {
(room) => {
const { _id, fname, servedBy, ts, lm, department, open, onHold, priorityWeight } = room;
const getStatusText = (open: boolean, onHold: boolean): string => {
if (!open) return t('Closed');
return onHold ? t('On_Hold_Chats') : t('Open');
@ -194,7 +198,7 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s
{moment(lm).format('L LTS')}
</GenericTableCell>
<GenericTableCell withTruncatedText data-qa='current-chats-cell-status'>
{getStatusText(open, onHold)}
<RoomActivityIcon room={room} /> {getStatusText(open, onHold)}
</GenericTableCell>
{canRemoveClosedChats && !open && <RemoveChatButton _id={_id} />}
</GenericTableRow>
@ -301,6 +305,17 @@ const CurrentChatsPage = ({ id, onRowClick }: { id?: string; onRowClick: (_id: s
hasCustomFields={hasCustomFields}
/>
)}
{isWorkspaceOverMacLimit && (
<Banner
variant='danger'
actionable
icon={<Icon name='warning' size='x24' />}
title={t('The_workspace_has_exceeded_the_monthly_limit_of_active_contacts')}
style={{ marginBlock: '2rem' }}
>
{t('Talk_to_your_workspace_admin_to_address_this_issue')}
</Banner>
)}
{isSuccess && data?.rooms.length === 0 && queryHasChanged && <GenericNoResults />}
{isSuccess && data?.rooms.length === 0 && !queryHasChanged && (
<GenericNoResults

@ -22,6 +22,7 @@ import CloseChatModalData from '../../../../../../components/Omnichannel/modals/
import ForwardChatModal from '../../../../../../components/Omnichannel/modals/ForwardChatModal';
import ReturnChatQueueModal from '../../../../../../components/Omnichannel/modals/ReturnChatQueueModal';
import TranscriptModal from '../../../../../../components/Omnichannel/modals/TranscriptModal';
import { useIsRoomOverMacLimit } from '../../../../../../hooks/omnichannel/useIsRoomOverMacLimit';
import { useOmnichannelRouteConfig } from '../../../../../../hooks/omnichannel/useOmnichannelRouteConfig';
import { quickActionHooks } from '../../../../../../ui';
import { useOmnichannelRoom } from '../../../../contexts/RoomContext';
@ -311,19 +312,20 @@ export const useQuickActions = (): {
const canCloseRoom = usePermission('close-livechat-room');
const canCloseOthersRoom = usePermission('close-others-livechat-room');
const canPlaceChatOnHold = Boolean(!room.onHold && room.u && !(room as any).lastMessage?.token && manualOnHoldAllowed);
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
const hasPermissionButtons = (id: string): boolean => {
switch (id) {
case QuickActionsEnum.MoveQueue:
return !!roomOpen && canMoveQueue;
return !isRoomOverMacLimit && !!roomOpen && canMoveQueue;
case QuickActionsEnum.ChatForward:
return !!roomOpen && canForwardGuest;
return !isRoomOverMacLimit && !!roomOpen && canForwardGuest;
case QuickActionsEnum.Transcript:
return canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF);
return !isRoomOverMacLimit && (canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF));
case QuickActionsEnum.TranscriptEmail:
return canSendTranscriptEmail;
return !isRoomOverMacLimit && canSendTranscriptEmail;
case QuickActionsEnum.TranscriptPDF:
return hasLicense && canSendTranscriptPDF;
return hasLicense && !isRoomOverMacLimit && canSendTranscriptPDF;
case QuickActionsEnum.CloseChat:
return !!roomOpen && (canCloseRoom || canCloseOthersRoom);
case QuickActionsEnum.OnHoldChat:

@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit';
import { useReactiveValue } from '../../../../hooks/useReactiveValue';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { useChat } from '../../contexts/ChatContext';
@ -24,6 +25,8 @@ export const useFileUploadDropTarget = (): readonly [
const room = useRoom();
const { triggerProps, overlayProps } = useDropTarget();
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
const t = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled') as boolean;
@ -46,7 +49,7 @@ export const useFileUploadDropTarget = (): readonly [
});
const allOverlayProps = useMemo(() => {
if (!fileUploadEnabled) {
if (!fileUploadEnabled || isRoomOverMacLimit) {
return {
enabled: false,
reason: t('FileUpload_Disabled'),
@ -67,7 +70,7 @@ export const useFileUploadDropTarget = (): readonly [
onFileDrop,
...overlayProps,
} as const;
}, [fileUploadAllowedForUser, fileUploadEnabled, onFileDrop, overlayProps, t]);
}, [fileUploadAllowedForUser, fileUploadEnabled, isRoomOverMacLimit, onFileDrop, overlayProps, t]);
return [triggerProps, allOverlayProps] as const;
};

@ -3,6 +3,7 @@ import { useTranslation, useUserId } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import { useIsRoomOverMacLimit } from '../../../../hooks/omnichannel/useIsRoomOverMacLimit';
import { useOmnichannelRoom, useUserIsSubscribed } from '../../contexts/RoomContext';
import type { ComposerMessageProps } from '../ComposerMessage';
import ComposerMessage from '../ComposerMessage';
@ -11,7 +12,8 @@ import { ComposerOmnichannelJoin } from './ComposerOmnichannelJoin';
import { ComposerOmnichannelOnHold } from './ComposerOmnichannelOnHold';
const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => {
const { servedBy, queuedAt, open, onHold } = useOmnichannelRoom();
const room = useOmnichannelRoom();
const { servedBy, queuedAt, open, onHold } = room;
const userId = useUserId();
const isSubscribed = useUserIsSubscribed();
@ -22,8 +24,14 @@ const ComposerOmnichannel = (props: ComposerMessageProps): ReactElement => {
const isSameAgent = servedBy?._id === userId;
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
if (!open) {
return <MessageFooterCallout>{t('This_conversation_is_already_closed')}</MessageFooterCallout>;
return <MessageFooterCallout color='default'>{t('This_conversation_is_already_closed')}</MessageFooterCallout>;
}
if (isRoomOverMacLimit) {
return <MessageFooterCallout color='default'>{t('Workspace_exceeded_MAC_limit_disclaimer')}</MessageFooterCallout>;
}
if (onHold) {

@ -1,8 +1,9 @@
import { api } from '@rocket.chat/core-services';
import type { LicenseLimitKind } from '@rocket.chat/license';
import { License } from '@rocket.chat/license';
import { Subscriptions, Users, Settings } from '@rocket.chat/models';
import { Subscriptions, Users, Settings, LivechatVisitors } from '@rocket.chat/models';
import { wrapExceptions } from '@rocket.chat/tools';
import moment from 'moment';
import { syncWorkspace } from '../../../../app/cloud/server/functions/syncWorkspace';
import { settings } from '../../../../app/settings/server';
@ -122,5 +123,4 @@ License.setLicenseLimitCounter('guestUsers', () => Users.getActiveLocalGuestCoun
License.setLicenseLimitCounter('roomsPerGuest', async (context) => (context?.userId ? Subscriptions.countByUserId(context.userId) : 0));
License.setLicenseLimitCounter('privateApps', () => getAppCount('private'));
License.setLicenseLimitCounter('marketplaceApps', () => getAppCount('marketplace'));
// #TODO: Get real value
License.setLicenseLimitCounter('monthlyActiveContacts', async () => 0);
License.setLicenseLimitCounter('monthlyActiveContacts', async () => LivechatVisitors.countVisitorsOnPeriod(moment.utc().format('YYYY-MM')));

@ -0,0 +1,20 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Icon } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import { useIsRoomOverMacLimit } from '../../../../../client/hooks/omnichannel/useIsRoomOverMacLimit';
type RoomActivityIconProps = {
room: IOmnichannelRoom;
};
export const RoomActivityIcon = ({ room }: RoomActivityIconProps): ReactElement | null => {
const t = useTranslation();
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
return isRoomOverMacLimit ? (
<Icon name='warning' verticalAlign='middle' size='x20' color='danger' title={t('Workspace_exceeded_MAC_limit_disclaimer')} />
) : null;
};

@ -17,7 +17,7 @@ export default {
export const Default: ComponentStory<typeof CannedResponse> = (args) => <CannedResponse {...args} />;
Default.storyName = 'CannedResponse';
Default.args = {
canEdit: true,
allowEdit: true,
data: {
shortcut: 'test3 long long long long long long long long long',
text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long',

@ -15,7 +15,8 @@ import {
import { useScopeDict } from '../../../hooks/useScopeDict';
const CannedResponse: FC<{
canEdit: boolean;
allowEdit: boolean;
allowUse: boolean;
data: {
departmentName: ILivechatDepartment['name'];
shortcut: IOmnichannelCannedResponse['shortcut'];
@ -26,7 +27,7 @@ const CannedResponse: FC<{
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
onClickEdit: MouseEventHandler<HTMLOrSVGElement>;
onClickUse: MouseEventHandler<HTMLOrSVGElement>;
}> = ({ canEdit, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => {
}> = ({ allowEdit, allowUse, data: { departmentName, shortcut, text, scope: dataScope, tags }, onClickBack, onClickEdit, onClickUse }) => {
const t = useTranslation();
const scope = useScopeDict(dataScope, departmentName);
@ -84,8 +85,8 @@ const CannedResponse: FC<{
</ContextualbarContent>
<ContextualbarFooter>
<ButtonGroup stretch>
{canEdit && <Button onClick={onClickEdit}>{t('Edit')}</Button>}
<Button primary onClick={onClickUse}>
{allowEdit && <Button onClick={onClickEdit}>{t('Edit')}</Button>}
<Button primary disabled={!allowUse} onClick={onClickUse}>
{t('Use')}
</Button>
</ButtonGroup>

@ -30,6 +30,7 @@ const CannedResponseList: FC<{
setText: FormEventHandler<HTMLOrSVGElement>;
type: string;
setType: Dispatch<SetStateAction<string>>;
isRoomOverMacLimit: boolean;
onClickItem: (data: any) => void;
onClickCreate: (e: MouseEvent<HTMLOrSVGElement>) => void;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
@ -45,6 +46,7 @@ const CannedResponseList: FC<{
setText,
type,
setType,
isRoomOverMacLimit,
onClickItem,
onClickCreate,
onClickUse,
@ -98,6 +100,7 @@ const CannedResponseList: FC<{
itemContent={(_index, data): ReactElement => (
<Item
data={data}
allowUse={!isRoomOverMacLimit}
onClickItem={(): void => {
onClickItem(data);
}}
@ -112,6 +115,7 @@ const CannedResponseList: FC<{
{cannedId && (
<ContextualbarInnerContent>
<WrapCannedResponse
allowUse={!isRoomOverMacLimit}
cannedItem={cannedItems.find((canned) => canned._id === (cannedId as unknown))}
onClickBack={onClickItem}
onClickUse={onClickUse}

@ -9,9 +9,10 @@ import { useScopeDict } from '../../../hooks/useScopeDict';
const Item: FC<{
data: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] };
allowUse?: boolean;
onClickItem: (e: MouseEvent<HTMLOrSVGElement>) => void;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
}> = ({ data, onClickItem, onClickUse }) => {
}> = ({ data, allowUse, onClickItem, onClickUse }) => {
const t = useTranslation();
const scope = useScopeDict(data.scope, data.departmentName);
@ -47,7 +48,7 @@ const Item: FC<{
</Box>
<Box display='flex' flexDirection='row' alignItems='center'>
<Button
display={visibility ? 'block' : 'none'}
display={visibility && allowUse ? 'block' : 'none'}
small
onClick={(e): void => {
onClickUse(e, data.text);

@ -6,11 +6,18 @@ import CreateCannedResponse from '../../CannedResponse/modals';
import CannedResponse from './CannedResponse';
const WrapCannedResponse: FC<{
allowUse: boolean;
cannedItem: any;
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
reload: () => void;
}> = ({ cannedItem: { _id, departmentName, departmentId, shortcut, tags, scope, text }, onClickBack, onClickUse, reload }) => {
}> = ({
allowUse,
cannedItem: { _id, departmentName, departmentId, shortcut, tags, scope, text } = {},
onClickBack,
onClickUse,
reload,
}) => {
const setModal = useSetModal();
const onClickEdit = (): void => {
setModal(<CreateCannedResponse data={{ _id, departmentId, shortcut, tags, scope, text }} reloadCannedList={reload} />);
@ -19,11 +26,12 @@ const WrapCannedResponse: FC<{
const hasManagerPermission = usePermission('view-all-canned-responses');
const hasMonitorPermission = usePermission('save-department-canned-responses');
const canEdit = hasManagerPermission || (hasMonitorPermission && scope !== 'global') || scope === 'user';
const allowEdit = hasManagerPermission || (hasMonitorPermission && scope !== 'global') || scope === 'user';
return (
<CannedResponse
canEdit={canEdit}
allowEdit={allowEdit}
allowUse={allowUse}
data={{
departmentName,
shortcut,

@ -4,6 +4,7 @@ import type { MouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useRecordList } from '../../../../../../client/hooks/lists/useRecordList';
import { useIsRoomOverMacLimit } from '../../../../../../client/hooks/omnichannel/useIsRoomOverMacLimit';
import { AsyncStatePhase } from '../../../../../../client/lib/asyncState';
import { useChat } from '../../../../../../client/views/room/contexts/ChatContext';
import { useRoom } from '../../../../../../client/views/room/contexts/RoomContext';
@ -24,6 +25,8 @@ export const WrapCannedResponseList = () => {
const [text, setText] = useState('');
const [type, setType] = useLocalStorage('canned-response-list-type', 'all');
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
const handleTextChange = useCallback((event) => {
setText(event.currentTarget.value);
}, []);
@ -74,6 +77,7 @@ export const WrapCannedResponseList = () => {
setText={handleTextChange}
type={type}
setType={setType}
isRoomOverMacLimit={isRoomOverMacLimit}
onClickUse={onClickUse}
onClickItem={onClickItem}
onClickCreate={onClickCreate}

@ -2045,6 +2045,7 @@
"error-max-guests-number-reached": "You reached the maximum number of guest users allowed by your license. Contact sale@rocket.chat for a new license.",
"error-max-number-simultaneous-chats-reached": "The maximum number of simultaneous chats per agent has been reached.",
"error-max-rooms-per-guest-reached": "The maximum number of rooms per guest has been reached.",
"error-mac-limit-reached": "The maximum number of monthly active contacts for this workspace has been reached.",
"error-message-deleting-blocked": "Message deleting is blocked",
"error-message-editing-blocked": "Message editing is blocked",
"error-message-size-exceeded": "Message size exceeds Message_MaxAllowedSize",
@ -2112,8 +2113,10 @@
"error-duplicated-sla": "An SLA with the same name or due time already exists",
"error-contact-sent-last-message-so-cannot-place-on-hold": "You cannot place chat on-hold, when the Contact has sent the last message",
"error-unserved-rooms-cannot-be-placed-onhold": "Room cannot be placed on hold before being served",
"Workspace_exceeded_MAC_limit_disclaimer": "The workspace has exceeded the monthly limit of active contacts. Talk to your workspace admin to address this issue.",
"You_do_not_have_permission_to_do_this": "You do not have permission to do this",
"You_do_not_have_permission_to_execute_this_command": "You do not have enough permissions to execute command: `/{{command}}`",
"You_have_reached_the_limit_active_costumers_this_month": "You have reached the limit of active customers this month",
"Errors_and_Warnings": "Errors and Warnings",
"Esc_to": "Esc to",
"Estimated_wait_time": "Estimated wait time",
@ -4946,6 +4949,7 @@
"Talk_to_an_expert": "Talk to an expert",
"Talk_to_sales": "Talk to sales",
"Talk_to_your_workspace_administrator_about_enabling_video_conferencing": "Talk to your workspace administrator about enabling video conferencing",
"Talk_to_your_workspace_admin_to_address_this_issue": "Talk to your workspace admin to address this issue.",
"Target user not allowed to receive messages": "Target user not allowed to receive messages",
"TargetRoom": "Target Room",
"TargetRoom_Description": "The room where messages will be sent which are a result of this event being fired. Only one target room is allowed and it must exist.",
@ -5043,6 +5047,7 @@
"The_user_s_will_be_removed_from_role_s": "The user %s will be removed from role %s",
"The_user_will_be_removed_from_s": "The user will be removed from %s",
"The_user_wont_be_able_to_type_in_s": "The user won't be able to type in %s",
"The_workspace_has_exceeded_the_monthly_limit_of_active_contacts": "The workspace has exceeded the monthly limit of active contacts.",
"Theme": "Theme",
"Themes": "Themes",
"Choose_theme_description": "Choose the interface appearance that best suits your needs.",

@ -1719,6 +1719,7 @@
"error-logged-user-not-in-room": "No estás en la sala \"%s\"",
"error-max-guests-number-reached": "Has alcanzado la cantidad máxima de usuarios invitados que permite tu licencia. Escribe a sale@rocket.chat para obtener una nueva licencia.",
"error-max-number-simultaneous-chats-reached": "Se ha alcanzado el máximo de chats simultáneos por agente.",
"error-mac-limit-reached": "Se ha alcanzado el máximo de contactos activos por mes para este espacio de trabajo.",
"error-message-deleting-blocked": "La eliminación de mensajes está bloqueada",
"error-message-editing-blocked": "La edición de mensajes está bloqueada",
"error-message-size-exceeded": "El tamaño de mensaje excede el máximo: Message_MaxAllowedSize",

@ -4118,6 +4118,7 @@
"Take_it": "Pegue!",
"Taken_at": "Atribuído em",
"Talk_Time": "Tempo de conversa",
"Talk_to_your_workspace_admin_to_address_this_issue": "Converse com o admin do seu workspace para resolver esse problema",
"Target user not allowed to receive messages": "Usuário de destino não autorizado a receber mensagens",
"TargetRoom": "Sala de destino",
"TargetRoom_Description": "A sala à qual as mensagens serão enviadas, que é o resultado de disparar este evento. Somente uma sala de destino é permitida e deve existir.",
@ -4210,6 +4211,7 @@
"The_user_s_will_be_removed_from_role_s": "O usuário %s será removido da função %s",
"The_user_will_be_removed_from_s": "O usuário será removido de %s",
"The_user_wont_be_able_to_type_in_s": "O usuário não poderá escrever em %s",
"The_workspace_has_exceeded_the_monthly_limit_of_active_contacts": "O workspace excedeu o limite mensal de contatos ativos",
"Theme": "Tema",
"theme-color-attention-color": "Cor de atenção",
"theme-color-component-color": "Cor do componente",

@ -474,5 +474,13 @@ export class ListenersModule {
notifications.streamApps.emitWithoutBroadcast('actions/changed');
notifications.streamApps.emitWithoutBroadcast('apps', ['actions/changed', []]);
});
service.onEvent('mac.limitReached', () => {
notifications.notifyLoggedInThisInstance('mac.limit', { limitReached: true });
});
service.onEvent('mac.limitRestored', () => {
notifications.notifyLoggedInThisInstance('mac.limit', { limitReached: false });
});
}
}

@ -1,4 +1,5 @@
import type { InquiryWithAgentInfo, IOmnichannelQueue } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { LivechatInquiry } from '@rocket.chat/models';
import { dispatchAgentDelegated } from '../../../app/livechat/server/lib/Helper';
@ -19,6 +20,10 @@ export class OmnichannelQueue implements IOmnichannelQueue {
return timeout < 1 ? DEFAULT_RACE_TIMEOUT : timeout * 1000;
}
public isRunning() {
return this.running;
}
async start() {
if (this.running) {
return;
@ -94,12 +99,16 @@ export class OmnichannelQueue implements IOmnichannelQueue {
}
}
shouldStart() {
async shouldStart() {
if (!settings.get('Livechat_enabled')) {
void this.stop();
return;
}
if (await License.shouldPreventAction('monthlyActiveContacts')) {
return;
}
const routingSupportsAutoAssign = RoutingManager.getConfig()?.autoAssignAgent;
queueLogger.debug({
msg: 'Routing method supports auto assignment',

@ -1,6 +1,8 @@
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { IOmnichannelService } from '@rocket.chat/core-services';
import type { IOmnichannelQueue } from '@rocket.chat/core-typings';
import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import moment from 'moment';
import { Livechat } from '../../../app/livechat/server/lib/LivechatTyped';
import { RoutingManager } from '../../../app/livechat/server/lib/RoutingManager';
@ -12,6 +14,8 @@ export class OmnichannelService extends ServiceClassInternal implements IOmnicha
private queueWorker: IOmnichannelQueue;
private macLimitReached = false;
constructor() {
super();
this.queueWorker = new OmnichannelQueue();
@ -34,9 +38,31 @@ export class OmnichannelService extends ServiceClassInternal implements IOmnicha
settings.watch<boolean>('Livechat_enabled', (enabled) => {
void (enabled && RoutingManager.isMethodSet() ? this.queueWorker.shouldStart() : this.queueWorker.stop());
});
License.onLimitReached('monthlyActiveContacts', async (): Promise<void> => {
if (this.macLimitReached) {
// Dupe events
return;
}
this.macLimitReached = true;
void this.api?.broadcast('mac.limitReached');
this.queueWorker.isRunning() && (await this.queueWorker.stop());
});
License.onValidateLicense(async (): Promise<void> => {
this.macLimitReached = false;
void this.api?.broadcast('mac.limitRestored');
RoutingManager.isMethodSet() && (await this.queueWorker.shouldStart());
});
}
getQueueWorker(): IOmnichannelQueue {
return this.queueWorker;
}
async isWithinMACLimit(room: AtLeast<IOmnichannelRoom, 'v'>): Promise<boolean> {
const currentMonth = moment.utc().format('YYYY-MM');
return room.v?.activity?.includes(currentMonth) || !(await License.shouldPreventAction('monthlyActiveContacts'));
}
}

@ -196,13 +196,11 @@ describe('LIVECHAT - dashboards', function () {
});
const minMessages = TOTAL_MESSAGES.min * TOTAL_ROOMS;
const maxMessages = TOTAL_MESSAGES.max * TOTAL_ROOMS;
const totalMessages = result.body.totalizers.find((item: any) => item.title === 'Total_messages');
expect(totalMessages).to.not.be.undefined;
const totalMessagesValue = parseInt(totalMessages.value);
expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages);
expect(totalMessagesValue).to.be.lessThanOrEqual(maxMessages);
});
});

@ -0,0 +1,69 @@
import type { ILivechatVisitor } from '@rocket.chat/core-typings';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import moment from 'moment';
import { api, getCredentials, request, credentials } from '../../../data/api-data';
import {
createVisitor,
createLivechatRoom,
createAgent,
makeAgentAvailable,
sendAgentMessage,
getLivechatRoomInfo,
} from '../../../data/livechat/rooms';
import { IS_EE } from '../../../e2e/config/constants';
(IS_EE ? describe : describe.skip)('MAC', () => {
before((done) => getCredentials(done));
before(async () => {
await createAgent();
await makeAgentAvailable();
});
describe('MAC rooms', () => {
let visitor: ILivechatVisitor;
it('Should create an innactive room by default', async () => {
const visitor = await createVisitor();
const room = await createLivechatRoom(visitor.token);
expect(room).to.be.an('object');
expect(room.v.activity).to.be.undefined;
});
it('should mark room as active when agent sends a message', async () => {
visitor = await createVisitor();
const room = await createLivechatRoom(visitor.token);
await sendAgentMessage(room._id);
const updatedRoom = await getLivechatRoomInfo(room._id);
expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array');
});
it('should mark multiple rooms as active when they come from same visitor', async () => {
const room = await createLivechatRoom(visitor.token);
await sendAgentMessage(room._id);
const updatedRoom = await getLivechatRoomInfo(room._id);
expect(updatedRoom).to.have.nested.property('v.activity').and.to.be.an('array');
});
it('visitor should be marked as active for period', async () => {
const { body } = await request
.get(api(`livechat/visitors.info?visitorId=${visitor._id}`))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(body).to.have.nested.property('visitor').and.to.be.an('object');
expect(body.visitor).to.have.nested.property('activity').and.to.be.an('array');
expect(body.visitor.activity).to.have.lengthOf(1);
expect(body.visitor.activity[0]).to.equal(moment.utc().format('YYYY-MM'));
});
});
});

@ -237,6 +237,7 @@ export interface StreamerEvents {
},
{ key: 'voip.statuschanged'; args: [boolean] },
{ key: 'mac.limit'; args: [{ limitReached: boolean }] },
{ key: 'omnichannel.priority-changed'; args: [{ id: string; clientAction: ClientAction; name?: string }] },
];

@ -273,4 +273,6 @@ export type EventSignatures = {
'command.updated'(command: string): void;
'command.removed'(command: string): void;
'actions.changed'(): void;
'mac.limitReached'(): void;
'mac.limitRestored'(): void;
};

@ -1,7 +1,8 @@
import type { IOmnichannelQueue } from '@rocket.chat/core-typings';
import type { AtLeast, IOmnichannelQueue, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { IServiceClass } from './ServiceClass';
export interface IOmnichannelService extends IServiceClass {
getQueueWorker(): IOmnichannelQueue;
isWithinMACLimit(_room: AtLeast<IOmnichannelRoom, 'v'>): Promise<boolean>;
}

@ -2,4 +2,5 @@ export interface IOmnichannelQueue {
start(): Promise<void>;
shouldStart(): void;
stop(): Promise<void>;
isRunning(): boolean;
}

Loading…
Cancel
Save