[NEW] [EE] PDF Chat transcript for Omnichannel conversations (#27572)

Co-authored-by: Kevin Aleman <kaleman960@gmail.com>
Co-authored-by: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com>
Co-authored-by: murtaza98 <murtaza.patrawala@rocket.chat>
Co-authored-by: Diego Sampaio <chinello@gmail.com>
pull/28031/head
Filipe Marins 3 years ago committed by GitHub
parent e44f506918
commit 813cdfbe45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .hygen.js
  2. 5
      _templates/service/new/package.json.ejs.t
  3. 2
      _templates/service/new/service.ejs.t
  4. 5
      apps/meteor/app/apps/server/bridges/livechat.ts
  5. 5
      apps/meteor/app/file-upload/server/lib/FileUpload.js
  6. 10
      apps/meteor/app/lib/server/functions/closeOmnichannelConversations.ts
  7. 9
      apps/meteor/app/lib/server/functions/setUserActiveStatus.ts
  8. 6
      apps/meteor/app/lib/server/startup/settings.ts
  9. 121
      apps/meteor/app/livechat/server/api/v1/room.ts
  10. 46
      apps/meteor/app/livechat/server/hooks/beforeCloseRoom.js
  11. 10
      apps/meteor/app/livechat/server/hooks/processRoomAbandonment.js
  12. 71
      apps/meteor/app/livechat/server/hooks/sendEmailTranscriptOnClose.ts
  13. 9
      apps/meteor/app/livechat/server/hooks/sendToCRM.js
  14. 20
      apps/meteor/app/livechat/server/hooks/sendTranscriptOnClose.js
  15. 4
      apps/meteor/app/livechat/server/index.js
  16. 113
      apps/meteor/app/livechat/server/lib/Livechat.js
  17. 31
      apps/meteor/app/livechat/server/lib/LivechatTyped.d.ts
  18. 182
      apps/meteor/app/livechat/server/lib/LivechatTyped.ts
  19. 3
      apps/meteor/app/livechat/server/lib/QueueManager.js
  20. 2
      apps/meteor/app/livechat/server/lib/stream/agentStatus.ts
  21. 23
      apps/meteor/app/livechat/server/methods/closeByVisitor.js
  22. 43
      apps/meteor/app/livechat/server/methods/closeRoom.js
  23. 129
      apps/meteor/app/livechat/server/methods/closeRoom.ts
  24. 32
      apps/meteor/app/livechat/server/methods/discardTranscript.js
  25. 36
      apps/meteor/app/livechat/server/methods/discardTranscript.ts
  26. 26
      apps/meteor/app/livechat/server/methods/removeAllClosedRooms.js
  27. 30
      apps/meteor/app/livechat/server/methods/removeAllClosedRooms.ts
  28. 9
      apps/meteor/app/livechat/server/methods/removeRoom.ts
  29. 24
      apps/meteor/app/livechat/server/methods/requestTranscript.js
  30. 29
      apps/meteor/app/livechat/server/methods/requestTranscript.ts
  31. 10
      apps/meteor/app/livechat/server/roomAccessValidator.compatibility.js
  32. 9
      apps/meteor/app/models/server/models/LivechatInquiry.ts
  33. 62
      apps/meteor/app/models/server/models/LivechatRooms.js
  34. 127
      apps/meteor/client/components/Omnichannel/modals/CloseChatModal.tsx
  35. 9
      apps/meteor/client/components/Omnichannel/modals/CloseChatModalData.tsx
  36. 7
      apps/meteor/client/components/Omnichannel/modals/TranscriptModal.tsx
  37. 6
      apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx
  38. 68
      apps/meteor/client/views/account/omnichannel/OmnichannelPreferencesPage.tsx
  39. 64
      apps/meteor/client/views/account/omnichannel/PreferencesConversationTranscript.tsx
  40. 5
      apps/meteor/client/views/account/routes.tsx
  41. 8
      apps/meteor/client/views/account/sidebarItems.ts
  42. 20
      apps/meteor/client/views/room/Header/Omnichannel/OmnichannelRoomHeader.tsx
  43. 9
      apps/meteor/client/views/room/Header/Omnichannel/QuickActions/QuickActions.tsx
  44. 49
      apps/meteor/client/views/room/Header/Omnichannel/QuickActions/ToolBoxActionOptions.tsx
  45. 79
      apps/meteor/client/views/room/Header/Omnichannel/QuickActions/hooks/useQuickActions.tsx
  46. 2
      apps/meteor/client/views/room/Header/ToolBox/ToolBox.tsx
  47. 15
      apps/meteor/client/views/room/lib/QuickActions/defaultActions.ts
  48. 12
      apps/meteor/client/views/room/lib/QuickActions/index.tsx
  49. 6
      apps/meteor/client/views/room/providers/RoomProvider.tsx
  50. 22
      apps/meteor/ee/app/license/server/getStatistics.ts
  51. 1
      apps/meteor/ee/app/livechat-enterprise/server/api/index.ts
  52. 37
      apps/meteor/ee/app/livechat-enterprise/server/api/transcript.ts
  53. 1
      apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts
  54. 6
      apps/meteor/ee/app/livechat-enterprise/server/hooks/onAgentAssignmentFailed.ts
  55. 8
      apps/meteor/ee/app/livechat-enterprise/server/hooks/onCloseLivechat.js
  56. 20
      apps/meteor/ee/app/livechat-enterprise/server/hooks/scheduleAutoTransfer.ts
  57. 42
      apps/meteor/ee/app/livechat-enterprise/server/hooks/sendPdfTranscriptOnClose.ts
  58. 17
      apps/meteor/ee/app/livechat-enterprise/server/lib/AutoCloseOnHoldScheduler.ts
  59. 25
      apps/meteor/ee/app/livechat-enterprise/server/lib/QueueInactivityMonitor.ts
  60. 4
      apps/meteor/ee/app/livechat-enterprise/server/lib/VisitorInactivityMonitor.js
  61. 2
      apps/meteor/ee/app/livechat-enterprise/server/permissions.ts
  62. 6
      apps/meteor/ee/app/livechat-enterprise/server/settings.ts
  63. 6
      apps/meteor/ee/app/voip-enterprise/server/services/voipService.ts
  64. 9
      apps/meteor/ee/client/omnichannel/tags/TagsRoute.js
  65. 5
      apps/meteor/ee/server/NetworkBroker.ts
  66. 6
      apps/meteor/ee/server/lib/registerServiceModels.ts
  67. 13
      apps/meteor/ee/server/models/raw/LivechatRooms.ts
  68. 22
      apps/meteor/ee/server/services/docker-compose.yml
  69. 5
      apps/meteor/lib/callbacks.ts
  70. 6
      apps/meteor/package.json
  71. 31
      apps/meteor/packages/rocketchat-i18n/i18n/en.i18n.json
  72. 2
      apps/meteor/server/methods/saveUserPreferences.js
  73. 6
      apps/meteor/server/models/raw/LivechatInquiry.ts
  74. 107
      apps/meteor/server/models/raw/LivechatRooms.js
  75. 25
      apps/meteor/server/models/raw/Messages.ts
  76. 4
      apps/meteor/server/models/raw/VoipRoom.ts
  77. 13
      apps/meteor/server/services/messages/service.ts
  78. 10
      apps/meteor/server/services/omnichannel-voip/service.ts
  79. 13
      apps/meteor/server/services/room/service.ts
  80. 12
      apps/meteor/server/services/settings/service.ts
  81. 14
      apps/meteor/server/services/startup.ts
  82. 35
      apps/meteor/server/services/translation/service.ts
  83. 24
      apps/meteor/server/services/upload/service.ts
  84. 1
      apps/meteor/tests/data/livechat/inboxes.ts
  85. 11
      apps/meteor/tests/e2e/omnichannel-send-transcript.spec.ts
  86. 8
      apps/meteor/tests/e2e/page-objects/fragments/home-content.ts
  87. 4
      apps/meteor/tests/end-to-end/api/00-miscellaneous.js
  88. 104
      apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts
  89. 80
      apps/meteor/tests/end-to-end/api/livechat/11-email-inbox.ts
  90. 58
      apps/meteor/tests/unit/client/components/Omnichannel/modals/TranscriptModal.spec.tsx
  91. 32
      docker-compose-ci.yml
  92. 171
      docker-compose-local.yml
  93. 4
      ee/apps/omnichannel-transcript/.eslintrc.json
  94. 49
      ee/apps/omnichannel-transcript/Dockerfile
  95. 57
      ee/apps/omnichannel-transcript/package.json
  96. 41
      ee/apps/omnichannel-transcript/src/service.ts
  97. 11
      ee/apps/omnichannel-transcript/tsconfig.json
  98. 5
      ee/apps/presence-service/package.json
  99. 4
      ee/apps/queue-worker/.eslintrc.json
  100. 49
      ee/apps/queue-worker/Dockerfile
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,5 @@
module.exports = {
helpers: {
random: () => Math.floor(3000 + (5000 - 3000) * Math.random()),
},
};

@ -47,6 +47,9 @@ to: ee/apps/<%= name %>/package.json
"main": "./dist/ee/apps/<%= name %>/src/service.js",
"files": [
"/dist"
]
],
"volta": {
"node": "14.19.3"
}
}

