RocketChat/Rocket.Chat

View on GitHub
apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts

Summary

Maintainability
A
0 mins
Test Coverage
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 type { Request } from 'express';
import filesize from 'filesize';
import twilio from 'twilio';

import { settings } from '../../../../app/settings/server';
import { fileUploadIsValidContentType } from '../../../../app/utils/server/restrictions';
import { i18n } from '../../../lib/i18n';
import { SystemLogger } from '../../../lib/logger/system';

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(`Error parsing 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(`(Twilio) -> ${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(`(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(`(Twilio) -> ${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>',
        };
    }

    isRequestFromTwilio(signature: string, requestBody: object): boolean {
        const authToken = settings.get<string>('SMS_Twilio_authToken');
        const siteUrl = settings.get<string>('Site_Url');

        if (!authToken || !siteUrl) {
            SystemLogger.error(`(Twilio) -> URL or Twilio token not configured.`);
            return false;
        }

        const twilioUrl = siteUrl.endsWith('/')
            ? `${siteUrl}api/v1/livechat/sms-incoming/twilio`
            : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`;
        return twilio.validateRequest(authToken, signature, twilioUrl, requestBody);
    }

    validateRequest(request: Request): boolean {
        // We're not getting original twilio requests on CI :p
        if (process.env.TEST_MODE === 'true') {
            return true;
        }
        const twilioHeader = request.headers['x-twilio-signature'] || '';
        const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader;
        return this.isRequestFromTwilio(twilioSignature, request.body);
    }

    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>`,
        };
    }
}