Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
296 lines
8.1 KiB
TypeScript
296 lines
8.1 KiB
TypeScript
import { api } from '@rocket.chat/core-services';
|
|
import type { ISMSProvider, ServiceData, SMSProviderResponse, SMSProviderResult } from '@rocket.chat/core-typings';
|
|
import { Users } from '@rocket.chat/models';
|
|
import filesize from 'filesize';
|
|
import twilio from 'twilio';
|
|
|
|
import { i18n } from '../../../lib/i18n';
|
|
import { SystemLogger } from '../../../lib/logger/system';
|
|
import { fileUploadIsValidContentType } from '../../../lib/utils/restrictions';
|
|
import { settings } from '../../../settings';
|
|
|
|
type TwilioData = {
|
|
From: string;
|
|
To: string;
|
|
Body: string;
|
|
NumMedia?: string;
|
|
ToCountry?: string;
|
|
ToState?: string;
|
|
ToCity?: string;
|
|
ToZip?: string;
|
|
FromCountry?: string;
|
|
FromState?: string;
|
|
FromCity?: string;
|
|
FromZip?: string;
|
|
Latitude?: string;
|
|
Longitude?: string;
|
|
} & Record<`MediaUrl${number}`, string> &
|
|
Record<`MediaContentType${number}`, string>;
|
|
|
|
const isTwilioData = (data: unknown): data is TwilioData => {
|
|
if (typeof data !== 'object' || data === null) {
|
|
return false;
|
|
}
|
|
|
|
const { From, To, Body } = data as Record<string, unknown>;
|
|
|
|
return typeof From === 'string' && typeof To === 'string' && typeof Body === 'string';
|
|
};
|
|
|
|
const MAX_FILE_SIZE = 5242880;
|
|
|
|
const notifyAgent = (userId: string | undefined, rid: string | undefined, msg: string) =>
|
|
userId &&
|
|
rid &&
|
|
void api.broadcast('notify.ephemeralMessage', userId, rid, {
|
|
msg,
|
|
});
|
|
|
|
export class Twilio implements ISMSProvider {
|
|
parse(data: unknown): ServiceData {
|
|
let numMedia = 0;
|
|
|
|
if (!isTwilioData(data)) {
|
|
throw new Error('Invalid data');
|
|
}
|
|
|
|
const returnData: ServiceData = {
|
|
from: data.From,
|
|
to: data.To,
|
|
body: data.Body,
|
|
|
|
extra: {
|
|
toCountry: data.ToCountry,
|
|
toState: data.ToState,
|
|
toCity: data.ToCity,
|
|
toZip: data.ToZip,
|
|
fromCountry: data.FromCountry,
|
|
fromState: data.FromState,
|
|
fromCity: data.FromCity,
|
|
fromZip: data.FromZip,
|
|
fromLatitude: data.Latitude,
|
|
fromLongitude: data.Longitude,
|
|
},
|
|
};
|
|
|
|
if (data.NumMedia) {
|
|
numMedia = parseInt(data.NumMedia, 10);
|
|
}
|
|
|
|
if (isNaN(numMedia)) {
|
|
SystemLogger.error({ msg: 'Error parsing NumMedia', numMedia: data.NumMedia });
|
|
return returnData;
|
|
}
|
|
|
|
returnData.media = [];
|
|
|
|
for (let mediaIndex = 0; mediaIndex < numMedia; mediaIndex++) {
|
|
const media = {
|
|
url: '',
|
|
contentType: '',
|
|
};
|
|
|
|
const mediaUrl = data[`MediaUrl${mediaIndex}`];
|
|
const contentType = data[`MediaContentType${mediaIndex}`];
|
|
|
|
media.url = mediaUrl;
|
|
media.contentType = contentType;
|
|
|
|
returnData.media.push(media);
|
|
}
|
|
|
|
return returnData;
|
|
}
|
|
|
|
private async getClient(rid?: string, userId?: string) {
|
|
const sid = settings.get<string>('SMS_Twilio_Account_SID');
|
|
const token = settings.get<string>('SMS_Twilio_authToken');
|
|
if (!sid || !token) {
|
|
await notifyAgent(userId, rid, i18n.t('SMS_Twilio_NotConfigured'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
return twilio(sid, token);
|
|
} catch (error) {
|
|
await notifyAgent(userId, rid, i18n.t('SMS_Twilio_InvalidCredentials'));
|
|
SystemLogger.error({ msg: '(Twilio) ->', err: error });
|
|
}
|
|
}
|
|
|
|
private async validateFileUpload(
|
|
extraData: {
|
|
fileUpload?: { size: number; type: string; publicFilePath: string };
|
|
location?: { coordinates: [number, number] };
|
|
rid?: string;
|
|
userId?: string;
|
|
},
|
|
lang: string,
|
|
): Promise<string> {
|
|
const { rid, userId, fileUpload: { size, type, publicFilePath } = { size: 0, type: 'invalid' } } = extraData;
|
|
const user = userId ? await Users.findOne({ _id: userId }, { projection: { language: 1 } }) : null;
|
|
const lng = user?.language || lang;
|
|
|
|
let reason;
|
|
if (!settings.get('SMS_Twilio_FileUpload_Enabled')) {
|
|
reason = i18n.t('FileUpload_Disabled', { lng });
|
|
} else if (size > MAX_FILE_SIZE) {
|
|
reason = i18n.t('File_exceeds_allowed_size_of_bytes', {
|
|
size: filesize(MAX_FILE_SIZE),
|
|
lng,
|
|
});
|
|
} else if (!fileUploadIsValidContentType(type, settings.get('SMS_Twilio_FileUpload_MediaTypeWhiteList'))) {
|
|
reason = i18n.t('File_type_is_not_accepted', { lng });
|
|
} else if (!publicFilePath) {
|
|
reason = i18n.t('FileUpload_NotAllowed', { lng });
|
|
}
|
|
|
|
// Check if JWT is set for public file uploads when protect_files is on
|
|
// If it's not, notify user upload won't go to twilio
|
|
const protectFileUploads = settings.get('FileUpload_ProtectFiles');
|
|
const jwtEnabled = settings.get('FileUpload_Enable_json_web_token_for_files');
|
|
const isJWTKeySet = jwtEnabled && !!settings.get('FileUpload_json_web_token_secret_for_files');
|
|
|
|
if (protectFileUploads && (!jwtEnabled || !isJWTKeySet)) {
|
|
reason = i18n.t('FileUpload_ProtectFilesEnabled_JWTNotSet', { lng });
|
|
}
|
|
|
|
if (reason) {
|
|
await notifyAgent(userId, rid, reason);
|
|
SystemLogger.error({ msg: '(Twilio) ->', reason });
|
|
return '';
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return publicFilePath!;
|
|
}
|
|
|
|
async send(
|
|
fromNumber: string,
|
|
toNumber: string,
|
|
message: string,
|
|
extraData?: {
|
|
fileUpload?: { size: number; type: string; publicFilePath: string };
|
|
location?: { coordinates: [number, number] };
|
|
rid?: string;
|
|
userId?: string;
|
|
},
|
|
): Promise<SMSProviderResult> {
|
|
const { rid, userId } = extraData || {};
|
|
|
|
const client = await this.getClient(rid, userId);
|
|
if (!client) {
|
|
return {
|
|
isSuccess: false,
|
|
resultMsg: 'Twilio not configured',
|
|
};
|
|
}
|
|
|
|
let body = message;
|
|
|
|
let mediaUrl;
|
|
const defaultLanguage = settings.get<string>('Language') || 'en';
|
|
if (extraData?.fileUpload) {
|
|
const publicFilePath = await this.validateFileUpload(extraData, defaultLanguage);
|
|
if (!publicFilePath) {
|
|
return {
|
|
isSuccess: false,
|
|
resultMsg: 'File upload not allowed',
|
|
};
|
|
}
|
|
mediaUrl = [publicFilePath];
|
|
}
|
|
|
|
let persistentAction;
|
|
if (extraData?.location) {
|
|
const [longitude, latitude] = extraData.location.coordinates;
|
|
persistentAction = [`geo:${latitude},${longitude}`];
|
|
body = i18n.t('Location', { lng: defaultLanguage });
|
|
}
|
|
|
|
try {
|
|
const result = await client.messages.create({
|
|
to: toNumber,
|
|
from: fromNumber,
|
|
body,
|
|
...(mediaUrl && { mediaUrl }),
|
|
...(persistentAction && { persistentAction }),
|
|
});
|
|
|
|
if (result.errorCode) {
|
|
await notifyAgent(userId, rid, result.errorMessage);
|
|
SystemLogger.error({ msg: '(Twilio) ->', errorCode: result.errorCode });
|
|
}
|
|
|
|
return {
|
|
isSuccess: result.status !== 'failed',
|
|
resultMsg: result.status,
|
|
};
|
|
} catch (e: any) {
|
|
await notifyAgent(userId, rid, e.message);
|
|
return {
|
|
isSuccess: false,
|
|
resultMsg: e.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
response(): SMSProviderResponse {
|
|
return {
|
|
headers: {
|
|
'Content-Type': 'text/xml',
|
|
},
|
|
body: '<Response></Response>',
|
|
};
|
|
}
|
|
|
|
private getUrl(url: string, siteUrl: string): string {
|
|
const baseUrl = new URL(url);
|
|
const newUrl = new URL(siteUrl);
|
|
baseUrl.protocol = newUrl.protocol;
|
|
baseUrl.host = newUrl.host;
|
|
|
|
return baseUrl.toString();
|
|
}
|
|
|
|
async isRequestFromTwilio(signature: string, request: Request, requestBody: unknown): Promise<boolean> {
|
|
const authToken = settings.get<string>('SMS_Twilio_authToken');
|
|
let siteUrl = settings.get<string>('Site_Url');
|
|
if (siteUrl.endsWith('/')) {
|
|
siteUrl = siteUrl.replace(/.$/, '');
|
|
}
|
|
|
|
if (!authToken || !siteUrl) {
|
|
SystemLogger.error('(Twilio) -> URL or Twilio token not configured.');
|
|
return false;
|
|
}
|
|
|
|
const twilioUrl = request.url ? this.getUrl(request.url, siteUrl) : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`;
|
|
|
|
return twilio.validateRequest(authToken, signature, twilioUrl, requestBody as Record<string, any>);
|
|
}
|
|
|
|
async validateRequest(request: Request, requestBody: unknown): Promise<boolean> {
|
|
// We're not getting original twilio requests on CI :p
|
|
if (process.env.TEST_MODE === 'true' || process.env.TEST_MODE === 'api') {
|
|
return true;
|
|
}
|
|
const twilioHeader = request.headers.get('x-twilio-signature') || '';
|
|
const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader;
|
|
return this.isRequestFromTwilio(twilioSignature, request, requestBody);
|
|
}
|
|
|
|
error(error: Error & { reason?: string }): SMSProviderResponse {
|
|
let message = '';
|
|
if (error.reason) {
|
|
message = `<Message>${error.reason}</Message>`;
|
|
}
|
|
return {
|
|
headers: {
|
|
'Content-Type': 'text/xml',
|
|
},
|
|
body: `<Response>${message}</Response>`,
|
|
};
|
|
}
|
|
}
|