@ -9,7 +9,7 @@ import { broker } from '../../../../apps/meteor/ee/server/startup/broker';
import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo';
import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels';
const PORT = process.env.PORT || 3034;
const PORT = process.env.PORT || <%= h.random() %>;
(async () => {
const db = await getConnection();

@ -17,6 +17,7 @@ import { getRoom } from '../../../livechat/server/api/lib/livechat';
import { Livechat } from '../../../livechat/server/lib/Livechat';
import { Users, LivechatDepartment, LivechatRooms } from '../../../models/server';
import type { AppServerOrchestrator } from '../orchestrator';
import { Livechat as LivechatTyped } from '../../../livechat/server/lib/LivechatTyped';
export class AppLivechatBridge extends LivechatBridge {
// eslint-disable-next-line no-empty-function
@ -122,7 +123,9 @@ export class AppLivechatBridge extends LivechatBridge {
...(visitor && { visitor }),
};
return Livechat.closeRoom(closeData);
await LivechatTyped.closeRoom(closeData);
return true;
}
protected async findRooms(visitor: IVisitor, departmentId: string | null, appId: string): Promise<Array<ILivechatRoom>> {

@ -706,6 +706,11 @@ export class FileUploadClass {
streamOrBuffer = Promise.await(streamToBuffer(streamOrBuffer));
}
if (streamOrBuffer instanceof Uint8Array) {
// Services compat :)
streamOrBuffer = Buffer.from(streamOrBuffer);
}
// Check if the fileData matches store filter
const filter = this.store.getFilter();
if (filter && filter.check) {

@ -3,18 +3,22 @@ import type { IUser } from '@rocket.chat/core-typings';
import { LivechatRooms } from '../../../models/server';
import { settings } from '../../../settings/server';
import { Livechat } from '../../../livechat/server/lib/Livechat';
import { Livechat } from '../../../livechat/server/lib/LivechatTyped';
type SubscribedRooms = {
rid: string;
t: string;
};
export const closeOmnichannelConversations = (user: IUser, subscribedRooms: SubscribedRooms[]): void => {
export const closeOmnichannelConversations = async (user: IUser, subscribedRooms: SubscribedRooms[]): Promise<void> => {
const roomsInfo = LivechatRooms.findByIds(subscribedRooms.map(({ rid }) => rid));
const language = settings.get<string>('Language') || 'en';
const comment = TAPi18n.__('Agent_deactivated', { lng: language });
const promises: Promise<void>[] = [];
roomsInfo.forEach((room: any) => {
Livechat.closeRoom({ user, visitor: {}, room, comment });
promises.push(Livechat.closeRoom({ user, room, comment }));
});
await Promise.all(promises);
};

@ -64,8 +64,13 @@ export function setUserActiveStatus(userId: string, active: boolean, confirmReli
throw new Meteor.Error('user-last-owner', '', rooms);
}
closeOmnichannelConversations(user, livechatSubscribedRooms);
Promise.await(relinquishRoomOwnerships(user, chatSubscribedRooms, false));
Promise.await(
// We don't want one killing the other :)
Promise.allSettled([
closeOmnichannelConversations(user, livechatSubscribedRooms),
relinquishRoomOwnerships(user, chatSubscribedRooms, false),
]),
);
}
if (active && !user.active) {

@ -524,6 +524,12 @@ settingsRegistry.addGroup('Accounts', function () {
public: true,
i18nLabel: 'Notifications_Sound_Volume',
});
this.add('Accounts_Default_User_Preferences_omnichannelTranscriptEmail', false, {
type: 'boolean',
public: true,
i18nLabel: 'Omnichannel_transcript_email',
});
});
this.section('Avatar', function () {

@ -2,9 +2,9 @@ import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Random } from 'meteor/random';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import type { ILivechatAgent, IOmnichannelRoom } from '@rocket.chat/core-typings';
import type { ILivechatAgent, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users } from '@rocket.chat/models';
import { LivechatVisitors, Users, LivechatRooms as LivechatRoomsRaw, Subscriptions } from '@rocket.chat/models';
import {
isLiveChatRoomForwardProps,
isPOSTLivechatRoomCloseParams,
@ -13,6 +13,7 @@ import {
isLiveChatRoomJoinProps,
isPUTLivechatRoomVisitorParams,
isLiveChatRoomSaveInfoProps,
isPOSTLivechatRoomCloseByUserParams,
} from '@rocket.chat/rest-typings';
import { settings as rcSettings } from '../../../../settings/server';
@ -20,13 +21,16 @@ import { Messages, LivechatRooms } from '../../../../models/server';
import { API } from '../../../../api/server';
import { findGuest, findRoom, getRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat';
import { Livechat } from '../../lib/Livechat';
import { Livechat as LivechatTyped } from '../../lib/LivechatTyped';
import { normalizeTransferredByData } from '../../lib/Helper';
import { findVisitorInfo } from '../lib/visitors';
import { canAccessRoom, hasPermission } from '../../../../authorization/server';
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { addUserToRoom } from '../../../../lib/server/functions';
import { apiDeprecationLogger } from '../../../../lib/server/lib/deprecationWarningLogger';
import { deprecationWarning } from '../../../../api/server/helpers/deprecationWarning';
import { callbacks } from '../../../../../lib/callbacks';
import type { CloseRoomParams } from '../../lib/LivechatTyped.d';
const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: true }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj);
@ -86,6 +90,8 @@ API.v1.addRoute('livechat/room', {
},
});
// Note: use this route if a visitor is closing a room
// If a RC user(like eg agent) is closing a room, use the `livechat/room.closeByUser` route
API.v1.addRoute(
'livechat/room.close',
{ validateParams: isPOSTLivechatRoomCloseParams },
@ -110,16 +116,121 @@ API.v1.addRoute(
const language = rcSettings.get<string>('Language') || 'en';
const comment = TAPi18n.__('Closed_by_visitor', { lng: language });
// @ts-expect-error -- typings on closeRoom are wrong
if (!Livechat.closeRoom({ visitor, room, comment })) {
return API.v1.failure();
const options: CloseRoomParams['options'] = {};
if (room.servedBy) {
const servingAgent: Pick<IUser, '_id' | 'name' | 'username' | 'utcOffset' | 'settings' | 'language'> | null =
await Users.findOneById(room.servedBy._id, {
projection: {
name: 1,
username: 1,
utcOffset: 1,
settings: 1,
language: 1,
},
});
if (servingAgent?.settings?.preferences?.omnichannelTranscriptPDF) {
options.pdfTranscript = {
requestedBy: servingAgent._id,
};
}
// We'll send the transcript by email only if the setting is disabled (that means, we're not asking the user if he wants to receive the transcript by email)
// And the agent has the preference enabled to send the transcript by email and the visitor has an email address
// When Livechat_enable_transcript is enabled, the email will be sent via livechat/transcript route
if (
!rcSettings.get<boolean>('Livechat_enable_transcript') &&
servingAgent?.settings?.preferences?.omnichannelTranscriptEmail &&
visitor.visitorEmails?.length &&
visitor.visitorEmails?.[0]?.address
) {
const visitorEmail = visitor.visitorEmails?.[0]?.address;
const language = servingAgent.language || rcSettings.get<string>('Language') || 'en';
const t = (s: string): string => TAPi18n.__(s, { lng: language });
const subject = t('Transcript_of_your_livechat_conversation');
options.emailTranscript = {
sendToVisitor: true,
requestData: {
email: visitorEmail,
requestedAt: new Date(),
requestedBy: servingAgent,
subject,
},
};
}
}
await LivechatTyped.closeRoom({ visitor, room, comment, options });
return API.v1.success({ rid, comment });
},
},
);
API.v1.addRoute(
'livechat/room.closeByUser',
{
validateParams: isPOSTLivechatRoomCloseByUserParams,
authRequired: true,
permissionsRequired: ['close-livechat-room'],
},
{
async post() {
const { rid, comment, tags, generateTranscriptPdf, transcriptEmail } = this.bodyParams;
const room = await LivechatRoomsRaw.findOneById(rid);
if (!room || !isOmnichannelRoom(room)) {
throw new Error('error-invalid-room');
}
if (!room.open) {
throw new Error('error-room-already-closed');
}
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, this.userId, { projection: { _id: 1 } });
if (!subscription && !(await hasPermissionAsync(this.userId, 'close-others-livechat-room'))) {
throw new Error('error-not-authorized');
}
const options: CloseRoomParams['options'] = {
clientAction: true,
tags,
...(generateTranscriptPdf && { pdfTranscript: { requestedBy: this.userId } }),
...(transcriptEmail && {
...(transcriptEmail.sendToVisitor
? {
emailTranscript: {
sendToVisitor: true,
requestData: {
email: transcriptEmail.requestData.email,
subject: transcriptEmail.requestData.subject,
requestedAt: new Date(),
requestedBy: this.user,
},
},
}
: {
emailTranscript: {
sendToVisitor: false,
},
}),
}),
};
await LivechatTyped.closeRoom({
room,
user: this.user,
options,
comment,
});
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.transfer',
{ validateParams: isPOSTLivechatRoomTransferParams },

@ -1,46 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { callbacks } from '../../../../lib/callbacks';
import { LivechatDepartment } from '../../../models/server';
const concatUnique = (...arrays) => [...new Set([].concat(...arrays.filter(Array.isArray)))];
const normalizeParams = (params, tags = []) => Object.assign(params, { extraData: { tags } });
callbacks.add(
'livechat.beforeCloseRoom',
(originalParams = {}) => {
const { room, options } = originalParams;
const { departmentId, tags: optionsTags } = room;
const { clientAction, tags: oldRoomTags } = options;
const roomTags = concatUnique(oldRoomTags, optionsTags);
if (!departmentId) {
return normalizeParams({ ...originalParams }, roomTags);
}
const department = LivechatDepartment.findOneById(departmentId);
if (!department) {
return normalizeParams({ ...originalParams }, roomTags);
}
const { requestTagBeforeClosingChat, chatClosingTags } = department;
const extraRoomTags = concatUnique(roomTags, chatClosingTags);
if (!requestTagBeforeClosingChat) {
return normalizeParams({ ...originalParams }, extraRoomTags);
}
const checkRoomTags = !clientAction || (roomTags && roomTags.length > 0);
const checkDepartmentTags = chatClosingTags && chatClosingTags.length > 0;
if (!checkRoomTags || !checkDepartmentTags) {
throw new Meteor.Error('error-tags-must-be-assigned-before-closing-chat', 'Tag(s) must be assigned before closing the chat', {
method: 'livechat.beforeCloseRoom',
});
}
return normalizeParams({ ...originalParams }, extraRoomTags);
},
callbacks.priority.HIGH,
'livechat-before-close-Room',
);

@ -58,20 +58,22 @@ const getSecondsSinceLastAgentResponse = async (room, agentLastMessage) => {
callbacks.add(
'livechat.closeRoom',
(room) => {
(params) => {
const { room } = params;
const closedByAgent = room.closer !== 'visitor';
const wasTheLastMessageSentByAgent = room.lastMessage && !room.lastMessage.token;
if (!closedByAgent || !wasTheLastMessageSentByAgent) {
return;
return params;
}
const agentLastMessage = Messages.findAgentLastMessageByVisitorLastMessageTs(room._id, room.v.lastMessageTs);
if (!agentLastMessage) {
return;
return params;
}
const secondsSinceLastAgentResponse = Promise.await(getSecondsSinceLastAgentResponse(room, agentLastMessage));
LivechatRooms.setVisitorInactivityInSecondsById(room._id, secondsSinceLastAgentResponse);
return room;
return params;
},
callbacks.priority.HIGH,
'process-room-abandonment',

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

@ -106,12 +106,15 @@ function sendToCRM(type, room, includeMessages = true) {
callbacks.add(
'livechat.closeRoom',
(room) => {
(params) => {
const { room } = params;
if (!settings.get('Livechat_webhook_on_close')) {
return room;
return params;
}
return sendToCRM('LivechatSession', room);
sendToCRM('LivechatSession', room);
return params;
},
callbacks.priority.MEDIUM,
'livechat-send-crm-close-room',

@ -1,20 +0,0 @@
import { callbacks } from '../../../../lib/callbacks';
import { Livechat } from '../lib/Livechat';
import { LivechatRooms } from '../../../models/server';
const sendTranscriptOnClose = (room) => {
const { _id: rid, transcriptRequest, v: { token } = {} } = room;
if (!transcriptRequest || !token) {
return room;
}
const { email, subject, requestedBy: user } = transcriptRequest;
// TODO: refactor this to use normal await
Promise.await(Livechat.sendTranscript({ token, rid, email, subject, user }));
LivechatRooms.removeTranscriptRequestByRoomId(rid);
return LivechatRooms.findOneById(rid);
};
callbacks.add('livechat.closeRoom', sendTranscriptOnClose, callbacks.priority.HIGH, 'livechat-send-transcript-on-close-room');

@ -2,7 +2,6 @@ import './livechat';
import './config';
import './startup';
import '../lib/messageTypes';
import './hooks/beforeCloseRoom';
import './hooks/beforeDelegateAgent';
import './hooks/leadCapture';
import './hooks/markRoomResponded';
@ -13,14 +12,13 @@ import './hooks/sendToCRM';
import './hooks/processRoomAbandonment';
import './hooks/saveLastVisitorMessageTs';
import './hooks/markRoomNotResponded';
import './hooks/sendTranscriptOnClose';
import './hooks/sendEmailTranscriptOnClose';
import './hooks/saveContactLastChat';
import './hooks/saveLastMessageToInquiry';
import './hooks/afterUserActions';
import './methods/addAgent';
import './methods/addManager';
import './methods/changeLivechatStatus';
import './methods/closeByVisitor';
import './methods/closeRoom';
import './methods/discardTranscript';
import './methods/getCustomFields';

@ -1,3 +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 dns from 'dns';
import { Meteor } from 'meteor/meteor';
@ -14,6 +17,10 @@ import {
LivechatVisitors,
LivechatCustomField,
Settings,
LivechatRooms as LivechatRoomsRaw,
LivechatInquiry as LivechatInquiryRaw,
Subscriptions as SubscriptionsRaw,
Messages as MessagesRaw,
LivechatDepartment as LivechatDepartmentRaw,
} from '@rocket.chat/models';
import { VideoConf, api } from '@rocket.chat/core-services';
@ -46,6 +53,7 @@ import { Apps, AppEvents } from '../../../apps/server';
import { businessHourManager } from '../business-hour';
import { addUserRoles } from '../../../../server/lib/roles/addUserRoles';
import { removeUserFromRoles } from '../../../../server/lib/roles/removeUserFromRoles';
import { Livechat as LivechatTyped } from './LivechatTyped';
const logger = new Logger('Livechat');
@ -427,76 +435,7 @@ export const Livechat = {
return ret;
},
closeRoom({ user, visitor, room, comment, options = {} }) {
Livechat.logger.debug(`Attempting to close room ${room._id}`);
if (!room || room.t !== 'l' || !room.open) {
return false;
}
const params = callbacks.run('livechat.beforeCloseRoom', { room, options });
const { extraData } = params;
const now = new Date();
const { _id: rid, servedBy, transcriptRequest } = room;
const serviceTimeDuration = servedBy && (now.getTime() - servedBy.ts) / 1000;
const closeData = {
closedAt: now,
chatDuration: (now.getTime() - room.ts) / 1000,
...(serviceTimeDuration && { serviceTimeDuration }),
...extraData,
};
Livechat.logger.debug(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`);
if (user) {
Livechat.logger.debug(`Closing by user ${user._id}`);
closeData.closer = 'user';
closeData.closedBy = {
_id: user._id,
username: user.username,
};
} else if (visitor) {
Livechat.logger.debug(`Closing by visitor ${visitor._id}`);
closeData.closer = 'visitor';
closeData.closedBy = {
_id: visitor._id,
username: visitor.username,
};
}
LivechatRooms.closeByRoomId(rid, closeData);
LivechatInquiry.removeByRoomId(rid);
Subscriptions.removeByRoomId(rid);
const message = {
t: 'livechat-close',
msg: comment,
groupable: false,
transcriptRequested: !!transcriptRequest,
};
// Retreive the closed room
room = LivechatRooms.findOneByIdOrName(rid);
Livechat.logger.debug(`Sending closing message to room ${room._id}`);
sendMessage(user || visitor, message, room);
Messages.createCommandWithRoomIdAndUser('promptTranscript', rid, closeData.closedBy);
Meteor.defer(() => {
/**
* @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed
* in the next major version of the Apps-Engine
*/
Apps.getBridges().getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, room);
Apps.getBridges().getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, room);
});
callbacks.runAsync('livechat.closeRoom', room);
return true;
},
removeRoom(rid) {
async removeRoom(rid) {
Livechat.logger.debug(`Deleting room ${rid}`);
check(rid, String);
const room = LivechatRooms.findOneById(rid);
@ -506,10 +445,21 @@ export const Livechat = {
});
}
Messages.removeByRoomId(rid);
Subscriptions.removeByRoomId(rid);
LivechatInquiry.removeByRoomId(rid);
return LivechatRooms.removeById(rid);
const result = await Promise.allSettled([
MessagesRaw.removeByRoomId(rid),
SubscriptionsRaw.removeByRoomId(rid),
LivechatInquiryRaw.removeByRoomId(rid),
LivechatRoomsRaw.removeById(rid),
]);
const errors = result.filter((r) => r.status === 'rejected').map((r) => r.reason);
if (errors.length > 0) {
this.logger.error(`Error removing room ${rid}: ${errors.join(', ')}`);
throw new Meteor.Error('error-removing-room', 'Error removing room', {
method: 'livechat:removeRoom',
errors,
});
}
},
async setCustomFields({ token, key, value, overwrite } = {}) {
@ -635,12 +585,17 @@ export const Livechat = {
}
},
closeOpenChats(userId, comment) {
async closeOpenChats(userId, comment) {
Livechat.logger.debug(`Closing open chats for user ${userId}`);
const user = Users.findOneById(userId);
LivechatRooms.findOpenByAgent(userId).forEach((room) => {
this.closeRoom({ user, room, comment });
const openChats = LivechatRooms.findOpenByAgent(userId);
const promises = [];
openChats.forEach((room) => {
promises.push(LivechatTyped.closeRoom({ user, room, comment }));
});
await Promise.all(promises);
},
forwardOpenChats(userId) {
@ -1249,7 +1204,7 @@ export const Livechat = {
}).fetch();
},
requestTranscript({ rid, email, subject, user }) {
async requestTranscript({ rid, email, subject, user }) {
check(rid, String);
check(email, String);
check(subject, String);
@ -1286,7 +1241,7 @@ export const Livechat = {
subject,
};
LivechatRooms.requestTranscriptByRoomId(rid, transcriptRequest);
await LivechatRoomsRaw.setEmailTranscriptRequestedByRoomId(rid, transcriptRequest);
return true;
},

@ -0,0 +1,31 @@
import type { IOmnichannelRoom, IUser, ILivechatVisitor } from '@rocket.chat/core-typings';
type GenericCloseRoomParams = {
room: IOmnichannelRoom;
comment?: string;
options?: {
clientAction?: boolean;
tags?: string[];
emailTranscript?:
| {
sendToVisitor: false;
}
| {
sendToVisitor: true;
requestData: NonNullable<IOmnichannelRoom['transcriptRequest']>;
};
pdfTranscript?: {
requestedBy: string;
};
};
};
export type CloseRoomParamsByUser = {
user: IUser;
} & GenericCloseRoomParams;
export type CloseRoomParamsByVisitor = {
visitor: ILivechatVisitor;
} & GenericCloseRoomParams;
export type CloseRoomParams = CloseRoomParamsByUser | CloseRoomParamsByVisitor;

@ -0,0 +1,182 @@
// Goal is to have a typed version of apps/meteor/app/livechat/server/lib/Livechat.js
// This is a work in progress, and is not yet complete
// But it is a start.
// Important note: Try to not use the original Livechat.js file, but use this one instead.
// If possible, move methods from Livechat.js to this file.
// This is because we want to slowly convert the code to typescript, and this is a good way to do it.
import type { IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions } from '@rocket.chat/models';
import { callbacks } from '../../../../lib/callbacks';
import { Logger } from '../../../logger/server';
import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './LivechatTyped.d';
import { sendMessage } from '../../../lib/server/functions/sendMessage';
import { Apps, AppEvents } from '../../../apps/server';
import { Messages as LegacyMessage } from '../../../models/server';
class LivechatClass {
logger: Logger;
constructor() {
this.logger = new Logger('Livechat');
}
async closeRoom(params: CloseRoomParams): Promise<void> {
const { comment } = params;
let { room } = params;
this.logger.debug(`Attempting to close room ${room._id}`);
if (!room || !isOmnichannelRoom(room) || !room.open) {
this.logger.debug(`Room ${room._id} is not open`);
return;
}
const { updatedOptions: options } = await this.resolveChatTags(room, params.options);
this.logger.debug(`Resolved chat tags for room ${room._id}`);
const now = new Date();
const { _id: rid, servedBy, transcriptRequest } = room;
const serviceTimeDuration = servedBy && (now.getTime() - new Date(servedBy.ts).getTime()) / 1000;
const closeData: IOmnichannelRoomClosingInfo = {
closedAt: now,
chatDuration: (now.getTime() - new Date(room.ts).getTime()) / 1000,
...(serviceTimeDuration && { serviceTimeDuration }),
...options,
};
this.logger.debug(`Room ${room._id} was closed at ${closeData.closedAt} (duration ${closeData.chatDuration})`);
const isRoomClosedByUserParams = (params: CloseRoomParams): params is CloseRoomParamsByUser =>
(params as CloseRoomParamsByUser).user !== undefined;
const isRoomClosedByVisitorParams = (params: CloseRoomParams): params is CloseRoomParamsByVisitor =>
(params as CloseRoomParamsByVisitor).visitor !== undefined;
let chatCloser: any;
if (isRoomClosedByUserParams(params)) {
const { user } = params;
this.logger.debug(`Closing by user ${user._id}`);
closeData.closer = 'user';
closeData.closedBy = {
_id: user._id,
username: user.username,
};
chatCloser = user;
} else if (isRoomClosedByVisitorParams(params)) {
const { visitor } = params;
this.logger.debug(`Closing by visitor ${params.visitor._id}`);
closeData.closer = 'visitor';
closeData.closedBy = {
_id: visitor._id,
username: visitor.username,
};
chatCloser = visitor;
} else {
throw new Error('Error: Please provide details of the user or visitor who closed the room');
}
this.logger.debug(`Updating DB for room ${room._id} with close data`);
await Promise.all([
LivechatRooms.closeRoomById(rid, closeData),
LivechatInquiry.removeByRoomId(rid),
Subscriptions.removeByRoomId(rid),
]);
this.logger.debug(`DB updated for room ${room._id}`);
const message = {
t: 'livechat-close',
msg: comment,
groupable: false,
transcriptRequested: !!transcriptRequest,
};
// Retrieve the closed room
room = (await LivechatRooms.findOneById(rid)) as IOmnichannelRoom;
this.logger.debug(`Sending closing message to room ${room._id}`);
sendMessage(chatCloser, message, room);
LegacyMessage.createCommandWithRoomIdAndUser('promptTranscript', rid, closeData.closedBy);
this.logger.debug(`Running callbacks for room ${room._id}`);
Meteor.defer(() => {
/**
* @deprecated the `AppEvents.ILivechatRoomClosedHandler` event will be removed
* in the next major version of the Apps-Engine
*/
Apps.getBridges()?.getListenerBridge().livechatEvent(AppEvents.ILivechatRoomClosedHandler, room);
Apps.getBridges()?.getListenerBridge().livechatEvent(AppEvents.IPostLivechatRoomClosed, room);
});
callbacks.runAsync('livechat.closeRoom', {
room,
options,
});
this.logger.debug(`Room ${room._id} was closed`);
}
private async resolveChatTags(
room: IOmnichannelRoom,
options: CloseRoomParams['options'] = {},
): Promise<{ updatedOptions: CloseRoomParams['options'] }> {
this.logger.debug(`Resolving chat tags for room ${room._id}`);
const concatUnique = (...arrays: (string[] | undefined)[]): string[] => [
...new Set(([] as string[]).concat(...arrays.filter((a): a is string[] => !!a))),
];
const { departmentId, tags: optionsTags } = room;
const { clientAction, tags: oldRoomTags } = options;
const roomTags = concatUnique(oldRoomTags, optionsTags);
if (!departmentId) {
return {
updatedOptions: {
...options,
...(roomTags.length && { tags: roomTags }),
},
};
}
const department = await LivechatDepartment.findOneById(departmentId);
if (!department) {
return {
updatedOptions: {
...options,
...(roomTags.length && { tags: roomTags }),
},
};
}
const { requestTagBeforeClosingChat, chatClosingTags } = department;
const extraRoomTags = concatUnique(roomTags, chatClosingTags);
if (!requestTagBeforeClosingChat) {
return {
updatedOptions: {
...options,
...(extraRoomTags.length && { tags: extraRoomTags }),
},
};
}
const checkRoomTags = !clientAction || (roomTags && roomTags.length > 0);
const checkDepartmentTags = chatClosingTags && chatClosingTags.length > 0;
if (!checkRoomTags || !checkDepartmentTags) {
throw new Error('error-tags-must-be-assigned-before-closing-chat');
}
return {
updatedOptions: {
...options,
...(extraRoomTags.length && { tags: extraRoomTags }),
},
};
}
}
export const Livechat = new LivechatClass();

@ -1,5 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';
import { LivechatInquiry as LivechatInquiryRaw } from '@rocket.chat/models';
import { LivechatRooms, LivechatInquiry, Users } from '../../../models/server';
import { checkServiceStatus, createLivechatRoom, createLivechatInquiry } from './Helper';
@ -98,7 +99,7 @@ export const QueueManager = {
const oldInquiry = LivechatInquiry.findOneByRoomId(rid);
if (oldInquiry) {
logger.debug(`Removing old inquiry (${oldInquiry._id}) for room ${rid}`);
LivechatInquiry.removeByRoomId(rid);
await LivechatInquiryRaw.removeByRoomId(rid);
}
const guest = {

@ -69,7 +69,7 @@ export const onlineAgents = {
try {
if (action === 'close') {
return Livechat.closeOpenChats(userId, comment);
return Promise.await(Livechat.closeOpenChats(userId, comment));
}
if (action === 'forward') {

@ -1,23 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { LivechatVisitors } from '@rocket.chat/models';
import { settings } from '../../../settings/server';
import { LivechatRooms } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger';
Meteor.methods({
async 'livechat:closeByVisitor'({ roomId, token }) {
methodDeprecationLogger.warn('livechat:closeByVisitor will be deprecated in future versions of Rocket.Chat');
const visitor = await LivechatVisitors.getVisitorByToken(token);
const language = (visitor && visitor.language) || settings.get('Language') || 'en';
return Livechat.closeRoom({
visitor,
room: LivechatRooms.findOneOpenByRoomIdAndVisitorToken(roomId, token),
comment: TAPi18n.__('Closed_by_visitor', { lng: language }),
});
},
});

@ -1,43 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { hasPermission } from '../../../authorization';
import { Subscriptions, LivechatRooms } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
Meteor.methods({
'livechat:closeRoom'(roomId, comment, options = {}) {
const userId = Meteor.userId();
if (!userId || !hasPermission(userId, 'close-livechat-room')) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'livechat:closeRoom',
});
}
const room = LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'livechat:closeRoom',
});
}
if (!room.open) {
throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:closeRoom' });
}
const user = Meteor.user();
const subscription = Subscriptions.findOneByRoomIdAndUserId(roomId, user._id, { _id: 1 });
if (!subscription && !hasPermission(userId, 'close-others-livechat-room')) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'livechat:closeRoom',
});
}
return Livechat.closeRoom({
user,
room: LivechatRooms.findOneById(roomId),
comment,
options,
});
},
});

@ -0,0 +1,129 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { Users, LivechatRooms, Subscriptions as SubscriptionRaw } from '@rocket.chat/models';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { Livechat } from '../lib/LivechatTyped';
import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger';
type CloseRoomOptions = {
clientAction?: boolean;
tags?: string[];
emailTranscript?:
| {
sendToVisitor: false;
}
| {
sendToVisitor: true;
requestData: Pick<NonNullable<IOmnichannelRoom['transcriptRequest']>, 'email' | 'subject'>;
};
generateTranscriptPdf?: boolean;
};
type LivechatCloseRoomOptions = Omit<CloseRoomOptions, 'generateTranscriptPdf'> & {
emailTranscript?:
| {
sendToVisitor: false;
}
| {
sendToVisitor: true;
requestData: NonNullable<IOmnichannelRoom['transcriptRequest']>;
};
pdfTranscript?: {
requestedBy: string;
};
};
Meteor.methods({
async 'livechat:closeRoom'(roomId: string, comment?: string, options?: CloseRoomOptions) {
methodDeprecationLogger.warn(
'livechat:closeRoom is deprecated and will be removed in next major version. Use /api/v1/livechat/room.closeByUser API instead.',
);
const userId = Meteor.userId();
if (!userId || !(await hasPermissionAsync(userId, 'close-livechat-room'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'livechat:closeRoom',
});
}
const room = await LivechatRooms.findOneById(roomId);
if (!room || room.t !== 'l') {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'livechat:closeRoom',
});
}
if (!room.open) {
throw new Meteor.Error('room-closed', 'Room closed', { method: 'livechat:closeRoom' });
}
const user = await Users.findOneById(userId);
if (!user) {
throw new Meteor.Error('error-invalid-user', 'Invalid user', {
method: 'livechat:closeRoom',
});
}
const subscription = await SubscriptionRaw.findOneByRoomIdAndUserId(roomId, user._id, {
projection: {
_id: 1,
},
});
if (!subscription && !(await hasPermissionAsync(userId, 'close-others-livechat-room'))) {
throw new Meteor.Error('error-not-authorized', 'Not authorized', {
method: 'livechat:closeRoom',
});
}
await Livechat.closeRoom({
user,
room,
comment,
options: resolveOptions(user, options),
});
},
});
const resolveOptions = (
user: NonNullable<IOmnichannelRoom['transcriptRequest']>['requestedBy'],
options?: CloseRoomOptions,
): LivechatCloseRoomOptions | undefined => {
if (!options) {
return undefined;
}
const resolvedOptions: LivechatCloseRoomOptions = {
clientAction: options.clientAction,
tags: options.tags,
};
if (options.generateTranscriptPdf) {
resolvedOptions.pdfTranscript = {
requestedBy: user._id,
};
}
if (!options?.emailTranscript) {
return resolvedOptions;
}
if (options?.emailTranscript.sendToVisitor === false) {
return {
...resolvedOptions,
emailTranscript: {
sendToVisitor: false,
},
};
}
return {
...resolvedOptions,
emailTranscript: {
sendToVisitor: true,
requestData: {
...options.emailTranscript.requestData,
requestedBy: user,
requestedAt: new Date(),
},
},
};
};

@ -1,32 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { hasPermission } from '../../../authorization';
import { LivechatRooms } from '../../../models/server';
Meteor.methods({
'livechat:discardTranscript'(rid) {
check(rid, String);
if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'send-omnichannel-chat-transcript')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:requestTranscript',
});
}
const room = LivechatRooms.findOneById(rid);
if (!room || !room.open) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'livechat:requestTranscript',
});
}
if (!room.transcriptRequest) {
throw new Meteor.Error('error-transcript-not-requested', 'No transcript requested for this chat', {
method: 'livechat:requestTranscript',
});
}
return LivechatRooms.removeTranscriptRequestByRoomId(rid);
},
});

@ -0,0 +1,36 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { LivechatRooms } from '@rocket.chat/models';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
Meteor.methods({
async 'livechat:discardTranscript'(rid: string) {
check(rid, String);
const user = Meteor.userId();
if (!user || !(await hasPermissionAsync(user, 'send-omnichannel-chat-transcript'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:requestTranscript',
});
}
const room = await LivechatRooms.findOneById(rid);
if (!room || !room.open) {
throw new Meteor.Error('error-invalid-room', 'Invalid room', {
method: 'livechat:discardTranscript',
});
}
if (!room.transcriptRequest) {
throw new Meteor.Error('error-transcript-not-requested', 'No transcript requested for this chat', {
method: 'livechat:discardTranscript',
});
}
await LivechatRooms.unsetEmailTranscriptRequestedByRoomId(rid);
return true;
},
});

@ -1,26 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { hasPermission } from '../../../authorization';
import { LivechatRooms } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
Meteor.methods({
'livechat:removeAllClosedRooms'(departmentIds) {
if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'remove-closed-livechat-rooms')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:removeAllClosedRoom',
});
}
let count = 0;
// These are not debug logs since we want to know when the action is performed
Livechat.logger.info(`User ${Meteor.userId()} is removing all closed rooms`);
LivechatRooms.findClosedRooms(departmentIds).forEach(({ _id }) => {
Livechat.removeRoom(_id);
count++;
});
Livechat.logger.info(`User ${Meteor.userId()} removed ${count} closed rooms`);
return count;
},
});

@ -0,0 +1,30 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { LivechatRooms } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
Meteor.methods({
async 'livechat:removeAllClosedRooms'(departmentIds: string[]) {
const user = Meteor.userId();
if (!user || !(await hasPermissionAsync(user, 'remove-closed-livechat-rooms'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:removeAllClosedRoom',
});
}
// These are not debug logs since we want to know when the action is performed
Livechat.logger.info(`User ${Meteor.userId()} is removing all closed rooms`);
const promises: Promise<void>[] = [];
LivechatRooms.findClosedRooms(departmentIds).forEach(({ _id }: IOmnichannelRoom) => {
promises.push(Livechat.removeRoom(_id));
});
await Promise.all(promises);
Livechat.logger.info(`User ${Meteor.userId()} removed ${promises.length} closed rooms`);
return promises.length;
},
});

@ -1,12 +1,13 @@
import { Meteor } from 'meteor/meteor';
import { hasPermission } from '../../../authorization';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { LivechatRooms } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
Meteor.methods({
'livechat:removeRoom'(rid) {
if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'remove-closed-livechat-rooms')) {
async 'livechat:removeRoom'(rid: string) {
const user = Meteor.userId();
if (!user || !(await hasPermissionAsync(user, 'remove-closed-livechat-rooms'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'livechat:removeRoom' });
}
@ -30,6 +31,6 @@ Meteor.methods({
});
}
return Livechat.removeRoom(rid);
await Livechat.removeRoom(rid);
},
});

@ -1,24 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { hasPermission } from '../../../authorization';
import { Users } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
Meteor.methods({
'livechat:requestTranscript'(rid, email, subject) {
check(rid, String);
check(email, String);
if (!Meteor.userId() || !hasPermission(Meteor.userId(), 'send-omnichannel-chat-transcript')) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:requestTranscript',
});
}
const user = Users.findOneById(Meteor.userId(), {
fields: { _id: 1, username: 1, name: 1, utcOffset: 1 },
});
return Livechat.requestTranscript({ rid, email, subject, user });
},
});

@ -0,0 +1,29 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { Users } from '../../../models/server';
import { Livechat } from '../lib/Livechat';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
Meteor.methods({
async 'livechat:requestTranscript'(rid: string, email: string, subject: string) {
check(rid, String);
check(email, String);
const userId = Meteor.userId();
if (!userId || !(await hasPermissionAsync(userId, 'send-omnichannel-chat-transcript'))) {
throw new Meteor.Error('error-not-allowed', 'Not allowed', {
method: 'livechat:requestTranscript',
});
}
const user = Users.findOneById(userId, {
fields: { _id: 1, username: 1, name: 1, utcOffset: 1 },
});
await Livechat.requestTranscript({ rid, email, subject, user });
return true;
},
});

@ -70,4 +70,14 @@ export const validators = [
}
return hasPermission(user._id, 'view-livechat-room-closed-same-department');
},
function (room, user) {
// Check if user is rocket.cat
if (!user?._id) {
return false;
}
// This opens the ability for rocketcat to upload files to a livechat room without being included in it :)
// Worst case, someone manages to log in as rocketcat lol
return user._id === 'rocket.cat';
},
];

@ -1,5 +1,5 @@
import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings';
import type { FindOptions, FindCursor, UpdateResult, DeleteResult } from 'mongodb';
import type { FindOptions, FindCursor, UpdateResult } from 'mongodb';
import { readSecondaryPreferred } from '../../../../server/database/readSecondaryPreferred';
import { Base } from './_Base';
@ -259,13 +259,6 @@ export class LivechatInquiry extends Base {
);
}
/*
* remove the inquiry by roomId
*/
removeByRoomId(rid: string): DeleteResult {
return this.remove({ rid });
}
removeByVisitorToken(token: string): void {
const query = {
'v.token': token,

@ -35,6 +35,8 @@ export class LivechatRooms extends Base {
},
);
this.tryEnsureIndex({ 'livechatData.$**': 1 });
this.tryEnsureIndex({ pdfTranscriptRequested: 1 }, { sparse: true });
this.tryEnsureIndex({ pdfFileId: 1 }, { sparse: true });
}
findOneByIdOrName(_idOrName, options) {
@ -649,66 +651,6 @@ export class LivechatRooms extends Base {
);
}
closeByRoomId(roomId, closeInfo) {
const { closer, closedBy, closedAt, chatDuration, serviceTimeDuration, ...extraData } = closeInfo;
return this.update(
{
_id: roomId,
t: 'l',
},
{
$set: {
closer,
closedBy,
closedAt,
'metrics.chatDuration': chatDuration,
'metrics.serviceTimeDuration': serviceTimeDuration,
'v.status': 'offline',
...extraData,
},
$unset: {
open: 1,
},
},
);
}
requestTranscriptByRoomId(roomId, transcriptInfo = {}) {
const { requestedAt, requestedBy, email, subject } = transcriptInfo;
return this.update(
{
_id: roomId,
t: 'l',
},
{
$set: {
transcriptRequest: {
requestedAt,
requestedBy,
email,
subject,
},
},
},
);
}
removeTranscriptRequestByRoomId(roomId) {
return this.update(
{
_id: roomId,
t: 'l',
},
{
$unset: {
transcriptRequest: 1,
},
},
);
}
findOpenByAgent(userId) {
const query = {
't': 'l',

@ -1,21 +1,30 @@
import type { ILivechatDepartment } from '@rocket.chat/core-typings';
import { Field, Button, TextInput, Modal, Box } from '@rocket.chat/fuselage';
import { useSetting, useTranslation } from '@rocket.chat/ui-contexts';
import { Field, Button, TextInput, Modal, Box, CheckBox, Divider, EmailInput } from '@rocket.chat/fuselage';
import { usePermission, useSetting, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule';
import { dispatchToastMessage } from '../../../lib/toast';
import GenericModal from '../../GenericModal';
import Tags from '../Tags';
const CloseChatModal = ({
department,
visitorEmail,
onCancel,
onConfirm,
}: {
department?: ILivechatDepartment | null;
visitorEmail?: string;
onCancel: () => void;
onConfirm: (comment?: string, tags?: string[]) => Promise<void>;
onConfirm: (
comment?: string,
tags?: string[],
preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean },
requestData?: { email: string; subject: string },
) => Promise<void>;
}): ReactElement => {
const t = useTranslation();
@ -34,33 +43,57 @@ const CloseChatModal = ({
const tags = watch('tags');
const comment = watch('comment');
const transcriptEmail = watch('transcriptEmail');
const subject = watch('subject');
const userTranscriptEmail = useUserPreference<boolean>('omnichannelTranscriptEmail') ?? false;
const userTranscriptPDF = useUserPreference<boolean>('omnichannelTranscriptPDF') ?? false;
const hasLicense = useHasLicenseModule('livechat-enterprise');
const transcriptPDFPermission = usePermission('request-pdf-transcript');
const transcriptEmailPermission = usePermission('send-omnichannel-chat-transcript');
const canSendTranscriptEmail = transcriptEmailPermission && visitorEmail;
const canSendTranscriptPDF = transcriptPDFPermission && hasLicense;
const canSendTranscript = canSendTranscriptEmail || canSendTranscriptPDF;
const handleTags = (value: string[]): void => {
setValue('tags', value);
};
const onSubmit = useCallback(
({ comment, tags }): void => {
({ comment, tags, transcriptPDF, transcriptEmail, subject }): void => {
const preferences = {
omnichannelTranscriptPDF: !!transcriptPDF,
omnichannelTranscriptEmail: !!transcriptEmail,
};
const requestData = transcriptEmail && visitorEmail ? { email: visitorEmail, subject } : undefined;
if (!comment && commentRequired) {
setError('comment', { type: 'custom', message: t('The_field_is_required', t('Comment')) });
}
if (transcriptEmail && !subject) {
setError('subject', { type: 'custom', message: t('The_field_is_required', t('Subject')) });
}
if (!tags?.length && tagRequired) {
setError('tags', { type: 'custom', message: t('error-tags-must-be-assigned-before-closing-chat') });
}
if (!errors.comment || errors.tags) {
onConfirm(comment, tags);
onConfirm(comment, tags, preferences, requestData);
}
},
[commentRequired, tagRequired, errors, setError, t, onConfirm],
[commentRequired, tagRequired, visitorEmail, errors, setError, t, onConfirm],
);
const cannotSubmit = useMemo(() => {
const cannotSendTag = (tagRequired && !tags?.length) || errors.tags;
const cannotSendComment = (commentRequired && !comment) || errors.comment;
return Boolean(cannotSendTag || cannotSendComment);
}, [comment, commentRequired, errors, tagRequired, tags]);
const cannotSendTranscriptEmail = transcriptEmail && (!visitorEmail || !subject);
return Boolean(cannotSendTag || cannotSendComment || cannotSendTranscriptEmail);
}, [comment, commentRequired, errors, tagRequired, tags, transcriptEmail, visitorEmail, subject]);
useEffect(() => {
if (department?.requestTagBeforeClosingChat) {
@ -80,11 +113,21 @@ const CloseChatModal = ({
}
}, [register, tagRequired]);
return commentRequired || tagRequired ? (
useEffect(() => {
if (transcriptEmail) {
if (!visitorEmail) {
dispatchToastMessage({ type: 'error', message: t('Customer_without_registered_email') });
return;
}
setValue('subject', subject || t('Transcript_of_your_livechat_conversation'));
}
}, [transcriptEmail, setValue, visitorEmail, subject, t]);
return commentRequired || tagRequired || canSendTranscript ? (
<Modal is='form' onSubmit={handleSubmit(onSubmit)}>
<Modal.Header>
<Modal.Icon name='baloon-close-top-right' />
<Modal.Title>{t('Closing_chat')}</Modal.Title>
<Modal.Title>{t('Wrap_up_conversation')}</Modal.Title>
<Modal.Close onClick={onCancel} />
</Modal.Header>
<Modal.Content fontScale='p2'>
@ -110,6 +153,70 @@ const CloseChatModal = ({
<Tags tagRequired={tagRequired} tags={tags} handler={handleTags} />
<Field.Error>{errors.tags?.message}</Field.Error>
</Field>
{canSendTranscript && (
<>
<Field>
<Divider />
<Field.Label marginBlockStart='x8'>{t('Chat_transcript')}</Field.Label>
</Field>
{canSendTranscriptPDF && (
<Field marginBlockStart='x10'>
<Field.Row>
<CheckBox id='transcript-pdf' {...register('transcriptPDF', { value: userTranscriptPDF })} />
<Field.Label htmlFor='transcript-pdf' color='default' fontScale='c1'>
{t('Omnichannel_transcript_pdf')}
</Field.Label>
</Field.Row>
</Field>
)}
{canSendTranscriptEmail && (
<>
<Field marginBlockStart='x10'>
<Field.Row>
<CheckBox id='transcript-email' {...register('transcriptEmail', { value: userTranscriptEmail })} />
<Field.Label htmlFor='transcript-email' color='default' fontScale='c1'>
{t('Omnichannel_transcript_email')}
</Field.Label>
</Field.Row>
</Field>
{transcriptEmail && (
<>
<Field marginBlockStart='x14'>
<Field.Label required>{t('Contact_email')}</Field.Label>
<Field.Row>
<EmailInput value={visitorEmail} required disabled flexGrow={1} />
</Field.Row>
</Field>
<Field marginBlockStart='x12'>
<Field.Label required>{t('Subject')}</Field.Label>
<Field.Row>
<TextInput
{...register('subject', { required: true })}
className='active'
error={
errors.subject &&
t('error-the-field-is-required', {
field: t('Subject'),
})
}
flexGrow={1}
/>
</Field.Row>
<Field.Error>{errors.subject?.message}</Field.Error>
</Field>
</>
)}
</>
)}
<Field marginBlockStart='x16'>
<Field.Label color='annotation' fontScale='c1'>
{canSendTranscriptPDF && canSendTranscriptEmail
? t('These_options_affect_this_conversation_only_To_set_default_selections_go_to_My_Account_Omnichannel')
: t('This_option_affect_this_conversation_only_To_set_default_selection_go_to_My_Account_Omnichannel')}
</Field.Label>
</Field>
</>
)}
</Modal.Content>
<Modal.Footer>
<Modal.FooterControllers>

@ -9,12 +9,18 @@ import CloseChatModal from './CloseChatModal';
const CloseChatModalData = ({
departmentId,
visitorEmail,
onCancel,
onConfirm,
}: {
departmentId: ILivechatDepartment['_id'];
onCancel: () => void;
onConfirm: (comment?: string, tags?: string[]) => Promise<void>;
visitorEmail?: string;
onConfirm: (
comment?: string,
tags?: string[],
preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean },
) => Promise<void>;
}): ReactElement => {
const { value: data, phase: state } = useEndpointData('/v1/livechat/department/:_id', { keys: { _id: departmentId } });
@ -31,6 +37,7 @@ const CloseChatModalData = ({
<CloseChatModal
onCancel={onCancel}
onConfirm={onConfirm}
visitorEmail={visitorEmail}
department={
(
data as {

@ -110,11 +110,12 @@ const TranscriptModal: FC<TranscriptModalProps> = ({
<Modal.Footer>
<Modal.FooterControllers>
<Button onClick={onCancel}>{t('Cancel')}</Button>
{roomOpen && transcriptRequest ? (
{roomOpen && transcriptRequest && (
<Button danger onClick={handleDiscard}>
{t('Discard')}
{t('Undo_request')}
</Button>
) : (
)}
{roomOpen && !transcriptRequest && (
<Button disabled={!canSave} primary onClick={handleRequest}>
{t('Request')}
</Button>

@ -35,7 +35,11 @@ export const GenericFileAttachment: FC<MessageAttachmentBase> = ({
<MessageGenericPreviewContent
thumb={<MessageGenericPreviewIcon name='attachment-file' type={format || getFileExtension(title)} />}
>
<MessageGenericPreviewTitle externalUrl={hasDownload && link ? getURL(link) : undefined} data-qa-type='attachment-title-link'>
<MessageGenericPreviewTitle
externalUrl={hasDownload && link ? getURL(link) : undefined}
data-qa-type='attachment-title-link'
download={hasDownload}
>
{title}
</MessageGenericPreviewTitle>
{size && (

@ -0,0 +1,68 @@
import { ButtonGroup, Button, Box, Accordion } from '@rocket.chat/fuselage';
import { useToastMessageDispatch, useTranslation, useEndpoint, useUserPreference } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import type { UseFormRegister } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import Page from '../../../components/Page';
import PreferencesConversationTranscript from './PreferencesConversationTranscript';
type CurrentData = {
omnichannelTranscriptPDF: boolean;
omnichannelTranscriptEmail: boolean;
};
export type FormSectionProps = {
register: UseFormRegister<CurrentData>;
};
const OmnichannelPreferencesPage = (): ReactElement => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const omnichannelTranscriptPDF = useUserPreference<boolean>('omnichannelTranscriptPDF') ?? false;
const omnichannelTranscriptEmail = useUserPreference<boolean>('omnichannelTranscriptEmail') ?? false;
const {
handleSubmit,
register,
formState: { isDirty },
reset,
} = useForm({
defaultValues: { omnichannelTranscriptPDF, omnichannelTranscriptEmail },
});
const saveFn = useEndpoint('POST', '/v1/users.setPreferences');
const handleSave = async (data: CurrentData) => {
try {
await saveFn({ data });
reset(data);
dispatchToastMessage({ type: 'success', message: t('Preferences_saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<Page>
<Page.Header title={t('Omnichannel')}>
<ButtonGroup>
<Button primary disabled={!isDirty} onClick={handleSubmit(handleSave)}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</Page.Header>
<Page.ScrollableContentWithShadow is='form' onSubmit={handleSubmit(handleSave)}>
<Box maxWidth='x600' w='full' alignSelf='center'>
<Accordion>
<PreferencesConversationTranscript register={register} />
</Accordion>
</Box>
</Page.ScrollableContentWithShadow>
</Page>
);
};
export default OmnichannelPreferencesPage;

@ -0,0 +1,64 @@
import { Accordion, Box, Field, FieldGroup, Tag, ToggleSwitch } from '@rocket.chat/fuselage';
import { useTranslation, usePermission } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule';
import type { FormSectionProps } from './OmnichannelPreferencesPage';
const PreferencesConversationTranscript = ({ register }: FormSectionProps): ReactElement | null => {
const t = useTranslation();
const hasLicense = useHasLicenseModule('livechat-enterprise');
const canSendTranscriptPDF = usePermission('request-pdf-transcript');
const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript');
const cantSendTranscriptPDF = !canSendTranscriptPDF || !hasLicense;
return (
<Accordion.Item defaultExpanded title={t('Conversational_transcript')}>
<FieldGroup>
<Field>
<Box display='flex' alignItems='center' flexDirection='row' justifyContent='spaceBetween' flexGrow={1}>
<Field.Label color={cantSendTranscriptPDF ? 'disabled' : undefined}>
<Box display='flex' alignItems='center'>
{t('Omnichannel_transcript_pdf')}
<Box marginInline={4}>
{!hasLicense && <Tag variant='featured'>{t('Enterprise')}</Tag>}
{!canSendTranscriptPDF && hasLicense && <Tag>{t('No_permission')}</Tag>}
</Box>
</Box>
</Field.Label>
<Field.Row>
<ToggleSwitch disabled={cantSendTranscriptPDF} {...register('omnichannelTranscriptPDF')} />
</Field.Row>
</Box>
<Field.Hint color={cantSendTranscriptPDF ? 'disabled' : undefined}>
{t('Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description')}
</Field.Hint>
</Field>
<Field>
<Box display='flex' alignItems='center' flexDirection='row' justifyContent='spaceBetween' flexGrow={1}>
<Field.Label color={!canSendTranscriptEmail ? 'disabled' : undefined}>
<Box display='flex' alignItems='center'>
{t('Omnichannel_transcript_email')}
{!canSendTranscriptEmail && (
<Box marginInline={4}>
<Tag>{t('No_permission')}</Tag>
</Box>
)}
</Box>
</Field.Label>
<Field.Row>
<ToggleSwitch disabled={!canSendTranscriptEmail} {...register('omnichannelTranscriptEmail')} />
</Field.Row>
</Box>
<Field.Hint color={!canSendTranscriptEmail ? 'disabled' : undefined}>
{t('Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description')}
</Field.Hint>
</Field>
</FieldGroup>
</Accordion.Item>
);
};
export default PreferencesConversationTranscript;

@ -32,3 +32,8 @@ registerAccountRoute('/tokens', {
name: 'tokens',
component: lazy(() => import('./tokens/AccountTokensRoute')),
});
registerAccountRoute('/omnichannel', {
name: 'omnichannel',
component: lazy(() => import('./omnichannel/OmnichannelPreferencesPage')),
});

@ -1,4 +1,4 @@
import { hasPermission } from '../../../app/authorization/client';
import { hasPermission, hasAtLeastOnePermission } from '../../../app/authorization/client';
import { settings } from '../../../app/settings/client';
import { createSidebarItems } from '../../lib/createSidebarItems';
@ -37,4 +37,10 @@ export const {
icon: 'key',
permissionGranted: (): boolean => hasPermission('create-personal-access-tokens'),
},
{
href: 'omnichannel',
i18nLabel: 'Omnichannel',
icon: 'headset',
permissionGranted: (): boolean => hasAtLeastOnePermission(['send-omnichannel-chat-transcript', 'request-pdf-transcript']),
},
]);

@ -10,7 +10,6 @@ import type { ToolboxActionConfig } from '../../lib/Toolbox';
import RoomHeader from '../RoomHeader';
import { BackButton } from './BackButton';
import QuickActions from './QuickActions';
import { useQuickActions } from './QuickActions/hooks/useQuickActions';
type OmnichannelRoomHeaderProps = {
slots: {
@ -31,7 +30,6 @@ const OmnichannelRoomHeader: FC<OmnichannelRoomHeaderProps> = ({ slots: parentSl
const [name] = useCurrentRoute();
const { isMobile } = useLayout();
const room = useOmnichannelRoom();
const { visibleActions, getAction } = useQuickActions(room);
const toolbox = useToolboxContext();
const slots = useMemo(
@ -43,7 +41,7 @@ const OmnichannelRoomHeader: FC<OmnichannelRoomHeaderProps> = ({ slots: parentSl
{<BackButton routeName={name} />}
</TemplateHeader.ToolBox>
),
...(!isMobile && { insideContent: <QuickActions room={room} /> }),
posContent: <QuickActions room={room} />,
}),
[isMobile, name, parentSlot, room],
);
@ -52,21 +50,9 @@ const OmnichannelRoomHeader: FC<OmnichannelRoomHeaderProps> = ({ slots: parentSl
value={useMemo(
() => ({
...toolbox,
actions: new Map([
...(isMobile
? (visibleActions.map((action) => [
action.id,
{
...action,
action: (): unknown => getAction(action.id),
order: (action.order || 0) - 10,
},
]) as [string, ToolboxActionConfig][])
: []),
...(Array.from(toolbox.actions.entries()) as [string, ToolboxActionConfig][]),
]),
actions: new Map([...(Array.from(toolbox.actions.entries()) as [string, ToolboxActionConfig][])]),
}),
[toolbox, isMobile, visibleActions, getAction],
[toolbox],
)}
>
<RoomHeader slots={slots} room={room} />

@ -5,6 +5,7 @@ import { useTranslation } from '@rocket.chat/ui-contexts';
import type { FC, ComponentProps } from 'react';
import React, { memo } from 'react';
import ToolBoxActionOptions from './ToolBoxActionOptions';
import { useQuickActions } from './hooks/useQuickActions';
type QuickActionsProps = {
@ -18,7 +19,7 @@ const QuickActions: FC<QuickActionsProps> = ({ room, className }) => {
return (
<Header.ToolBox aria-label={t('Omnichannel_quick_actions')}>
{visibleActions.map(({ id, color, icon, title, action = actionDefault }, index) => {
{visibleActions.map(({ id, color, icon, title, action = actionDefault, options }, index) => {
const props = {
id,
icon,
@ -29,10 +30,16 @@ const QuickActions: FC<QuickActionsProps> = ({ room, className }) => {
primary: false,
action,
key: id,
room,
};
if (options) {
return <ToolBoxActionOptions options={options} {...props} />;
}
return <Header.ToolBox.Action {...props} />;
})}
{visibleActions.length > 0 && <Header.ToolBox.Divider />}
</Header.ToolBox>
);
};

@ -0,0 +1,49 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { Box, Dropdown, Option } from '@rocket.chat/fuselage';
import { Header } from '@rocket.chat/ui-client';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { FC } from 'react';
import React, { memo, useRef } from 'react';
import { useDropdownVisibility } from '../../../../../sidebar/header/hooks/useDropdownVisibility';
import type { QuickActionsActionOptions } from '../../../lib/QuickActions';
type ToolBoxActionOptionsProps = {
options: QuickActionsActionOptions;
action: (id: string) => void;
room: IOmnichannelRoom;
};
const ToolBoxActionOptions: FC<ToolBoxActionOptionsProps> = ({ options, room, action, ...props }) => {
const t = useTranslation();
const reference = useRef(null);
const target = useRef(null);
const { isVisible, toggle } = useDropdownVisibility({ reference, target });
const handleClick = (id: string) => (): void => {
toggle();
action(id);
};
return (
<>
<Header.ToolBox.Action ref={reference} onClick={(): void => toggle()} secondary={isVisible} {...props} />
{isVisible && (
<Dropdown reference={reference} ref={target}>
{options.map(({ id, label, validate }) => {
const { value: valid = true, tooltip } = validate?.(room) || {};
return (
<Option key={id} onClick={handleClick(id)} disabled={!valid} title={!valid && tooltip ? t(tooltip) : undefined}>
<Box fontScale='p2m' minWidth='180px'>
{t(label)}
</Box>
</Option>
);
})}
</Dropdown>
)}
</>
);
};
export default memo(ToolBoxActionOptions);

@ -17,6 +17,7 @@ import React, { useCallback, useState, useEffect } from 'react';
import { RoomManager } from '../../../../../../../app/ui-utils/client';
import PlaceChatOnHoldModal from '../../../../../../../ee/app/livechat-enterprise/client/components/modals/PlaceChatOnHoldModal';
import { useHasLicenseModule } from '../../../../../../../ee/client/hooks/useHasLicenseModule';
import CloseChatModal from '../../../../../../components/Omnichannel/modals/CloseChatModal';
import CloseChatModalData from '../../../../../../components/Omnichannel/modals/CloseChatModalData';
import ForwardChatModal from '../../../../../../components/Omnichannel/modals/ForwardChatModal';
@ -84,7 +85,7 @@ export const useQuickActions = (
closeModal();
dispatchToastMessage({
type: 'success',
message: t('Livechat_transcript_has_been_requested'),
message: t('Livechat_email_transcript_has_been_requested'),
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
@ -93,6 +94,20 @@ export const useQuickActions = (
[closeModal, dispatchToastMessage, requestTranscript, rid, t],
);
const sendTranscriptPDF = useEndpoint('POST', '/v1/omnichannel/:rid/request-transcript', { rid });
const handleSendTranscriptPDF = useCallback(async () => {
try {
await sendTranscriptPDF();
dispatchToastMessage({
type: 'success',
message: t('Livechat_transcript_has_been_requested'),
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}, [dispatchToastMessage, sendTranscriptPDF, t]);
const sendTranscript = useMethod('livechat:sendTranscript');
const handleSendTranscript = useCallback(
@ -164,19 +179,39 @@ export const useQuickActions = (
[closeModal, dispatchToastMessage, forwardChat, room.t, rid, homeRoute, t],
);
const closeChat = useMethod('livechat:closeRoom');
const closeChat = useEndpoint('POST', '/v1/livechat/room.closeByUser');
const handleClose = useCallback(
async (comment?: string, tags?: string[]) => {
async (
comment?: string,
tags?: string[],
preferences?: { omnichannelTranscriptPDF: boolean; omnichannelTranscriptEmail: boolean },
requestData?: { email: string; subject: string },
) => {
try {
await closeChat(rid, comment, { clientAction: true, tags });
await closeChat({
rid,
...(comment && { comment }),
...(tags && { tags }),
...(preferences?.omnichannelTranscriptPDF && { generateTranscriptPdf: true }),
...(preferences?.omnichannelTranscriptEmail && requestData
? {
transcriptEmail: {
sendToVisitor: preferences?.omnichannelTranscriptEmail,
requestData,
},
}
: { transcriptEmail: { sendToVisitor: false } }),
});
homeRoute.push();
RoomManager.close(room.t + rid);
closeModal();
dispatchToastMessage({ type: 'success', message: t('Chat_closed_successfully') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[closeChat, closeModal, dispatchToastMessage, rid, t],
[closeChat, closeModal, dispatchToastMessage, homeRoute, room.t, rid, t],
);
const returnChatToQueueMutation = useReturnChatToQueueMutation({
@ -204,7 +239,7 @@ export const useQuickActions = (
},
});
const openModal = useMutableCallback(async (id: string) => {
const handleAction = useMutableCallback(async (id: string) => {
switch (id) {
case QuickActionsEnum.MoveQueue:
setModal(
@ -216,7 +251,10 @@ export const useQuickActions = (
/>,
);
break;
case QuickActionsEnum.Transcript:
case QuickActionsEnum.TranscriptPDF:
handleSendTranscriptPDF();
break;
case QuickActionsEnum.TranscriptEmail:
const visitorEmail = await getVisitorEmail();
if (!visitorEmail) {
@ -239,11 +277,12 @@ export const useQuickActions = (
setModal(<ForwardChatModal room={room} onForward={handleForwardChat} onCancel={closeModal} />);
break;
case QuickActionsEnum.CloseChat:
const email = await getVisitorEmail();
setModal(
room.departmentId ? (
<CloseChatModalData departmentId={room.departmentId} onConfirm={handleClose} onCancel={closeModal} />
<CloseChatModalData visitorEmail={email} departmentId={room.departmentId} onConfirm={handleClose} onCancel={closeModal} />
) : (
<CloseChatModal onConfirm={handleClose} onCancel={closeModal} />
<CloseChatModal visitorEmail={email} onConfirm={handleClose} onCancel={closeModal} />
),
);
break;
@ -273,7 +312,9 @@ export const useQuickActions = (
const roomOpen = room?.open && (room.u?._id === uid || hasManagerRole) && room?.lastMessage?.t !== 'livechat-close';
const canMoveQueue = !!omnichannelRouteConfig?.returnQueue && room?.u !== undefined;
const canForwardGuest = usePermission('transfer-livechat-guest');
const canSendTranscript = usePermission('send-omnichannel-chat-transcript');
const canSendTranscriptEmail = usePermission('send-omnichannel-chat-transcript');
const hasLicense = useHasLicenseModule('livechat-enterprise');
const canSendTranscriptPDF = usePermission('request-pdf-transcript');
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);
@ -285,7 +326,11 @@ export const useQuickActions = (
case QuickActionsEnum.ChatForward:
return !!roomOpen && canForwardGuest;
case QuickActionsEnum.Transcript:
return canSendTranscript;
return canSendTranscriptEmail || (hasLicense && canSendTranscriptPDF);
case QuickActionsEnum.TranscriptEmail:
return canSendTranscriptEmail;
case QuickActionsEnum.TranscriptPDF:
return hasLicense && canSendTranscriptPDF;
case QuickActionsEnum.CloseChat:
return !!roomOpen && (canCloseRoom || canCloseOthersRoom);
case QuickActionsEnum.OnHoldChat:
@ -296,14 +341,20 @@ export const useQuickActions = (
return false;
};
const visibleActions = actions.filter(({ id }) => hasPermissionButtons(id));
const visibleActions = actions.filter((action) => {
const { options, id } = action;
if (options) {
action.options = options.filter(({ id }) => hasPermissionButtons(id));
}
return hasPermissionButtons(id);
});
const actionDefault = useMutableCallback((actionId) => {
openModal(actionId);
handleAction(actionId);
});
const getAction = useMutableCallback((id) => {
openModal(id);
handleAction(id);
});
return { visibleActions, actionDefault, getAction };

@ -117,7 +117,7 @@ const ToolBox = ({ className }: ToolBoxProps): ReactElement => {
}
return <Header.ToolBox.Action {...props} />;
})}
{filteredActions.length > 6 && (
{(filteredActions.length > 6 || isMobile) && (
<Menu
data-qa-id='ToolBox-Menu'
tiny={!isMobile}

@ -19,15 +19,26 @@ addAction(QuickActionsEnum.ChatForward, {
addAction(QuickActionsEnum.Transcript, {
groups: ['live'],
id: QuickActionsEnum.Transcript,
title: 'Transcript',
title: 'Send_transcript',
icon: 'mail-arrow-top-right',
order: 3,
options: [
{ label: 'Send_via_email', id: QuickActionsEnum.TranscriptEmail },
{
label: 'Export_as_PDF',
id: QuickActionsEnum.TranscriptPDF,
validate: (room) => ({
tooltip: 'Export_enabled_at_the_end_of_the_conversation',
value: !room?.open,
}),
},
],
});
addAction(QuickActionsEnum.CloseChat, {
groups: ['live'],
id: QuickActionsEnum.CloseChat,
title: 'Close',
title: 'End_conversation',
icon: 'balloon-close-top-right',
order: 5,
color: 'danger',

@ -1,5 +1,6 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { Box, Option } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ReactNode } from 'react';
import type { ToolboxActionConfig } from '../Toolbox';
@ -20,9 +21,18 @@ type OptionRendererProps = ComponentProps<typeof Option>;
export type OptionRenderer = (props: OptionRendererProps) => ReactNode;
export type QuickActionsActionOptions = Array<{
id: string;
label: TranslationKey;
enabled?: boolean;
validate?: (room: IRoom) => { value: boolean; tooltip: TranslationKey };
}>;
export type QuickActionsActionConfig = ToolboxActionConfig & {
action?: (id?: QuickActionsActionConfig['id']) => void;
groups: Array<'live'>;
color?: string;
options?: QuickActionsActionOptions;
};
export type QuickActionsAction = QuickActionsHook | QuickActionsActionConfig;
@ -37,6 +47,8 @@ export enum QuickActionsEnum {
MoveQueue = 'rocket-move-to-queue',
ChatForward = 'rocket-chat-forward',
Transcript = 'rocket-transcript',
TranscriptEmail = 'rocket-transcript-email',
TranscriptPDF = 'rocket-transcript-pdf',
CloseChat = 'rocket-close-chat',
OnHoldChat = 'rocket-on-hold-chat',
}

@ -25,12 +25,12 @@ type RoomProviderProps = {
const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
useRoomRolesManagement(rid);
const roomQuery = useReactiveQuery(['rooms', rid], ({ rooms }) => rooms.findOne({ _id: rid }));
const roomQuery = useReactiveQuery(['rooms', rid], ({ rooms }) => rooms.findOne({ _id: rid }) ?? null);
// TODO: the following effect is a workaround while we don't have a general and definitive solution for it
const homeRoute = useRoute('home');
useEffect(() => {
if (roomQuery.isSuccess && !roomQuery.data) {
if (roomQuery.isSuccess && roomQuery.data === undefined) {
homeRoute.push();
}
}, [roomQuery.isSuccess, roomQuery.data, homeRoute]);
@ -103,7 +103,7 @@ const RoomProvider = ({ rid, children }: RoomProviderProps): ReactElement => {
const api = useMemo(() => ({}), []);
if (!pseudoRoom) {
return roomQuery.isSuccess ? <RoomNotFound /> : <RoomSkeleton />;
return roomQuery.isSuccess && roomQuery.data === undefined ? <RoomNotFound /> : <RoomSkeleton />;
}
return (

@ -1,6 +1,6 @@
import { log } from 'console';
import { CannedResponse, LivechatPriority, LivechatTag, LivechatUnit } from '@rocket.chat/models';
import { CannedResponse, LivechatPriority, LivechatRooms, LivechatTag, LivechatUnit } from '@rocket.chat/models';
import { Analytics } from '@rocket.chat/core-services';
import { getModules, getTags, hasLicense } from './license';
@ -18,6 +18,8 @@ type EEOnlyStats = {
cannedResponses: number;
priorities: number;
businessUnits: number;
omnichannelPdfTranscriptRequested: number;
omnichannelPdfTranscriptSucceeded: number;
};
export async function getStatistics(): Promise<ENTERPRISE_STATISTICS> {
@ -81,6 +83,24 @@ async function getEEStatistics(): Promise<EEOnlyStats | undefined> {
}),
);
// Number of PDF transcript requested
statsPms.push(
LivechatRooms.find({ pdfTranscriptRequested: { $exists: true } })
.count()
.then((count) => {
statistics.omnichannelPdfTranscriptRequested = count;
}),
);
// Number of PDF transcript that succeeded
statsPms.push(
LivechatRooms.find({ pdfFileId: { $exists: true } })
.count()
.then((count) => {
statistics.omnichannelPdfTranscriptSucceeded = count;
}),
);
await Promise.all(statsPms).catch(log);
return statistics as EEOnlyStats;

@ -7,3 +7,4 @@ import './tags';
import './units';
import './business-hours';
import './rooms';
import './transcript';

@ -0,0 +1,37 @@
import { LivechatRooms } from '@rocket.chat/models';
import { OmnichannelTranscript } from '@rocket.chat/core-services';
import { API } from '../../../../../app/api/server';
import { canAccessRoomAsync } from '../../../../../app/authorization/server/functions/canAccessRoom';
API.v1.addRoute(
'omnichannel/:rid/request-transcript',
{ authRequired: true, permissionsRequired: ['request-pdf-transcript'] },
{
async post() {
const room = await LivechatRooms.findOneById(this.urlParams.rid);
if (!room) {
throw new Error('error-invalid-room');
}
if (!(await canAccessRoomAsync(room, { _id: this.userId }))) {
throw new Error('error-not-allowed');
}
// Flow is as follows:
// 1. Call OmnichannelTranscript.requestTranscript()
// 2. OmnichannelTranscript.requestTranscript() calls QueueWorker.queueWork()
// 3. QueueWorker.queueWork() eventually calls OmnichannelTranscript.workOnPdf()
// 4. OmnichannelTranscript.workOnPdf() calls OmnichannelTranscript.pdfComplete() when processing ends
// 5. OmnichannelTranscript.pdfComplete() sends the messages to the user, and updates the room with the flags
await OmnichannelTranscript.requestTranscript({
details: {
userId: this.userId,
rid: this.urlParams.rid,
},
});
return API.v1.success();
},
},
);

@ -23,3 +23,4 @@ import './applyDepartmentRestrictions';
import './afterForwardChatToAgent';
import './applySimultaneousChatsRestrictions';
import './afterInquiryQueued';
import './sendPdfTranscriptOnClose';

@ -1,5 +1,7 @@
import { Subscriptions } from '@rocket.chat/models';
import { callbacks } from '../../../../../lib/callbacks';
import { LivechatInquiry, Subscriptions, LivechatRooms } from '../../../../../app/models/server';
import { LivechatInquiry, LivechatRooms } from '../../../../../app/models/server';
import { queueInquiry } from '../../../../../app/livechat/server/lib/QueueManager';
import { settings } from '../../../../../app/settings/server';
import { cbLogger } from '../lib/logger';
@ -29,7 +31,7 @@ const handleOnAgentAssignmentFailed = async ({
const { _id: inquiryId } = inquiry;
LivechatInquiry.queueInquiryAndRemoveDefaultAgent(inquiryId);
LivechatRooms.removeAgentByRoomId(roomId);
Subscriptions.removeByRoomId(roomId);
await Subscriptions.removeByRoomId(roomId);
dispatchAgentDelegated(roomId, null);
const newInquiry = LivechatInquiry.findOneById(inquiryId);

@ -3,17 +3,19 @@ import { settings } from '../../../../../app/settings/server';
import { debouncedDispatchWaitingQueueStatus } from '../lib/Helper';
import { LivechatEnterprise } from '../lib/LivechatEnterprise';
const onCloseLivechat = (room) => {
const onCloseLivechat = (params) => {
const { room } = params;
Promise.await(LivechatEnterprise.releaseOnHoldChat(room));
if (!settings.get('Livechat_waiting_queue')) {
return room;
return params;
}
const { departmentId } = room || {};
debouncedDispatchWaitingQueueStatus(departmentId);
return room;
return params;
};
callbacks.add('livechat.closeRoom', onCloseLivechat, callbacks.priority.HIGH, 'livechat-waiting-queue-monitor-close-room');

@ -1,10 +1,16 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import type { IMessage, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { AutoTransferChatScheduler } from '../lib/AutoTransferChatScheduler';
import { callbacks } from '../../../../../lib/callbacks';
import { settings } from '../../../../../app/settings/server';
import { LivechatRooms } from '../../../../../app/models/server';
import { cbLogger } from '../lib/logger';
import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/LivechatTyped.d';
type LivechatCloseCallbackParams = {
room: IOmnichannelRoom;
options: CloseRoomParams['options'];
};
let autoTransferTimeout = 0;
@ -56,23 +62,25 @@ const handleAfterSaveMessage = (message: any = {}, room: any = {}): IMessage =>
return message;
};
const handleAfterCloseRoom = (room: any = {}): IRoom => {
const handleAfterCloseRoom = (params: LivechatCloseCallbackParams): LivechatCloseCallbackParams => {
const { room } = params;
const { _id: rid, autoTransferredAt, autoTransferOngoing } = room;
if (!autoTransferTimeout || autoTransferTimeout <= 0) {
return room;
return params;
}
if (autoTransferredAt) {
return room;
return params;
}
if (!autoTransferOngoing) {
return room;
return params;
}
Promise.await(AutoTransferChatScheduler.unscheduleRoom(rid));
return room;
return params;
};
settings.watch('Livechat_auto_transfer_chat_timeout', function (value) {

@ -0,0 +1,42 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
import { OmnichannelTranscript } from '@rocket.chat/core-services';
import { callbacks } from '../../../../../lib/callbacks';
import type { CloseRoomParams } from '../../../../../app/livechat/server/lib/LivechatTyped.d';
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 OmnichannelTranscript.requestTranscript({
details: {
userId: requestedBy,
rid: room._id,
},
});
return params;
};
callbacks.add(
'livechat.closeRoom',
(params: LivechatCloseCallbackParams) => Promise.await(sendPdfTranscriptOnClose(params)),
callbacks.priority.HIGH,
'livechat-send-pdf-transcript-on-close-room',
);

@ -5,7 +5,7 @@ import moment from 'moment';
import { LivechatRooms, Users } from '@rocket.chat/models';
import type { IUser } from '@rocket.chat/core-typings';
import { Livechat } from '../../../../../app/livechat/server';
import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped';
const SCHEDULER_NAME = 'omnichannel_auto_close_on_hold_scheduler';
@ -49,15 +49,20 @@ class AutoCloseOnHoldSchedulerClass {
private async executeJob({ attrs: { data } }: any = {}): Promise<void> {
const { roomId, comment } = data;
const [room, user] = await Promise.all([LivechatRooms.findOneById(roomId), this.getSchedulerUser()]);
if (!room || !user) {
throw new Error(
`Unable to process AutoCloseOnHoldScheduler job because room or user not found for roomId: ${roomId} and userId: rocket.cat`,
);
}
const payload = {
user: await this.getSchedulerUser(),
room: await LivechatRooms.findOneById(roomId),
room,
user,
comment,
options: {},
visitor: undefined,
};
Livechat.closeRoom(payload);
await Livechat.closeRoom(payload);
}
private async getSchedulerUser(): Promise<IUser> {

@ -4,11 +4,12 @@ import { MongoInternals } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import type { IUser, IOmnichannelRoom } from '@rocket.chat/core-typings';
import { LivechatRooms as LivechatRoomsRaw, LivechatInquiry as LivechatInquiryRaw } from '@rocket.chat/models';
import { settings } from '../../../../../app/settings/server';
import { Logger } from '../../../../../app/logger/server';
import { LivechatRooms, Users, LivechatInquiry } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
import { Users } from '../../../../../app/models/server';
import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped';
const SCHEDULER_NAME = 'omnichannel_queue_inactivity_monitor';
@ -92,29 +93,33 @@ export class OmnichannelQueueInactivityMonitorClass {
await this.scheduler.cancel({ name });
}
closeRoomAction(room: IOmnichannelRoom): void {
closeRoomAction(room: IOmnichannelRoom): Promise<void> {
const comment = this.message;
Livechat.closeRoom({
return Livechat.closeRoom({
comment,
room,
user: this.user,
visitor: null,
});
}
closeRoom({ attrs: { data } }: any = {}): void {
const { inquiryId } = data;
const inquiry = LivechatInquiry.findOneById(inquiryId);
const inquiry = Promise.await(LivechatInquiryRaw.findOneById(inquiryId));
this.logger.debug(`Processing inquiry item ${inquiryId}`);
if (!inquiry || inquiry.status !== 'queued') {
this.logger.debug(`Skipping inquiry ${inquiryId}. Invalid or not queued anymore`);
return;
}
this.closeRoomAction(LivechatRooms.findOneById(inquiry.rid));
Promise.await(this.stopInquiry(inquiryId));
this.logger.debug(`Running succesful. Closed inquiry ${inquiry._id} because of inactivity`);
const room = Promise.await(LivechatRoomsRaw.findOneById(inquiry.rid));
if (!room) {
this.logger.error(`Error: unable to find room ${inquiry.rid} for inquiry ${inquiryId} to close in queue inactivity monitor`);
return;
}
Promise.await(Promise.all([this.closeRoomAction(room), this.stopInquiry(inquiryId)]));
this.logger.debug(`Running successful. Closed inquiry ${inquiry._id} because of inactivity`);
}
}

@ -4,7 +4,7 @@ import { Meteor } from 'meteor/meteor';
import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models';
import { settings } from '../../../../../app/settings/server';
import { Livechat } from '../../../../../app/livechat/server/lib/Livechat';
import { Livechat } from '../../../../../app/livechat/server/lib/LivechatTyped';
import { LivechatEnterprise } from './LivechatEnterprise';
import { logger } from './logger';
@ -72,7 +72,7 @@ export class VisitorInactivityMonitor {
if (room.departmentId) {
comment = (await this._getDepartmentAbandonedCustomMessage(room.departmentId)) || comment;
}
Livechat.closeRoom({
await Livechat.closeRoom({
comment,
room,
user: this.user,

@ -6,6 +6,7 @@ export const createPermissions = async (): Promise<void> => {
const livechatMonitorRole = 'livechat-monitor';
const livechatManagerRole = 'livechat-manager';
const adminRole = 'admin';
const livechatAgentRole = 'livechat-agent';
const monitorRole = await Roles.findOneById(livechatMonitorRole, { projection: { _id: 1 } });
if (!monitorRole) {
@ -22,5 +23,6 @@ export const createPermissions = async (): Promise<void> => {
Permissions.create('manage-livechat-canned-responses', [adminRole, livechatManagerRole, livechatMonitorRole]),
Permissions.create('spy-voip-calls', [adminRole, livechatManagerRole, livechatMonitorRole]),
Permissions.create('outbound-voip-calls', [adminRole, livechatManagerRole]),
Permissions.create('request-pdf-transcript', [adminRole, livechatManagerRole, livechatMonitorRole, livechatAgentRole]),
]);
};

@ -207,6 +207,12 @@ export const createSettings = async (): Promise<void> => {
enableQuery: omnichannelEnabledQuery,
});
settingsRegistry.add('Accounts_Default_User_Preferences_omnichannelTranscriptPDF', false, {
type: 'boolean',
public: true,
i18nLabel: 'Omnichannel_transcript_pdf',
});
await Settings.addOptionValueById('Livechat_Routing_Method', {
key: 'Load_Balancing',
i18nLabel: 'Load_Balancing',

@ -1,4 +1,4 @@
import type { ILivechatAgent, ILivechatVisitor, IRoomClosingInfo, IUser, IVoipRoom } from '@rocket.chat/core-typings';
import type { ILivechatAgent, ILivechatVisitor, IVoipRoomClosingInfo, IUser, IVoipRoom } from '@rocket.chat/core-typings';
import type { IOmniRoomClosingMessage } from '../../../../../server/services/omnichannel-voip/internalTypes';
import { OmnichannelVoipService } from '../../../../../server/services/omnichannel-voip/service';
@ -14,12 +14,12 @@ overwriteClassOnLicense('voip-enterprise', OmnichannelVoipService, {
sysMessageId?: 'voip-call-wrapup' | 'voip-call-ended-unexpectedly',
options?: { comment?: string | null; tags?: string[] | null },
) => Promise<boolean>,
closeInfo: IRoomClosingInfo,
closeInfo: IVoipRoomClosingInfo,
closeSystemMsgData: IOmniRoomClosingMessage,
room: IVoipRoom,
sysMessageId: 'voip-call-wrapup' | 'voip-call-ended-unexpectedly',
options?: { comment?: string; tags?: string[] },
): { closeInfo: IRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage } {
): { closeInfo: IVoipRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage } {
const { comment, tags } = options || {};
if (comment) {
closeSystemMsgData.msg = comment;

@ -95,12 +95,11 @@ function TagsRoute() {
[reload, onRowClick],
);
if (context === 'new') {
return <TagEdit reload={reload} />;
}
if (context === 'edit') {
return <TagEditWithData reload={reload} tagId={id} title={context === 'edit' ? t('Edit_Tag') : t('New_Tag')} />;
return <TagEditWithData reload={reload} tagId={id} title={t('Edit_Tag')} />;
}
if (context === 'new') {
return <TagEdit data={data} reload={reload} title={t('New_Tag')} />;
}
if (!canViewTags) {

@ -78,7 +78,7 @@ export class NetworkBroker implements IBroker {
this.broker.destroyService(name);
}
createService(instance: IServiceClass): void {
createService(instance: IServiceClass, serviceDependencies?: string[]): void {
const methods = (
instance.constructor?.name === 'Object'
? Object.getOwnPropertyNames(instance)
@ -97,7 +97,8 @@ export class NetworkBroker implements IBroker {
return;
}
const dependencies = name !== 'license' ? { dependencies: ['license'] } : {};
// Allow services to depend on other services too
const dependencies = name !== 'license' ? { dependencies: ['license', ...(serviceDependencies || [])] } : {};
const service: ServiceSchema = {
name,

@ -26,6 +26,9 @@ import { IntegrationHistoryRaw } from '../../../server/models/raw/IntegrationHis
import { IntegrationsRaw } from '../../../server/models/raw/Integrations';
import { EmailInboxRaw } from '../../../server/models/raw/EmailInbox';
import { PbxEventsRaw } from '../../../server/models/raw/PbxEvents';
import { LivechatRoomsRaw } from '../../../server/models/raw/LivechatRooms';
import { UploadsRaw } from '../../../server/models/raw/Uploads';
import { LivechatVisitorsRaw } from '../../../server/models/raw/LivechatVisitors';
// TODO add trash param to appropiate model instances
export function registerServiceModels(db: Db, trash?: Collection<RocketChatRecordDeleted<any>>): void {
@ -55,4 +58,7 @@ export function registerServiceModels(db: Db, trash?: Collection<RocketChatRecor
registerModel('IIntegrationsModel', () => new IntegrationsRaw(db));
registerModel('IEmailInboxModel', () => new EmailInboxRaw(db));
registerModel('IPbxEventsModel', () => new PbxEventsRaw(db));
registerModel('ILivechatRoomsModel', () => new LivechatRoomsRaw(db));
registerModel('IUploadsModel', () => new UploadsRaw(db));
registerModel('ILivechatVisitorsModel', () => new LivechatVisitorsRaw(db));
}

@ -202,11 +202,18 @@ export class LivechatRoomsRawEE extends LivechatRoomsRaw implements ILivechatRoo
return super.update(restrictedQuery, ...restArgs);
}
updateOne(...args: Parameters<LivechatRoomsRaw['updateOne']>) {
const [query, ...restArgs] = args;
updateOne(...args: Parameters<LivechatRoomsRaw['updateOne']> & { bypassUnits?: boolean }) {
const [query, update, opts, extraOpts] = args;
if (extraOpts?.bypassUnits) {
// When calling updateOne from a service, we cannot call the meteor code inside the query restrictions
// So the solution now is to pass a bypassUnits flag to the updateOne method which prevents checking
// units restrictions on the query, but just for the query the service is actually using
// We need to find a way of remove the meteor dependency when fetching units, and then, we can remove this flag
return super.updateOne(query, update, opts);
}
const restrictedQuery = addQueryRestrictionsToRoomsModel(query);
queriesLogger.debug({ msg: 'LivechatRoomsRawEE.updateOne', query: restrictedQuery });
return super.updateOne(restrictedQuery, ...restArgs);
return super.updateOne(restrictedQuery, update, opts);
}
updateMany(...args: Parameters<LivechatRoomsRaw['updateMany']>) {

@ -100,6 +100,28 @@ services:
depends_on:
- nats
queue-worker-service:
container_name: queue-worker-service
build:
context: .
args:
SERVICE: queue-worker
image: rocketchat/queue-worker-service:latest
env_file: .config/services/service.env
depends_on:
- nats
omnichannel-transcript-service:
container_name: omnichannel-transcript-service
build:
context: .
args:
SERVICE: omnichannel-transcript
image: rocketchat/omnichannel-transcript-service:latest
env_file: .config/services/service.env
depends_on:
- nats
nats:
image: nats
ports:

@ -15,6 +15,7 @@ import type {
ParsedUrl,
OEmbedMeta,
OEmbedUrlContent,
IOmnichannelRoom,
} from '@rocket.chat/core-typings';
import type { Logger } from '../app/logger/server';
@ -22,6 +23,7 @@ import type { IBusinessHourBehavior } from '../app/livechat/server/business-hour
import { getRandomId } from './random';
import type { ILoginAttempt } from '../app/authentication/server/ILoginAttempt';
import { compareByRanking } from './utils/comparisons';
import type { CloseRoomParams } from '../app/livechat/server/lib/LivechatTyped.d';
enum CallbackPriority {
HIGH = -1000,
@ -49,7 +51,7 @@ type EventLikeCallbackSignatures = {
'afterSaveMessage': (message: IMessage, room: IRoom, uid?: string) => void;
'livechat.removeAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void;
'livechat.saveAgentDepartment': (params: { departmentId: ILivechatDepartmentRecord['_id']; agentsId: ILivechatAgent['_id'][] }) => void;
'livechat.closeRoom': (room: IRoom) => void;
'livechat.closeRoom': (params: { room: IOmnichannelRoom; options: CloseRoomParams['options'] }) => void;
'livechat.saveRoom': (room: IRoom) => void;
'livechat:afterReturnRoomAsInquiry': (params: { room: IRoom }) => void;
'livechat.setUserStatusLivechat': (params: { userId: IUser['_id']; status: OmnichannelAgentStatus }) => void;
@ -112,7 +114,6 @@ type ChainedCallbackSignatures = {
agentsId: ILivechatAgent['_id'][];
};
'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined;
'livechat.beforeCloseRoom': (params: { room: IRoom; options: unknown }) => { room: IRoom; options: unknown };
'livechat.beforeDelegateAgent': (agent: ILivechatAgent, params: { department?: ILivechatDepartmentRecord }) => ILivechatAgent | null;
'livechat.applyDepartmentRestrictions': (
query: FilterOperators<ILivechatDepartmentRecord>,

@ -206,6 +206,7 @@
"@nivo/line": "0.79.1",
"@nivo/pie": "0.79.1",
"@react-aria/color": "^3.0.0-beta.15",
"@react-pdf/renderer": "^3.1.3",
"@rocket.chat/agenda": "workspace:^",
"@rocket.chat/api-client": "workspace:^",
"@rocket.chat/apps-engine": "1.36.0",
@ -233,11 +234,14 @@
"@rocket.chat/model-typings": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/mp3-encoder": "0.24.0",
"@rocket.chat/omnichannel-services": "workspace:^",
"@rocket.chat/onboarding-ui": "next",
"@rocket.chat/pdf-worker": "workspace:^",
"@rocket.chat/poplib": "workspace:^",
"@rocket.chat/presence": "workspace:^",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/string-helpers": "next",
"@rocket.chat/tools": "workspace:^",
"@rocket.chat/ui-client": "workspace:^",
"@rocket.chat/ui-composer": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
@ -287,6 +291,7 @@
"dompurify": "^2.3.8",
"ejson": "^2.2.2",
"emailreplyparser": "^0.0.5",
"emoji-toolkit": "^7.0.0",
"emojione": "^4.5.0",
"eslint-plugin-anti-trojan-source": "^1.1.0",
"eventemitter3": "^4.0.7",
@ -336,6 +341,7 @@
"moleculer": "^0.14.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.34",
"mongo-message-queue": "^1.0.0",
"mongodb": "^4.12.1",
"mongodb-memory-server": "^7.6.3",
"nats": "^2.6.1",

@ -944,6 +944,9 @@
"Closed_automatically_because_chat_was_onhold_for_seconds": "Closed automatically because chat was On Hold for __onHoldTime__ seconds",
"Closed_automatically_chat_queued_too_long": "Closed automatically by the system (queue maximum time exceeded)",
"Closed_by_visitor": "Closed by visitor",
"Wrap_up_conversation": "Wrap up conversation",
"These_options_affect_this_conversation_only_To_set_default_selections_go_to_My_Account_Omnichannel": "These options affect this conversation only. To set default selections, go to My Account > Omnichannel.",
"This_option_affect_this_conversation_only_To_set_default_selection_go_to_My_Account_Omnichannel": "This option affect this conversation only. To set default selection, go to My Account > Omnichannel.",
"Closing_chat": "Closing chat",
"Closing_chat_message": "Closing chat message",
"Cloud": "Cloud",
@ -1754,6 +1757,7 @@
"End": "End",
"End_suspicious_sessions": "End any suspicious sessions",
"End_call": "End call",
"End_conversation": "End conversation",
"Expand_view": "Expand view",
"Explore_marketplace": "Explore Marketplace",
"Explore_the_marketplace_to_find_awesome_apps": "Explore the Marketplace to find awesome apps for Rocket.Chat",
@ -1924,6 +1928,7 @@
"error-role-name-required": "Role name is required",
"error-room-does-not-exist": "This room does not exist",
"error-role-already-present": "A role with this name already exists",
"error-room-already-closed": "Room is already closed",
"error-room-is-not-closed": "Room is not closed",
"error-room-onHold": "Error! Room is On Hold",
"error-selected-agent-room-agent-are-same": "The selected agent and the room agent are the same",
@ -2920,7 +2925,8 @@
"Livechat_title": "Livechat Title",
"Livechat_title_color": "Livechat Title Background Color",
"Livechat_transcript_already_requested_warning": "The transcript of this chat has already been requested and will be sent as soon as the conversation ends.",
"Livechat_transcript_has_been_requested": "The chat transcript has been requested.",
"Livechat_transcript_has_been_requested": "Export requested. It may take a few seconds.",
"Livechat_email_transcript_has_been_requested": "The transcript has been requested. It may take a few seconds.",
"Livechat_transcript_request_has_been_canceled": "The chat transcription request has been canceled.",
"Livechat_transcript_sent": "Omnichannel transcript sent",
"Livechat_transfer_return_to_the_queue": "__from__ returned the chat to the queue",
@ -3649,6 +3655,8 @@
"Paid_Apps": "Paid Apps",
"Payload": "Payload",
"PDF": "PDF",
"pdf_success_message": "PDF Transcript successfully generated",
"pdf_error_message": "Error generating PDF Transcript",
"Peer_Password": "Peer Password",
"People": "People",
"Permalink": "Permalink",
@ -3936,6 +3944,8 @@
"Request_comment_when_closing_conversation": "Request comment when closing conversation",
"Request_comment_when_closing_conversation_description": "If enabled, the agent will need to set a comment before the conversation is closed.",
"Request_tag_before_closing_chat": "Request tag(s) before closing conversation",
"request-pdf-transcript": "Request PDF Transcript",
"request-pdf-transcript_description": "Permission to request a PDF transcript for a given Omnichannel room",
"Requested_At": "Requested At",
"Requested_By": "Requested By",
"Require": "Require",
@ -4268,6 +4278,8 @@
"Send_Test_Email": "Send test email",
"Send_via_email": "Send via email",
"Send_via_Email_as_attachment": "Send via Email as attachment",
"Export_as_PDF": "Export as PDF",
"Export_enabled_at_the_end_of_the_conversation": "Export enabled at the end of the conversation",
"Send_Visitor_navigation_history_as_a_message": "Send Visitor Navigation History as a Message",
"Send_visitor_navigation_history_on_request": "Send Visitor Navigation History on Request",
"Send_welcome_email": "Send welcome email",
@ -5501,6 +5513,23 @@
"Theme_match_system": "Match system",
"Join_your_team": "Join your team",
"Create_an_account": "Create an account",
"Chat_transcript": "Chat transcript",
"Conversational_transcript": "Conversational transcript",
"Send_conversation_transcript_via_email": "Send conversation transcript via email",
"Always_send_the_transcript_to_contacts_at_the_end_of_the_conversations": "Always send the transcript to contacts at the end of the conversations.",
"Export_conversation_transcript_as_PDF": "Export conversation transcript as PDF",
"Omnichannel_transcript_email": "Send chat transcript via email.",
"Accounts_Default_User_Preferences_omnichannelTranscriptEmail_Description": "Always send the transcript to contacts at the end of the conversations.",
"Omnichannel_transcript_pdf": "Export chat transcript as PDF.",
"Accounts_Default_User_Preferences_omnichannelTranscriptPDF_Description": "Always export the transcript as PDF at the end of conversations.",
"Contact_email": "Contact email",
"Customer": "Customer",
"Time": "Time",
"Omnichannel_Agent": "Omnichannel Agent",
"This_attachment_is_not_supported": "This attachment is not supported",
"Send_transcript": "Send transcript",
"Undo_request": "Undo request",
"No_permission": "No permission",
"Community_cap_description": "Community workspaces have a cap of 200 concurrent connections, although you can have more connections active, once you hit that limit you won't be able to see users' status. This doesn't affect their ability to send & receive messages.",
"Enterprise_cap_description": "Enterprise workspaces have no cap on the presence service.",
"Service_status": "Service status",

@ -35,6 +35,8 @@ Meteor.methods({
sidebarDisplayAvatar: Match.Optional(Boolean),
sidebarGroupByType: Match.Optional(Boolean),
muteFocusedConversations: Match.Optional(Boolean),
omnichannelTranscriptEmail: Match.Optional(Boolean),
omnichannelTranscriptPDF: Match.Optional(Boolean),
};
check(settings, Match.ObjectIncluding(keys));
const user = Meteor.user();

@ -1,5 +1,5 @@
import type { ILivechatInquiryModel } from '@rocket.chat/model-typings';
import type { Collection, Db, Document, FindOptions, DistinctOptions, UpdateResult } from 'mongodb';
import type { Collection, Db, Document, FindOptions, DistinctOptions, UpdateResult, DeleteResult } from 'mongodb';
import type { ILivechatInquiryRecord, IMessage, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import { LivechatInquiryStatus } from '@rocket.chat/core-typings';
@ -91,4 +91,8 @@ export class LivechatInquiryRaw extends BaseRaw<ILivechatInquiryRecord> implemen
{ $unset: { locked: 1, lockedAt: 1 } },
);
}
async removeByRoomId(rid: string): Promise<DeleteResult> {
return this.deleteOne({ rid });
}
}

@ -1289,6 +1289,113 @@ export class LivechatRoomsRaw extends BaseRaw {
]);
}
// These 3 methods shouldn't be here :( but current EE model has a meteor dependency
// And refactoring it could take time
setTranscriptRequestedPdfById(rid) {
return this.updateOne(
{
_id: rid,
},
{
$set: { pdfTranscriptRequested: true },
},
{},
{
bypassUnits: true,
},
);
}
unsetTranscriptRequestedPdfById(rid) {
return this.updateOne(
{
_id: rid,
},
{
$unset: { pdfTranscriptRequested: 1 },
},
{},
{
bypassUnits: true,
},
);
}
setPdfTranscriptFileIdById(rid, fileId) {
return this.updateOne(
{
_id: rid,
},
{
$set: { pdfTranscriptFileId: fileId },
},
{},
{
bypassUnits: true,
},
);
}
setEmailTranscriptRequestedByRoomId(roomId, transcriptInfo) {
const { requestedAt, requestedBy, email, subject } = transcriptInfo;
return this.updateOne(
{
_id: roomId,
t: 'l',
},
{
$set: {
transcriptRequest: {
requestedAt,
requestedBy,
email,
subject,
},
},
},
);
}
unsetEmailTranscriptRequestedByRoomId(roomId) {
return this.updateOne(
{
_id: roomId,
t: 'l',
},
{
$unset: {
transcriptRequest: 1,
},
},
);
}
closeRoomById(roomId, closeInfo) {
const { closer, closedBy, closedAt, chatDuration, serviceTimeDuration, tags } = closeInfo;
return this.updateOne(
{
_id: roomId,
t: 'l',
},
{
$set: {
closedAt,
'metrics.chatDuration': chatDuration,
'metrics.serviceTimeDuration': serviceTimeDuration,
'v.status': 'offline',
...(closer && { closer }),
...(closedBy && { closedBy }),
...(tags && { tags }),
},
$unset: {
open: 1,
},
},
);
}
bulkRemoveDepartmentAndUnitsFromRooms(departmentId) {
return this.updateMany({ departmentId }, { $unset: { departmentId: 1, departmentAncestors: 1 } });
}

@ -11,6 +11,7 @@ import type {
Filter,
FindOptions,
IndexDescription,
DeleteResult,
} from 'mongodb';
import { escapeRegExp } from '@rocket.chat/string-helpers';
@ -222,6 +223,26 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
);
}
findLivechatMessages(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
return this.find(
{
rid,
$or: [{ t: { $exists: false } }, { t: 'livechat-close' }],
},
options,
);
}
findLivechatMessagesWithoutClosing(rid: IRoom['_id'], options?: FindOptions<IMessage>): FindCursor<IMessage> {
return this.find(
{
rid,
t: { $exists: false },
},
options,
);
}
async setBlocksById(_id: string, blocks: Required<IMessage>['blocks']): Promise<void> {
await this.updateOne(
{ _id },
@ -404,4 +425,8 @@ export class MessagesRaw extends BaseRaw<IMessage> implements IMessagesModel {
.toArray()
)[0] as IMessage;
}
removeByRoomId(roomId: string): Promise<DeleteResult> {
return this.deleteMany({ rid: roomId });
}
}

@ -1,4 +1,4 @@
import type { IRoomClosingInfo, IVoipRoom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { IVoipRoomClosingInfo, IVoipRoom, RocketChatRecordDeleted } from '@rocket.chat/core-typings';
import type { FindPaginated, IVoipRoomModel } from '@rocket.chat/model-typings';
import type { Collection, FindCursor, Db, Filter, FindOptions, UpdateResult, Document } from 'mongodb';
import { escapeRegExp } from '@rocket.chat/string-helpers';
@ -81,7 +81,7 @@ export class VoipRoomRaw extends BaseRaw<IVoipRoom> implements IVoipRoomModel {
return this.findOne(query, options);
}
closeByRoomId(roomId: IVoipRoom['_id'], closeInfo: IRoomClosingInfo): Promise<Document | UpdateResult> {
closeByRoomId(roomId: IVoipRoom['_id'], closeInfo: IVoipRoomClosingInfo): Promise<Document | UpdateResult> {
const { closer, closedBy, closedAt, callDuration, serviceTimeDuration, ...extraData } = closeInfo;
return this.updateOne(

@ -0,0 +1,13 @@
import type { IMessage } from '@rocket.chat/core-typings';
import type { IMessageService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { executeSendMessage } from '../../../app/lib/server/methods/sendMessage';
export class MessageService extends ServiceClassInternal implements IMessageService {
protected name = 'message';
async sendMessage({ fromId, rid, msg }: { fromId: string; rid: string; msg: string }): Promise<IMessage> {
return executeSendMessage(fromId, { rid, msg });
}
}

@ -9,7 +9,7 @@ import type {
ILivechatAgent,
ILivechatVisitor,
IVoipRoom,
IRoomClosingInfo,
IVoipRoomClosingInfo,
} from '@rocket.chat/core-typings';
import { isILivechatVisitor, OmnichannelSourceType, isVoipRoom, VoipClientEvents } from '@rocket.chat/core-typings';
import type { PaginatedResult } from '@rocket.chat/rest-typings';
@ -308,12 +308,12 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
}
getRoomClosingData(
closeInfo: IRoomClosingInfo,
closeInfo: IVoipRoomClosingInfo,
closeSystemMsgData: IOmniRoomClosingMessage,
_room: IVoipRoom,
_sysMessageId: 'voip-call-wrapup' | 'voip-call-ended-unexpectedly',
_options?: { comment?: string; tags?: string[] },
): { closeInfo: IRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage } {
): { closeInfo: IVoipRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage } {
return { closeInfo, closeSystemMsgData };
}
@ -322,11 +322,11 @@ export class OmnichannelVoipService extends ServiceClassInternal implements IOmn
room: IVoipRoom,
sysMessageId: 'voip-call-wrapup' | 'voip-call-ended-unexpectedly',
_options?: { comment?: string; tags?: string[] },
): Promise<{ closeInfo: IRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage }> {
): Promise<{ closeInfo: IVoipRoomClosingInfo; closeSystemMsgData: IOmniRoomClosingMessage }> {
const now = new Date();
const closer = isILivechatVisitor(closerParam) ? 'visitor' : 'user';
const closeData: IRoomClosingInfo = {
const closeData: IVoipRoomClosingInfo = {
closedAt: now,
callDuration: now.getTime() - room.ts.getTime(),
closer,

@ -4,6 +4,7 @@ import { ServiceClassInternal, Authorization } from '@rocket.chat/core-services'
import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services';
import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import
import { createDirectMessage } from '../../methods/createDirectMessage';
export class RoomService extends ServiceClassInternal implements IRoomService {
protected name = 'room';
@ -27,6 +28,18 @@ export class RoomService extends ServiceClassInternal implements IRoomService {
return createRoom(type, name, user.username, members, readOnly, extraData, options) as unknown as IRoom;
}
async createDirectMessage({ to, from }: { to: string; from: string }): Promise<{ rid: string }> {
const [toUser, fromUser] = await Promise.all([
Users.findOneById(to, { projection: { username: 1 } }),
Users.findOneById(from, { projection: { _id: 1 } }),
]);
if (!toUser || !fromUser) {
throw new Error('error-invalid-user');
}
return createDirectMessage([toUser.username], fromUser._id);
}
async addMember(uid: string, rid: string): Promise<boolean> {
const hasPermission = await Authorization.hasPermission(uid, 'add-user-to-joined-room', rid);
if (!hasPermission) {

@ -0,0 +1,12 @@
import type { ISettingsService } from '@rocket.chat/core-services';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import { settings } from '../../../app/settings/server';
export class SettingsService extends ServiceClassInternal implements ISettingsService {
protected name = 'settings';
async get<T>(settingId: string): Promise<T> {
return settings.get<T>(settingId);
}
}

@ -1,5 +1,6 @@
import { MongoInternals } from 'meteor/mongo';
import { api } from '@rocket.chat/core-services';
import { OmnichannelTranscript, QueueWorker } from '@rocket.chat/omnichannel-services';
import { AnalyticsService } from './analytics/service';
import { AppsEngineService } from './apps-engine/service';
@ -22,6 +23,10 @@ import { PushService } from './push/service';
import { DeviceManagementService } from './device-management/service';
import { FederationService } from './federation/service';
import { UploadService } from './upload/service';
import { MessageService } from './messages/service';
import { TranslationService } from './translation/service';
import { SettingsService } from './settings/service';
import { Logger } from '../lib/logger/Logger';
const { db } = MongoInternals.defaultRemoteCollectionDriver().mongo;
@ -45,6 +50,9 @@ api.registerService(new DeviceManagementService());
api.registerService(new VideoConfService());
api.registerService(new FederationService());
api.registerService(new UploadService());
api.registerService(new MessageService());
api.registerService(new TranslationService());
api.registerService(new SettingsService());
// if the process is running in micro services mode we don't need to register services that will run separately
if (!isRunningMs()) {
@ -55,5 +63,11 @@ if (!isRunningMs()) {
api.registerService(new Presence());
api.registerService(new Authorization());
// Run EE services defined outside of the main repo
// Otherwise, monolith would ignore them :(
// Always register the service and manage licensing inside the service (tbd)
api.registerService(new QueueWorker(db, Logger));
api.registerService(new OmnichannelTranscript(Logger));
})();
}

@ -0,0 +1,35 @@
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { Settings } from '@rocket.chat/models';
import type { IUser } from '@rocket.chat/core-typings';
import mem from 'mem';
import { ServiceClassInternal } from '@rocket.chat/core-services';
import type { ITranslationService } from '@rocket.chat/core-services';
export class TranslationService extends ServiceClassInternal implements ITranslationService {
protected name = 'translation';
// Cache the server language for 1 hour
private getServerLanguageCached = mem(this.getServerLanguage.bind(this), { maxAge: 1000 * 60 * 60 });
private async getServerLanguage(): Promise<string> {
return ((await Settings.findOneById('Language'))?.value as string) || 'en';
}
// Use translateText when you already know the language, or want to translate to a predefined language
translateText(text: string, targetLanguage: string): Promise<string> {
return Promise.resolve(TAPi18n.__(text, { lng: targetLanguage }));
}
// Use translate when you want to translate to the user's language, or server's as a fallback
async translate(text: string, user: IUser): Promise<string> {
const language = user.language || (await this.getServerLanguageCached());
return this.translateText(text, language);
}
async translateToServerLanguage(text: string): Promise<string> {
const language = await this.getServerLanguageCached();
return this.translateText(text, language);
}
}

@ -8,9 +8,11 @@ import { FileUpload } from '../../../app/file-upload/server';
export class UploadService extends ServiceClassInternal implements IUploadService {
protected name = 'upload';
async uploadFile({ buffer, details }: IUploadFileParams): Promise<IUpload> {
const fileStore = FileUpload.getStore('Uploads');
return fileStore.insert(details, buffer);
async uploadFile({ buffer, details, userId }: IUploadFileParams): Promise<IUpload> {
return Meteor.runAsUser(userId, () => {
const fileStore = FileUpload.getStore('Uploads');
return fileStore.insert(details, buffer);
});
}
async sendFileMessage({ roomId, file, userId, message }: ISendFileMessageParams): Promise<IMessage | undefined> {
@ -21,13 +23,15 @@ export class UploadService extends ServiceClassInternal implements IUploadServic
return Meteor.call('sendFileLivechatMessage', roomId, visitorToken, file, message);
}
async getFileBuffer({ file }: { userId: string; file: IUpload }): Promise<Buffer> {
return new Promise((resolve, reject) => {
FileUpload.getBuffer(file, (err: Error, buffer: Buffer) => {
if (err) {
return reject(err);
}
return resolve(buffer);
async getFileBuffer({ userId, file }: { userId: string; file: IUpload }): Promise<Buffer> {
return Meteor.runAsUser(userId, () => {
return new Promise((resolve, reject) => {
FileUpload.getBuffer(file, (err: Error, buffer: Buffer) => {
if (err) {
return reject(err);
}
return resolve(buffer);
});
});
});
}

@ -11,7 +11,6 @@ export const createEmailInbox = async (): Promise<{ _id: string }> => {
email: `test${new Date().getTime()}@test.com`,
description: 'test',
senderInfo: 'test',
department: 'test',
smtp: {
server: 'smtp.example.com',
port: 587,

@ -3,6 +3,7 @@ import type { Browser, Page } from '@playwright/test';
import { test, expect } from './utils/test';
import { OmnichannelLiveChat, HomeChannel } from './page-objects';
import { IS_EE } from './config/constants';
const createAuxContext = async (browser: Browser, storageState: string): Promise<{ page: Page; poHomeChannel: HomeChannel }> => {
const page = await browser.newPage({ storageState });
@ -52,10 +53,18 @@ test.describe('omnichannel-transcript', () => {
await agent.poHomeChannel.sidenav.openChat(newUser.name);
});
await test.step('Expect to be able to create transcript', async () => {
await test.step('Expect to be able to send transcript to email', async () => {
await agent.poHomeChannel.content.btnSendTranscript.click();
await agent.poHomeChannel.content.btnSendTranscriptToEmail.click();
await agent.poHomeChannel.content.btnModalConfirm.click();
await expect(agent.poHomeChannel.toastSuccess).toBeVisible();
});
await test.step('Expect to be not able send transcript as PDF', async () => {
test.skip(!IS_EE, 'Enterprise Only');
await agent.poHomeChannel.content.btnSendTranscript.click();
await agent.poHomeChannel.content.btnSendTranscriptAsPDF.hover();
await expect(agent.poHomeChannel.content.btnSendTranscriptAsPDF).toHaveAttribute('aria-disabled', 'true');
});
});
});

@ -130,6 +130,14 @@ export class HomeContent {
return this.page.locator('[data-qa-id="ToolBoxAction-mail-arrow-top-right"]');
}
get btnSendTranscriptToEmail(): Locator {
return this.page.locator('li.rcx-option', { hasText: 'Send via email' });
}
get btnSendTranscriptAsPDF(): Locator {
return this.page.locator('li.rcx-option', { hasText: 'Export as PDF' });
}
get btnCannedResponses(): Locator {
return this.page.locator('[data-qa-id="ToolBoxAction-canned-response"]');
}

@ -151,6 +151,8 @@ describe('miscellaneous', function () {
'emailNotificationMode',
'unreadAlert',
'notificationsSoundVolume',
'omnichannelTranscriptEmail',
IS_EE ? 'omnichannelTranscriptPDF' : false,
'desktopNotifications',
'pushNotifications',
'enableAutoAway',
@ -169,7 +171,7 @@ describe('miscellaneous', function () {
'sidebarDisplayAvatar',
'sidebarGroupByType',
'muteFocusedConversations',
];
].filter((p) => Boolean(p));
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('_id', credentials['X-User-Id']);

@ -17,6 +17,8 @@ import {
getLivechatRoomInfo,
sendMessage,
closeRoom,
takeInquiry,
fetchInquiry,
} from '../../../data/livechat/rooms';
import { updatePermission, updateSetting } from '../../../data/permissions.helper';
import { createUser, login } from '../../../data/users.helper.js';
@ -1447,8 +1449,106 @@ describe('LIVECHAT - rooms', function () {
});
});
// TODO: Implement proper department data cleanup after each test to run in CE
(IS_EE ? describe : describe.skip)('it should mark room as unread when a new message arrives and the config is activated', () => {
describe('livechat/room.closeByUser', () => {
it('should fail if user is not logged in', async () => {
await request.post(api('livechat/room.closeByUser')).expect(401);
});
it('should fail if not all required params are passed (rid)', async () => {
await request.post(api('livechat/room.closeByUser')).set(credentials).expect(400);
});
it('should fail if user doesnt have close-livechat-room permission', async () => {
await updatePermission('close-livechat-room', []);
await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: 'invalid-room-id' }).expect(403);
});
it('should fail if room is not found', async () => {
await updatePermission('close-livechat-room', ['admin']);
await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: 'invalid-room-id' }).expect(400);
});
it('should fail if room is closed', async () => {
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await closeRoom(_id);
await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id }).expect(400);
});
it('should fail if user is not serving and doesnt have close-others-livechat-room permission', async () => {
await updatePermission('close-others-livechat-room', []);
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id }).expect(400);
});
it('should close room if user has permission', async () => {
await updatePermission('close-others-livechat-room', ['admin']);
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await request.post(api('livechat/room.closeByUser')).set(credentials).send({ rid: _id }).expect(200);
});
});
(IS_EE ? describe : describe.skip)('omnichannel/:rid/request-transcript', () => {
before(async () => {
await updateSetting('Livechat_Routing_Method', 'Manual_Selection');
// Wait for one sec to be sure routing stops
await sleep(1000);
});
it('should fail if user is not logged in', async () => {
await request.post(api('omnichannel/rid/request-transcript')).expect(401);
});
it('should fail if :rid doesnt exists', async () => {
await request.post(api('omnichannel/rid/request-transcript')).set(credentials).expect(400);
});
it('should fail if user doesnt have request-pdf-transcript permission', async () => {
await updatePermission('request-pdf-transcript', []);
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await request
.post(api(`omnichannel/${_id}/request-transcript`))
.set(credentials)
.expect(403);
});
// Increasing a bit the timeout since service calls + other calls are a bit slow on pipe
it('should fail if room is not closed', async () => {
await updatePermission('request-pdf-transcript', ['admin', 'livechat-agent', 'livechat-manager']);
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await request
.post(api(`omnichannel/${_id}/request-transcript`))
.set(credentials)
.expect(400);
});
it('should fail if no one is serving the room', async () => {
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
await closeRoom(_id);
await request
.post(api(`omnichannel/${_id}/request-transcript`))
.set(credentials)
.expect(400);
});
let roomId: string;
it('should request a pdf transcript when all conditions are met', async () => {
await createAgent();
const visitor = await createVisitor();
const { _id } = await createLivechatRoom(visitor.token);
const inq = await fetchInquiry(_id);
roomId = _id;
await takeInquiry(inq._id);
await closeRoom(_id);
await request
.post(api(`omnichannel/${_id}/request-transcript`))
.set(credentials)
.expect(200);
});
it('should return immediately if transcript was already requested', async () => {
await request
.post(api(`omnichannel/${roomId}/request-transcript`))
.set(credentials)
.expect(200);
});
});
describe('it should mark room as unread when a new message arrives and the config is activated', () => {
let room: IOmnichannelRoom;
let visitor: ILivechatVisitor;
let totalMessagesSent = 0;

@ -3,7 +3,6 @@ import { expect } from 'chai';
import type { Response } from 'supertest';
import { getCredentials, api, request, credentials } from '../../../data/api-data';
import { createDepartment } from '../../../data/livechat/rooms';
import { createEmailInbox } from '../../../data/livechat/inboxes';
import { updatePermission } from '../../../data/permissions.helper';
@ -11,47 +10,43 @@ describe('Email inbox', () => {
before((done) => getCredentials(done));
let testInbox = '';
before((done) => {
createDepartment()
.then((dept) =>
request
.post(api('email-inbox'))
.set(credentials)
.send({
active: true,
name: 'test-email-inbox##',
email: 'test-email@example.com',
description: 'test email inbox',
senderInfo: 'test email inbox',
department: dept.name,
smtp: {
server: 'smtp.example.com',
port: 587,
username: 'example@example.com',
password: 'not-a-real-password',
secure: true,
},
imap: {
server: 'imap.example.com',
port: 993,
username: 'example@example.com',
password: 'not-a-real-password',
secure: true,
maxRetries: 10,
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success');
if (res.body.success === true) {
testInbox = res.body._id;
} else {
expect(res.body).to.have.property('error');
expect(res.body.error.includes('E11000')).to.be.eq(true);
}
}),
)
.finally(done);
request
.post(api('email-inbox'))
.set(credentials)
.send({
active: true,
name: 'test-email-inbox##',
email: 'test-email@example.com',
description: 'test email inbox',
senderInfo: 'test email inbox',
smtp: {
server: 'smtp.example.com',
port: 587,
username: 'example@example.com',
password: 'not-a-real-password',
secure: true,
},
imap: {
server: 'imap.example.com',
port: 993,
username: 'example@example.com',
password: 'not-a-real-password',
secure: true,
maxRetries: 10,
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success');
if (res.body.success === true) {
testInbox = res.body._id;
} else {
expect(res.body).to.have.property('error');
expect(res.body.error.includes('E11000')).to.be.eq(true);
}
})
.end(done);
});
after((done) => {
if (testInbox) {
@ -97,7 +92,6 @@ describe('Email inbox', () => {
email: `test${new Date().getTime()}@test.com`,
description: 'Updated test description',
senderInfo: 'test',
department: 'test',
smtp: {
server: 'smtp.example.com',
port: 587,

@ -0,0 +1,58 @@
import type { IOmnichannelRoom } from '@rocket.chat/core-typings';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { expect, spy } from 'chai';
import TranscriptModal from '../../../../../../client/components/Omnichannel/modals/TranscriptModal';
const room = {
open: true,
v: { token: '1234567890' },
transcriptRequest: {
email: 'example@example.com',
subject: 'Transcript of livechat conversation',
},
} as IOmnichannelRoom;
const defaultProps = {
room,
email: 'test@example.com',
onRequest: () => null,
onSend: () => null,
onCancel: () => null,
onDiscard: () => null,
};
describe('components/Omnichannel/TranscriptModal', () => {
it('should show Undo request button when roomOpen is true and transcriptRequest exist', () => {
const onDiscardMock = spy();
const { getByText } = render(<TranscriptModal {...defaultProps} onDiscard={onDiscardMock} />);
const undoRequestButton = getByText('Undo_request');
fireEvent.click(undoRequestButton);
expect(onDiscardMock).to.have.been.called();
});
it('should show Request button when roomOpen is true and transcriptRequest not exist', () => {
const onRequestMock = spy();
const { getByText } = render(
<TranscriptModal {...{ ...defaultProps, room: { ...room, transcriptRequest: undefined } }} onRequest={onRequestMock} />,
);
const requestButton = getByText('Request');
fireEvent.click(requestButton);
expect(onRequestMock).to.have.been.called();
});
it('should show Send button when roomOpen is false', () => {
const onSendMock = spy();
const { getByText } = render(<TranscriptModal {...{ ...defaultProps, room: { ...room, open: false } }} onSend={onSendMock} />);
const requestButton = getByText('Send');
fireEvent.click(requestButton);
expect(onSendMock).to.have.been.called();
});
});

@ -110,6 +110,38 @@ services:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
queue-worker-service:
platform: linux/amd64
build:
dockerfile: ee/apps/queue-worker/Dockerfile
args:
SERVICE: queue-worker
image: ghcr.io/${LOWERCASE_REPOSITORY}/queue-worker-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
omnichannel-transcript-service:
platform: linux/amd64
build:
dockerfile: ee/apps/omnichannel-transcript/Dockerfile
args:
SERVICE: omnichannel-transcript
image: ghcr.io/${LOWERCASE_REPOSITORY}/omnichannel-transcript-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
nats:
image: nats:2.6-alpine

@ -0,0 +1,171 @@
version: '3.8'
services:
rocketchat:
platform: linux/amd64
build:
dockerfile: ${RC_DOCKERFILE}
context: /tmp/build
image: ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${RC_DOCKER_TAG}
environment:
- TEST_MODE=true
- EXIT_UNHANDLEDPROMISEREJECTION=true
- 'MONGO_URL=${MONGO_URL}'
- 'MONGO_OPLOG_URL=${MONGO_OPLOG_URL}'
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
- 'ROCKETCHAT_LICENSE=${ENTERPRISE_LICENSE}'
- OVERWRITE_SETTING_Log_Level=2
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- traefik
- mongo
labels:
traefik.enable: true
traefik.http.services.rocketchat.loadbalancer.server.port: 3000
traefik.http.routers.rocketchat.service: rocketchat
traefik.http.routers.rocketchat.rule: PathPrefix(`/`)
authorization-service:
platform: linux/amd64
build:
dockerfile: ee/apps/authorization-service/Dockerfile
args:
SERVICE: authorization-service
image: ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${DOCKER_TAG}
environment:
- 'MONGO_URL=${MONGO_URL}'
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
account-service:
platform: linux/amd64
build:
dockerfile: ee/apps/account-service/Dockerfile
args:
SERVICE: account-service
image: ghcr.io/${LOWERCASE_REPOSITORY}/account-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
presence-service:
platform: linux/amd64
build:
dockerfile: ee/apps/presence-service/Dockerfile
args:
SERVICE: presence-service
image: ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
ddp-streamer-service:
platform: linux/amd64
build:
dockerfile: ee/apps/ddp-streamer/Dockerfile
args:
SERVICE: ddp-streamer
image: ghcr.io/${LOWERCASE_REPOSITORY}/ddp-streamer-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
- traefik
labels:
traefik.enable: true
traefik.http.services.ddp-streamer-service.loadbalancer.server.port: 3000
traefik.http.routers.ddp-streamer-service.service: ddp-streamer-service
traefik.http.routers.ddp-streamer-service.rule: PathPrefix(`/websocket`) || PathPrefix(`/sockjs`)
stream-hub-service:
platform: linux/amd64
build:
dockerfile: ee/apps/stream-hub-service/Dockerfile
args:
SERVICE: stream-hub-service
image: ghcr.io/${LOWERCASE_REPOSITORY}/stream-hub-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
queue-worker-service:
platform: linux/amd64
build:
dockerfile: ee/apps/queue-worker/Dockerfile
args:
SERVICE: queue-worker
image: ghcr.io/${LOWERCASE_REPOSITORY}/queue-worker-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
omnichannel-transcript-service:
platform: linux/amd64
build:
dockerfile: ee/apps/omnichannel-transcript/Dockerfile
args:
SERVICE: omnichannel-transcript
image: ghcr.io/${LOWERCASE_REPOSITORY}/omnichannel-transcript-service:${DOCKER_TAG}
environment:
- MONGO_URL=${MONGO_URL}
- 'TRANSPORTER=${TRANSPORTER}'
- MOLECULER_LOG_LEVEL=info
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
- nats
mongo:
image: docker.io/bitnami/mongodb:4.4
restart: on-failure
environment:
MONGODB_REPLICA_SET_MODE: primary
MONGODB_REPLICA_SET_NAME: ${MONGODB_REPLICA_SET_NAME:-rs0}
MONGODB_PORT_NUMBER: ${MONGODB_PORT_NUMBER:-27017}
MONGODB_INITIAL_PRIMARY_HOST: ${MONGODB_INITIAL_PRIMARY_HOST:-mongo}
MONGODB_INITIAL_PRIMARY_PORT_NUMBER: ${MONGODB_INITIAL_PRIMARY_PORT_NUMBER:-27017}
MONGODB_ADVERTISED_HOSTNAME: ${MONGODB_ADVERTISED_HOSTNAME:-mongo}
MONGODB_ENABLE_JOURNAL: ${MONGODB_ENABLE_JOURNAL:-true}
ALLOW_EMPTY_PASSWORD: ${ALLOW_EMPTY_PASSWORD:-yes}
nats:
image: nats:2.6-alpine
traefik:
image: traefik:v2.8
command:
- --providers.docker=true
ports:
- 3000:80
volumes:
- /var/run/docker.sock:/var/run/docker.sock

@ -0,0 +1,4 @@
{
"extends": ["@rocket.chat/eslint-config"],
"ignorePatterns": ["**/dist"]
}

@ -0,0 +1,49 @@
FROM node:14.21.2-alpine
ARG SERVICE
WORKDIR /app
COPY ./packages/core-services/package.json packages/core-services/package.json
COPY ./packages/core-services/dist packages/core-services/dist
COPY ./packages/core-typings/package.json packages/core-typings/package.json
COPY ./packages/core-typings/dist packages/core-typings/dist
COPY ./packages/rest-typings/package.json packages/rest-typings/package.json
COPY ./packages/rest-typings/dist packages/rest-typings/dist
COPY ./packages/model-typings/package.json packages/model-typings/package.json
COPY ./packages/model-typings/dist packages/model-typings/dist
COPY ./packages/models/package.json packages/models/package.json
COPY ./packages/models/dist packages/models/dist
COPY ./ee/packages/omnichannel-services/package.json ee/packages/omnichannel-services/package.json
COPY ./ee/packages/omnichannel-services/dist ee/packages/omnichannel-services/dist
COPY ./ee/packages/pdf-worker/package.json ee/packages/pdf-worker/package.json
COPY ./ee/packages/pdf-worker/dist ee/packages/pdf-worker/dist
COPY ./packages/tools/package.json packages/tools/package.json
COPY ./packages/tools/dist packages/tools/dist
COPY ./ee/apps/${SERVICE}/dist .
COPY ./package.json .
COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./.yarn/plugins .yarn/plugins
COPY ./.yarn/releases .yarn/releases
COPY ./ee/apps/${SERVICE}/package.json ee/apps/${SERVICE}/package.json
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app/ee/apps/${SERVICE}
RUN yarn workspaces focus --production
EXPOSE 3000 9458
CMD ["node", "src/service.js"]

@ -0,0 +1,57 @@
{
"name": "@rocket.chat/omnichannel-transcript",
"private": true,
"version": "0.1.0",
"description": "Rocket.Chat service",
"scripts": {
"build": "tsc -p tsconfig.json",
"ms": "TRANSPORTER=${TRANSPORTER:-TCP} MONGO_URL=${MONGO_URL:-mongodb://localhost:3001/meteor} ts-node --files src/service.ts",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src",
"typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json"
},
"keywords": [
"rocketchat"
],
"author": "Rocket.Chat",
"dependencies": {
"@react-pdf/renderer": "^3.1.3",
"@rocket.chat/core-services": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/emitter": "0.31.22",
"@rocket.chat/model-typings": "workspace:^",
"@rocket.chat/models": "workspace:^",
"@rocket.chat/omnichannel-services": "workspace:^",
"@rocket.chat/pdf-worker": "workspace:^",
"@rocket.chat/tools": "workspace:^",
"@types/node": "^14.18.21",
"ejson": "^2.2.2",
"emoji-toolkit": "^7.0.0",
"eventemitter3": "^4.0.7",
"fibers": "^5.0.3",
"mem": "^8.1.1",
"moleculer": "^0.14.21",
"moment-timezone": "^0.5.34",
"mongo-message-queue": "^1.0.0",
"mongodb": "^4.12.1",
"nats": "^2.4.0",
"pino": "^8.4.2",
"polka": "^0.5.2"
},
"devDependencies": {
"@rocket.chat/eslint-config": "workspace:^",
"@rocket.chat/ui-contexts": "workspace:^",
"@types/eslint": "^8.4.10",
"@types/polka": "^0.5.4",
"eslint": "^8.29.0",
"ts-node": "^10.9.1",
"typescript": "~4.5.5"
},
"main": "./dist/ee/apps/omnichannel-transcript/src/service.js",
"files": [
"/dist"
],
"volta": {
"extends": "../../../package.json"
}
}

@ -0,0 +1,41 @@
import type { Document } from 'mongodb';
import polka from 'polka';
import { api } from '@rocket.chat/core-services';
import { broker } from '../../../../apps/meteor/ee/server/startup/broker';
import { Collections, getCollection, getConnection } from '../../../../apps/meteor/ee/server/services/mongo';
import { registerServiceModels } from '../../../../apps/meteor/ee/server/lib/registerServiceModels';
import { Logger } from '../../../../apps/meteor/server/lib/logger/Logger';
const PORT = process.env.PORT || 3036;
(async () => {
const db = await getConnection();
const trash = await getCollection<Document>(Collections.Trash);
registerServiceModels(db, trash);
api.setBroker(broker);
// need to import service after models are registered
const { OmnichannelTranscript } = await import('@rocket.chat/omnichannel-services');
api.registerService(new OmnichannelTranscript(Logger), ['queue-worker']);
await api.start();
polka()
.get('/health', async function (_req, res) {
try {
await api.nodeList();
res.end('ok');
} catch (err) {
console.error('Service not healthy', err);
res.writeHead(500);
res.end('not healthy');
}
})
.listen(PORT);
})();

@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.server.json",
"compilerOptions": {
"allowJs": true,
"strictPropertyInitialization": false,
"outDir": "./dist",
},
"files": ["./src/service.ts"],
"include": ["../../../apps/meteor/definition/externals/meteor"],
"exclude": ["./dist"]
}

@ -43,5 +43,8 @@
"main": "./dist/ee/apps/presence-service/src/service.js",
"files": [
"/dist"
]
],
"volta": {
"extends": "../../../package.json"
}
}

@ -0,0 +1,4 @@
{
"extends": ["@rocket.chat/eslint-config"],
"ignorePatterns": ["**/dist"]
}

@ -0,0 +1,49 @@
FROM node:14.21.2-alpine
ARG SERVICE
WORKDIR /app
COPY ./packages/core-services/package.json packages/core-services/package.json
COPY ./packages/core-services/dist packages/core-services/dist
COPY ./packages/core-typings/package.json packages/core-typings/package.json
COPY ./packages/core-typings/dist packages/core-typings/dist
COPY ./packages/rest-typings/package.json packages/rest-typings/package.json
COPY ./packages/rest-typings/dist packages/rest-typings/dist
COPY ./packages/model-typings/package.json packages/model-typings/package.json
COPY ./packages/model-typings/dist packages/model-typings/dist
COPY ./packages/models/package.json packages/models/package.json
COPY ./packages/models/dist packages/models/dist
COPY ./ee/packages/omnichannel-services/package.json ee/packages/omnichannel-services/package.json
COPY ./ee/packages/omnichannel-services/dist ee/packages/omnichannel-services/dist
COPY ./ee/packages/pdf-worker/package.json ee/packages/pdf-worker/package.json
COPY ./ee/packages/pdf-worker/dist ee/packages/pdf-worker/dist
COPY ./packages/tools/package.json packages/tools/package.json
COPY ./packages/tools/dist packages/tools/dist
COPY ./ee/apps/${SERVICE}/dist .
COPY ./package.json .
COPY ./yarn.lock .
COPY ./.yarnrc.yml .
COPY ./.yarn/plugins .yarn/plugins
COPY ./.yarn/releases .yarn/releases
COPY ./ee/apps/${SERVICE}/package.json ee/apps/${SERVICE}/package.json
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app/ee/apps/${SERVICE}
RUN yarn workspaces focus --production
EXPOSE 3000 9458
CMD ["node", "src/service.js"]

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save