Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { FlexValidatedHandler } from '../validation/flexToken';
import type { AccountSID } from '@tech-matters/twilio-types';
import { newErr, newOk } from '../Result';
import { getTwilioClient, getDocsBucketName } from '@tech-matters/twilio-configuration';
import { newMissingParameterResult } from '../httpErrors';

export const getExternalRecordingS3LocationHandler: FlexValidatedHandler = async (
{ body: event },
accountSid: AccountSID,
) => {
const { callSid } = event as { callSid?: string };

if (!callSid) {
return newMissingParameterResult('callSid');
}

try {
const client = await getTwilioClient(accountSid);
const bucket = await getDocsBucketName(accountSid);

const recordings = await client.recordings.list({ callSid, limit: 20 });

if (recordings.length === 0) {
return newErr({
message: 'No recording found',
error: { statusCode: 404 },
});
}
if (recordings.length > 1) {
return newErr({
message: 'More than one recording found',
error: { statusCode: 409 },
});
}

const recordingSid = recordings[0].sid;
const key = `voice-recordings/${accountSid}/${recordingSid}`;
return newOk({ recordingSid, key, bucket });
} catch (err: any) {
return newErr({ message: err.message, error: { statusCode: 500, cause: err } });
}
};
64 changes: 64 additions & 0 deletions lambdas/account-scoped/src/conversation/getMediaUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { FlexValidatedHandler } from '../validation/flexToken';
import type { AccountSID } from '@tech-matters/twilio-types';
import { newErr, newOk } from '../Result';
import { getAccountAuthToken } from '@tech-matters/twilio-configuration';
import { newMissingParameterResult } from '../httpErrors';

const TWILIO_MCS_BASE_URL = 'https://mcs.us1.twilio.com/v1/Services';

export const getMediaUrlHandler: FlexValidatedHandler = async (
{ body: event },
accountSid: AccountSID,
) => {
const { serviceSid, mediaSid } = event as { serviceSid?: string; mediaSid?: string };

if (!serviceSid) return newMissingParameterResult('serviceSid');
if (!mediaSid) return newMissingParameterResult('mediaSid');

try {
const authToken = await getAccountAuthToken(accountSid);
const url = `${TWILIO_MCS_BASE_URL}/${serviceSid}/Media/${mediaSid}`;
const base64Credentials = Buffer.from(`${accountSid}:${authToken}`).toString(
'base64',
);

const responseData = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Basic ${base64Credentials}`,
},
});

const media = (await responseData.json()) as {
status?: number;
links?: { content_direct_temporary?: string };
};

if (!responseData.ok) {
return newErr({
message: `Failed to fetch media URL: ${responseData.statusText}`,
error: { statusCode: media.status ?? responseData.status },
});
}

return newOk(media.links?.content_direct_temporary);
} catch (err: any) {
return newErr({ message: err.message, error: { statusCode: 500, cause: err } });
}
};
10 changes: 10 additions & 0 deletions lambdas/account-scoped/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import { pullTaskHandler } from './worker/pullTask';
import { sendSystemMessageHandler } from './conversation/sendSystemMessage';
import { sendStudioMessageHandler } from './conversation/sendStudioMessage';
import { sendMessageAndRunJanitorHandler } from './conversation/sendMessageAndRunJanitor';
import { getExternalRecordingS3LocationHandler } from './conversation/getExternalRecordingS3Location';
import { getMediaUrlHandler } from './conversation/getMediaUrl';

/**
* Super simple router sufficient for directly ported Twilio Serverless functions
Expand Down Expand Up @@ -231,6 +233,14 @@ const ACCOUNTSID_ROUTES: Record<
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })],
handler: selfReportToIWFHandler,
},
'conversation/getExternalRecordingS3Location': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })],
handler: getExternalRecordingS3LocationHandler,
},
'conversation/getMediaUrl': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })],
handler: getMediaUrlHandler,
},
'worker/populateCounselors': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'agent' })],
handler: populateCounselorsHandler,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Copyright (C) 2021-2023 Technology Matters
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/

import { getExternalRecordingS3LocationHandler } from '../../../src/conversation/getExternalRecordingS3Location';
import { getTwilioClient, getDocsBucketName } from '@tech-matters/twilio-configuration';
import { isErr, isOk } from '../../../src/Result';
import { FlexValidatedHttpRequest } from '../../../src/validation/flexToken';
import { TEST_ACCOUNT_SID } from '../../testTwilioValues';

jest.mock('@tech-matters/twilio-configuration', () => ({
getTwilioClient: jest.fn(),
getDocsBucketName: jest.fn(),
}));

const mockGetTwilioClient = getTwilioClient as jest.MockedFunction<
typeof getTwilioClient
>;
const mockGetDocsBucketName = getDocsBucketName as jest.MockedFunction<
typeof getDocsBucketName
>;

const TEST_BUCKET = 'test-docs-bucket';
const TEST_CALL_SID = 'CA00000000000000000000000000000000';
const TEST_RECORDING_SID = 'RE00000000000000000000000000000000';

const createMockRequest = (body: any): FlexValidatedHttpRequest => ({
method: 'POST',
headers: {},
path: '/test',
query: {},
body,
tokenResult: { worker_sid: 'WK1234', roles: ['agent'] },
});

describe('getExternalRecordingS3LocationHandler', () => {
let mockClient: any;

beforeEach(() => {
jest.clearAllMocks();
mockGetDocsBucketName.mockResolvedValue(TEST_BUCKET);
mockClient = {
recordings: {
list: jest.fn(),
},
};
mockGetTwilioClient.mockResolvedValue(mockClient as any);
});

it('should return 400 when callSid is missing', async () => {
const request = createMockRequest({});

const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toContain('callSid');
expect(result.error.statusCode).toBe(400);
}
});

it('should return 404 when no recordings found', async () => {
mockClient.recordings.list.mockResolvedValue([]);

const request = createMockRequest({ callSid: TEST_CALL_SID });

const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toBe('No recording found');
expect(result.error.statusCode).toBe(404);
}
});

it('should return 409 when more than one recording found', async () => {
mockClient.recordings.list.mockResolvedValue([
{ sid: TEST_RECORDING_SID },
{ sid: 'RE11111111111111111111111111111111' },
]);

const request = createMockRequest({ callSid: TEST_CALL_SID });

const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toBe('More than one recording found');
expect(result.error.statusCode).toBe(409);
}
});

it('should return recordingSid, key and bucket when exactly one recording found', async () => {
mockClient.recordings.list.mockResolvedValue([{ sid: TEST_RECORDING_SID }]);

const request = createMockRequest({ callSid: TEST_CALL_SID });

const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID);

expect(isOk(result)).toBe(true);
if (isOk(result)) {
expect(result.data.recordingSid).toBe(TEST_RECORDING_SID);
expect(result.data.key).toBe(
`voice-recordings/${TEST_ACCOUNT_SID}/${TEST_RECORDING_SID}`,
);
expect(result.data.bucket).toBe(TEST_BUCKET);
}
expect(mockClient.recordings.list).toHaveBeenCalledWith({
callSid: TEST_CALL_SID,
limit: 20,
});
});

it('should return 500 on unexpected error', async () => {
mockClient.recordings.list.mockRejectedValue(new Error('Twilio API error'));

const request = createMockRequest({ callSid: TEST_CALL_SID });

const result = await getExternalRecordingS3LocationHandler(request, TEST_ACCOUNT_SID);

expect(isErr(result)).toBe(true);
if (isErr(result)) {
expect(result.message).toBe('Twilio API error');
expect(result.error.statusCode).toBe(500);
}
});
});
Loading
Loading