apps/meteor/app/livechat/server/api/v1/room.ts
import { Omnichannel } from '@rocket.chat/core-services';
import type { ILivechatAgent, IUser, SelectedAgent, TransferByData } from '@rocket.chat/core-typings';
import { isOmnichannelRoom, OmnichannelSourceType } from '@rocket.chat/core-typings';
import { LivechatVisitors, Users, LivechatRooms, Messages } from '@rocket.chat/models';
import {
isLiveChatRoomForwardProps,
isPOSTLivechatRoomCloseParams,
isPOSTLivechatRoomTransferParams,
isPOSTLivechatRoomSurveyParams,
isLiveChatRoomJoinProps,
isPUTLivechatRoomVisitorParams,
isLiveChatRoomSaveInfoProps,
isPOSTLivechatRoomCloseByUserParams,
} from '@rocket.chat/rest-typings';
import { check } from 'meteor/check';
import { callbacks } from '../../../../../lib/callbacks';
import { i18n } from '../../../../../server/lib/i18n';
import { API } from '../../../../api/server';
import { isWidget } from '../../../../api/server/helpers/isWidget';
import { canAccessRoomAsync, roomAccessAttributes } from '../../../../authorization/server';
import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission';
import { addUserToRoom } from '../../../../lib/server/functions/addUserToRoom';
import { closeLivechatRoom } from '../../../../lib/server/functions/closeLivechatRoom';
import { settings as rcSettings } from '../../../../settings/server';
import { normalizeTransferredByData } from '../../lib/Helper';
import type { CloseRoomParams } from '../../lib/LivechatTyped';
import { Livechat as LivechatTyped } from '../../lib/LivechatTyped';
import { findGuest, findRoom, settings, findAgent, onCheckRoomParams } from '../lib/livechat';
import { findVisitorInfo } from '../lib/visitors';
const isAgentWithInfo = (agentObj: ILivechatAgent | { hiddenInfo: boolean }): agentObj is ILivechatAgent => !('hiddenInfo' in agentObj);
API.v1.addRoute(
'livechat/room',
{
rateLimiterOptions: {
numRequestsAllowed: 5,
intervalTimeInMS: 60000,
},
},
{
async get() {
// I'll temporary use check for validation, as validateParams doesnt support what's being done here
const extraCheckParams = await onCheckRoomParams({
token: String,
rid: Match.Maybe(String),
agentId: Match.Maybe(String),
});
check(this.queryParams, extraCheckParams as any);
const { token, rid, agentId, ...extraParams } = this.queryParams;
const guest = token && (await findGuest(token));
if (!guest) {
throw new Error('invalid-token');
}
if (!rid) {
const room = await LivechatRooms.findOneOpenByVisitorToken(token, {});
if (room) {
return API.v1.success({ room, newRoom: false });
}
let agent: SelectedAgent | undefined;
const agentObj = agentId && (await findAgent(agentId));
if (agentObj) {
if (isAgentWithInfo(agentObj)) {
const { username = undefined } = agentObj;
agent = { agentId, username };
} else {
agent = { agentId };
}
}
const roomInfo = {
source: {
type: isWidget(this.request.headers) ? OmnichannelSourceType.WIDGET : OmnichannelSourceType.API,
},
};
const newRoom = await LivechatTyped.createRoom({ visitor: guest, roomInfo, agent, extraData: extraParams });
return API.v1.success({
room: newRoom,
newRoom: true,
});
}
const froom = await LivechatRooms.findOneOpenByRoomIdAndVisitorToken(rid, token, {});
if (!froom) {
throw new Error('invalid-room');
}
return API.v1.success({ room: froom, newRoom: false });
},
},
);
// 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 },
{
async post() {
const { rid, token } = this.bodyParams;
const visitor = await findGuest(token);
if (!visitor) {
throw new Error('invalid-token');
}
const room = await findRoom(token, rid);
if (!room) {
throw new Error('invalid-room');
}
if (!room.open) {
throw new Error('room-closed');
}
const language = rcSettings.get<string>('Language') || 'en';
const comment = i18n.t('Closed_by_visitor', { lng: language });
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 => i18n.t(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;
await closeLivechatRoom(this.user, rid, { comment, tags, generateTranscriptPdf, transcriptEmail });
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.transfer',
{ validateParams: isPOSTLivechatRoomTransferParams, deprecation: { version: '7.0.0' } },
{
async post() {
const { rid, token, department } = this.bodyParams;
const guest = await findGuest(token);
if (!guest) {
throw new Error('invalid-token');
}
let room = await findRoom(token, rid);
if (!room) {
throw new Error('invalid-room');
}
// update visited page history to not expire
await Messages.keepHistoryForToken(token);
const { _id, username, name } = guest;
const transferredBy = normalizeTransferredByData({ _id, username, name, userType: 'visitor' }, room);
if (!(await LivechatTyped.transfer(room, guest, { departmentId: department, transferredBy }))) {
return API.v1.failure();
}
room = await findRoom(token, rid);
if (!room) {
throw new Error('invalid-room');
}
return API.v1.success({ room });
},
},
);
API.v1.addRoute(
'livechat/room.survey',
{ validateParams: isPOSTLivechatRoomSurveyParams },
{
async post() {
const { rid, token, data } = this.bodyParams;
const visitor = await findGuest(token);
if (!visitor) {
throw new Error('invalid-token');
}
const room = await findRoom(token, rid);
if (!room) {
throw new Error('invalid-room');
}
const config = await settings();
if (!config.survey?.items || !config.survey.values) {
throw new Error('invalid-livechat-config');
}
const updateData: { [k: string]: string } = {};
for (const item of data) {
if ((config.survey.items.includes(item.name) && config.survey.values.includes(item.value)) || item.name === 'additionalFeedback') {
updateData[item.name] = item.value;
}
}
if (Object.keys(updateData).length === 0) {
throw new Error('invalid-data');
}
if (!(await LivechatRooms.updateSurveyFeedbackById(room._id, updateData))) {
return API.v1.failure();
}
return API.v1.success({ rid, data: updateData });
},
},
);
API.v1.addRoute(
'livechat/room.forward',
{ authRequired: true, permissionsRequired: ['view-l-room', 'transfer-livechat-guest'], validateParams: isLiveChatRoomForwardProps },
{
async post() {
const transferData = this.bodyParams as typeof this.bodyParams & {
transferredBy: TransferByData;
transferredTo?: { _id: string; username?: string; name?: string };
};
const room = await LivechatRooms.findOneById(this.bodyParams.roomId);
if (!room || room.t !== 'l') {
throw new Error('error-invalid-room');
}
if (!room.open) {
throw new Error('This_conversation_is_already_closed');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
const guest = await LivechatVisitors.findOneEnabledById(room.v?._id);
if (!guest) {
throw new Error('error-invalid-visitor');
}
transferData.transferredBy = normalizeTransferredByData(this.user, room);
if (transferData.userId) {
const userToTransfer = await Users.findOneById(transferData.userId);
if (userToTransfer) {
transferData.transferredTo = {
_id: userToTransfer._id,
username: userToTransfer.username,
name: userToTransfer.name,
};
}
}
const chatForwardedResult = await LivechatTyped.transfer(room, guest, transferData);
if (!chatForwardedResult) {
throw new Error('error-forwarding-chat');
}
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.visitor',
{
authRequired: true,
permissionsRequired: ['change-livechat-room-visitor'],
validateParams: isPUTLivechatRoomVisitorParams,
deprecation: {
version: '7.0.0',
},
},
{
async put() {
// This endpoint is deprecated and will be removed in future versions.
const { rid, newVisitorId, oldVisitorId } = this.bodyParams;
const { visitor } = await findVisitorInfo({ visitorId: newVisitorId });
if (!visitor) {
throw new Error('invalid-visitor');
}
const room = await LivechatRooms.findOneById(rid, { projection: { ...roomAccessAttributes, _id: 1, t: 1, v: 1 } }); // TODO: check _id
if (!room) {
throw new Error('invalid-room');
}
const { v: { _id: roomVisitorId = undefined } = {} } = room; // TODO: v it will be undefined
if (roomVisitorId !== oldVisitorId) {
throw new Error('invalid-room-visitor');
}
const roomAfterChange = await LivechatTyped.changeRoomVisitor(this.userId, room, visitor);
if (!roomAfterChange) {
return API.v1.failure();
}
return API.v1.success({ room: roomAfterChange });
},
},
);
API.v1.addRoute(
'livechat/room.join',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomJoinProps },
{
async get() {
const { roomId } = this.queryParams;
const { user } = this;
if (!user) {
throw new Error('error-invalid-user');
}
const room = await LivechatRooms.findOneById(roomId);
if (!room) {
throw new Error('error-invalid-room');
}
if (!room.open) {
throw new Error('room-closed');
}
if (!(await Omnichannel.isWithinMACLimit(room))) {
throw new Error('error-mac-limit-reached');
}
if (!(await canAccessRoomAsync(room, user))) {
throw new Error('error-not-allowed');
}
await addUserToRoom(roomId, user);
return API.v1.success();
},
},
);
API.v1.addRoute(
'livechat/room.saveInfo',
{ authRequired: true, permissionsRequired: ['view-l-room'], validateParams: isLiveChatRoomSaveInfoProps },
{
async post() {
const { roomData, guestData } = this.bodyParams;
const room = await LivechatRooms.findOneById(roomData._id);
if (!room || !isOmnichannelRoom(room)) {
throw new Error('error-invalid-room');
}
if (
(!room.servedBy || room.servedBy._id !== this.userId) &&
!(await hasPermissionAsync(this.userId, 'save-others-livechat-room-info'))
) {
return API.v1.unauthorized();
}
if (room.sms) {
delete guestData.phone;
}
// We want this both operations to be concurrent, so we have to go with Promise.allSettled
const result = await Promise.allSettled([LivechatTyped.saveGuest(guestData, this.userId), LivechatTyped.saveRoomInfo(roomData)]);
const firstError = result.find((item) => item.status === 'rejected');
if (firstError) {
throw new Error((firstError as PromiseRejectedResult).reason.error);
}
await callbacks.run('livechat.saveInfo', await LivechatRooms.findOneById(roomData._id), {
user: this.user,
oldRoom: room,
});
return API.v1.success();
},
},
);