apps/meteor/app/livechat/imports/server/rest/sms.ts
import { OmnichannelIntegration } from '@rocket.chat/core-services';
import type {
ILivechatVisitor,
IOmnichannelRoom,
IUpload,
MessageAttachment,
ServiceData,
FileAttachmentProps,
} from '@rocket.chat/core-typings';
import { OmnichannelSourceType } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Meteor } from 'meteor/meteor';
import { getFileExtension } from '../../../../../lib/utils/getFileExtension';
import { API } from '../../../../api/server';
import { FileUpload } from '../../../../file-upload/server';
import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf';
import { settings } from '../../../../settings/server';
import type { ILivechatMessage } from '../../../server/lib/LivechatTyped';
import { Livechat as LivechatTyped } from '../../../server/lib/LivechatTyped';
const logger = new Logger('SMS');
const getUploadFile = async (details: Omit<IUpload, '_id'>, fileUrl: string) => {
const isSsrfSafe = await checkUrlForSsrf(fileUrl);
if (!isSsrfSafe) {
throw new Meteor.Error('error-invalid-url', 'Invalid URL');
}
const response = await fetch(fileUrl, { redirect: 'error' });
const content = Buffer.from(await response.arrayBuffer());
const contentSize = content.length;
if (response.status !== 200 || contentSize === 0) {
throw new Meteor.Error('error-invalid-file-uploaded', 'Invalid file uploaded');
}
const fileStore = FileUpload.getStore('Uploads');
return fileStore.insert({ ...details, size: contentSize }, content);
};
const defineDepartment = async (idOrName?: string) => {
if (!idOrName || idOrName === '') {
return;
}
const department = await LivechatDepartment.findOneByIdOrName(idOrName, { projection: { _id: 1 } });
return department?._id;
};
const defineVisitor = async (smsNumber: string, targetDepartment?: string) => {
const visitor = await LivechatVisitors.findOneVisitorByPhone(smsNumber);
let data: { token: string; department?: string } = {
token: visitor?.token || Random.id(),
};
if (!visitor) {
data = Object.assign(data, {
username: smsNumber.replace(/[^0-9]/g, ''),
phone: {
number: smsNumber,
},
});
}
if (targetDepartment) {
data.department = targetDepartment;
}
const livechatVisitor = await LivechatTyped.registerGuest(data);
if (!livechatVisitor) {
throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor');
}
return livechatVisitor;
};
const normalizeLocationSharing = (payload: ServiceData) => {
const { extra: { fromLatitude: latitude, fromLongitude: longitude } = {} } = payload;
if (!latitude || !longitude) {
return;
}
return {
type: 'Point',
coordinates: [parseFloat(longitude), parseFloat(latitude)],
};
};
// @ts-expect-error - this is an special endpoint that requires the return to not be wrapped as regular returns
API.v1.addRoute('livechat/sms-incoming/:service', {
async post() {
const { service } = this.urlParams;
if (!(await OmnichannelIntegration.isConfiguredSmsService(service))) {
return API.v1.failure('Invalid service');
}
const smsDepartment = settings.get<string>('SMS_Default_Omnichannel_Department');
const SMSService = await OmnichannelIntegration.getSmsService(service);
if (!SMSService.validateRequest(this.request)) {
return API.v1.failure('Invalid request');
}
const sms = SMSService.parse(this.bodyParams);
const { department } = this.queryParams;
let targetDepartment = await defineDepartment(department || smsDepartment);
if (!targetDepartment) {
targetDepartment = await defineDepartment(smsDepartment);
}
const visitor = await defineVisitor(sms.from, targetDepartment);
if (!visitor) {
return API.v1.success(SMSService.error(new Error('Invalid visitor')));
}
const roomInfo = {
sms: {
from: sms.to,
},
source: {
type: OmnichannelSourceType.SMS,
alias: service,
},
};
const { token } = visitor;
const room =
(await LivechatRooms.findOneOpenByVisitorTokenAndDepartmentIdAndSource(token, targetDepartment, OmnichannelSourceType.SMS)) ??
(await LivechatTyped.createRoom({
visitor,
roomInfo,
}));
const location = normalizeLocationSharing(sms);
const rid = room?._id;
let file: ILivechatMessage['file'];
const attachments: (MessageAttachment | undefined)[] = [];
const [media] = sms?.media || [];
if (media) {
const { url: smsUrl, contentType } = media;
const details = {
name: 'Upload File',
type: contentType,
rid,
visitorToken: token,
};
try {
const uploadedFile = await getUploadFile(details, smsUrl);
file = { _id: uploadedFile._id, name: uploadedFile.name || 'file', type: uploadedFile.type };
const fileUrl = FileUpload.getPath(`${file._id}/${encodeURI(file.name || 'file')}`);
const fileType = file.type as string;
if (/^image\/.+/.test(fileType)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
image_url: fileUrl,
image_type: fileType,
image_size: file.size,
};
if (file.identify?.size) {
attachment.image_dimensions = file?.identify.size;
}
attachments.push(attachment);
} else if (/^audio\/.+/.test(fileType)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
audio_url: fileUrl,
audio_type: fileType,
audio_size: file.size,
title_link_download: true,
};
attachments.push(attachment);
} else if (/^video\/.+/.test(fileType)) {
const attachment: FileAttachmentProps = {
title: file.name,
type: 'file',
description: file.description,
title_link: fileUrl,
video_url: fileUrl,
video_type: fileType,
video_size: file.size as number,
title_link_download: true,
};
attachments.push(attachment);
} else {
const attachment = {
title: file.name,
type: 'file',
description: file.description,
format: getFileExtension(file.name),
title_link: fileUrl,
title_link_download: true,
size: file.size as number,
};
attachments.push(attachment);
}
} catch (err) {
logger.error({ msg: 'Attachment upload failed', err });
const attachment = {
title: 'Attachment upload failed',
type: 'file',
description: 'An attachment was received, but upload to server failed',
fields: [
{
title: 'User upload failed',
value: 'An attachment was received, but upload to server failed',
short: true,
},
],
color: 'yellow',
};
attachments.push(attachment);
}
}
const sendMessage: {
guest: ILivechatVisitor;
message: ILivechatMessage;
roomInfo: {
source?: IOmnichannelRoom['source'];
[key: string]: unknown;
};
} = {
guest: visitor,
roomInfo,
message: {
_id: Random.id(),
rid,
token,
msg: sms.body,
...(location && { location }),
...(attachments && { attachments: attachments.filter((a: any): a is MessageAttachment => !!a) }),
...(file && { file }),
},
};
try {
await LivechatTyped.sendMessage(sendMessage);
const msg = SMSService.response();
setImmediate(async () => {
if (sms.extra) {
if (sms.extra.fromCountry) {
await Meteor.callAsync('livechat:setCustomField', sendMessage.message.token, 'country', sms.extra.fromCountry);
}
if (sms.extra.fromState) {
await Meteor.callAsync('livechat:setCustomField', sendMessage.message.token, 'state', sms.extra.fromState);
}
if (sms.extra.fromCity) {
await Meteor.callAsync('livechat:setCustomField', sendMessage.message.token, 'city', sms.extra.fromCity);
}
if (sms.extra.toPhone) {
await Meteor.callAsync('livechat:setCustomField', sendMessage.message.token, 'phoneNumber', sms.extra.toPhone);
}
}
});
return msg;
} catch (e: any) {
return SMSService.error(e);
}
},
});