Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/actions/install-flex-plugin/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ runs:
using: "composite"
steps:
- name: Install the Twilio CLI and plugins
run: npm install -g twilio-cli && twilio plugins:install @twilio-labs/plugin-flex@7.0.0
run: npm install -g twilio-cli@6.2.2 && twilio plugins:install @twilio-labs/plugin-flex@7.1.2
shell: bash
- name: Install plugin-hrm-form Packages
run: npm ci
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/flex-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x' # some twilio dev deps still complain about node 22 :-(
node-version: '22.x' # some twilio dev deps still complain about node 22 :-(
- name: Create Temp Files
run: |
touch ./public/appConfig.js
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
node-version: '22.x'
- name: Install Flex Plugin
uses: ./.github/actions/install-flex-plugin
- name: Configure AWS credentials
Expand Down Expand Up @@ -107,7 +107,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
node-version: '22.x'

- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v5
Expand Down Expand Up @@ -184,7 +184,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: '20.x'
node-version: '22.x'
- name: Install Flex Plugin
uses: ./.github/actions/install-flex-plugin

Expand Down
19 changes: 12 additions & 7 deletions lambdas/account-scoped/src/httpTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { Result } from './Result';
import { AccountSID } from '@tech-matters/twilio-types';
import { FlexValidatedHttpRequest } from './validation/flexToken';

export type HttpError = {
statusCode: number;
Expand All @@ -30,22 +31,26 @@ export type HttpRequest = {
body: any;
};

export type AccountScopedHandler = (
event: HttpRequest,
export type AccountScopedHandler<T extends HttpRequest = HttpRequest> = (
event: T,
accountSid: AccountSID,
) => Promise<Result<HttpError, any>>;

type PipelineStep<Input, Context = undefined, Output = Input> = (
export type PipelineStep<Input, Context = undefined, Output = Input> = (
item: Input,
context: Context,
) => Promise<Result<HttpError, Output>>;

export type HttpRequestPipelineStep = PipelineStep<HttpRequest, AccountScopedRoute>;
export type HttpRequestPipelineStep = PipelineStep<
HttpRequest | FlexValidatedHttpRequest,
AccountScopedRoute | AccountScopedRoute<FlexValidatedHttpRequest>
>;

export type FunctionRoute = {
export type FunctionRoute<T extends HttpRequest = HttpRequest> = {
requestPipeline: HttpRequestPipelineStep[];
handler: AccountScopedHandler;
handler: AccountScopedHandler<T>;
};
export type AccountScopedRoute = FunctionRoute & {

export type AccountScopedRoute<T extends HttpRequest = HttpRequest> = FunctionRoute<T> & {
accountSid: AccountSID;
};
3 changes: 2 additions & 1 deletion lambdas/account-scoped/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const handler = async (event: ALBEvent): Promise<ALBResult> => {
}
processedRequest = stepResult.unwrap();
}
const result = await route.handler(processedRequest, route.accountSid);
// TODO: Rethink types around pipelines & handlers so this cast isn't necessary
const result = await route.handler(processedRequest as any, route.accountSid);
if (isErr(result)) {
console.error(
`handler for path ${event.path} resulted in error ${result.message}`,
Expand Down
26 changes: 23 additions & 3 deletions lambdas/account-scoped/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
handleChatbotCallbackCleanup,
} from './channelCapture';
import { addParticipantHandler } from './conference/addParticipant';
import { validateFlexTokenRequest } from './validation/flexToken';
import {
FlexValidatedHttpRequest,
validateFlexTokenRequest,
} from './validation/flexToken';
import { getParticipantHandler } from './conference/getParticipant';
import { updateParticipantHandler } from './conference/updateParticipant';
import { removeParticipantHandler } from './conference/removeParticipant';
Expand All @@ -40,6 +43,9 @@ import './conference/stopRecordingWhenLastAgentLeaves';
import { instagramToFlexHandler } from './customChannels/instagram/instagramToFlex';
import { flexToInstagramHandler } from './customChannels/instagram/flexToInstagram';
import { handleConversationEvent } from './conversation';
import { getTaskAndReservationsHandler } from './task/getTaskAndReservations';
import { checkTaskAssignmentHandler } from './task/checkTaskAssignment';
import { completeTaskAssignmentHandler } from './task/completeTaskAssignment';

/**
* Super simple router sufficient for directly ported Twilio Serverless functions
Expand All @@ -53,7 +59,7 @@ export const ROUTE_PREFIX = '/lambda/twilio/account-scoped/';

const INITIAL_PIPELINE = [validateRequestMethod];

const ROUTES: Record<string, FunctionRoute> = {
const ROUTES: Record<string, FunctionRoute | FunctionRoute<FlexValidatedHttpRequest>> = {
'webhooks/taskrouterCallback': {
requestPipeline: [validateWebhookRequest],
handler: handleTaskRouterEvent,
Expand Down Expand Up @@ -122,13 +128,27 @@ const ROUTES: Record<string, FunctionRoute> = {
requestPipeline: [],
handler: handleOperatingHours,
},
'task/checkTaskAssignment': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'worker' })],
handler: checkTaskAssignmentHandler,
},
'task/completeTaskAssignment': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'worker' })],
handler: completeTaskAssignmentHandler,
},
'task/getTaskAndReservations': {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'worker' })],
handler: getTaskAndReservationsHandler,
},
updateWorkersSkills: {
requestPipeline: [validateFlexTokenRequest({ tokenMode: 'supervisor' })],
handler: handleUpdateWorkersSkills,
},
};

export const lookupRoute = (event: HttpRequest): AccountScopedRoute | undefined => {
export const lookupRoute = (
event: HttpRequest,
): AccountScopedRoute | AccountScopedRoute<FlexValidatedHttpRequest> | undefined => {
if (event.path.startsWith(ROUTE_PREFIX)) {
const path = event.path.substring(ROUTE_PREFIX.length);
const [accountSid, ...applicationPathParts] = path.split('/');
Expand Down
60 changes: 60 additions & 0 deletions lambdas/account-scoped/src/task/checkTaskAssignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Copyright (C) 2021-2025 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 '@twilio-labs/serverless-runtime-types';
import { AccountScopedHandler, HttpRequest } from '../httpTypes';
import type { AccountSID, TaskSID } from '@tech-matters/twilio-types';
import { newMissingParameterResult } from '../httpErrors';
import { newOk } from '../Result';
import { getTwilioClient, getWorkspaceSid } from '@tech-matters/twilio-configuration';

export type Body = {
request: { cookies: {}; headers: {} };
} & { taskSid: TaskSID };

const isTaskAssigned = async (
accountSid: AccountSID,
taskSid: TaskSID,
): Promise<boolean> => {
const client = await getTwilioClient(accountSid);
const workspaceSid = await getWorkspaceSid(accountSid);
try {
const task = await client.taskrouter.v1
.workspaces(workspaceSid)
.tasks(taskSid)
.fetch();

const { assignmentStatus } = task;

return assignmentStatus === 'assigned' || assignmentStatus === 'wrapping';
} catch (err) {
console.error('Error fetching task:', err);
return false;
}
};

export const checkTaskAssignmentHandler: AccountScopedHandler = async (
{ body: event }: HttpRequest,
accountSid: AccountSID,
) => {
const { taskSid } = event as { taskSid: TaskSID };

if (taskSid === undefined) {
return newMissingParameterResult('taskSid');
}

const result = await isTaskAssigned(accountSid, taskSid);
return newOk({ isAssigned: result });
};
104 changes: 104 additions & 0 deletions lambdas/account-scoped/src/task/completeTaskAssignment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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/.
*/
// Close task as a supervisor
import '@twilio-labs/serverless-runtime-types';
import { AccountSID, TaskSID } from '@tech-matters/twilio-types';
import { newMissingParameterResult } from '../httpErrors';
import type {
FlexValidatedHandler,
TokenValidatorResponse,
} from '../validation/flexToken';
import { isErr, isOk, newErr, newOk, Result } from '../Result';
import { getTwilioClient } from '@tech-matters/twilio-configuration';
import { Twilio } from 'twilio';
import type { TaskInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/task';
import {
getTaskAndReservations,
isTaskNotFoundErrorResult,
VALID_RESERVATION_STATUSES_FOR_TASK_OWNER,
} from './getTaskAndReservations';
import { HttpError } from '../httpTypes';

type AssignmentResult = Result<{ cause: Error }, { completedTask: TaskInstance }>;

const closeTaskAssignment = async (
client: Twilio,
task: TaskInstance,
finalTaskAttributes: string,
): Promise<AssignmentResult> => {
try {
const attributes = JSON.parse(task.attributes);
const callSid = attributes?.call_sid;

// Ends the task for the worker and client for chat tasks, and only for the worker for voice tasks
const completedTask = await task.update({
assignmentStatus: 'completed',
attributes: finalTaskAttributes,
});

// Ends the call for the client for voice
if (callSid) await client.calls(callSid).update({ status: 'completed' });

return newOk({ completedTask });
} catch (err) {
const error = err as Error;
return newErr({
message: error.message,
error: { cause: error },
});
}
};

const isSupervisor = (tokenResult: TokenValidatorResponse) =>
Array.isArray(tokenResult.roles) && tokenResult.roles.includes('supervisor');

export const completeTaskAssignmentHandler: FlexValidatedHandler = async (
{ body: event, tokenResult },
accountSid: AccountSID,
) => {
const { taskSid } = event as { taskSid: TaskSID };

if (taskSid === undefined) {
return newMissingParameterResult('taskSid');
}

const lookupResult = await getTaskAndReservations(accountSid, taskSid, tokenResult);
if (isErr(lookupResult)) {
return newErr<HttpError>({
...lookupResult,
error: {
...lookupResult.error,
statusCode: isTaskNotFoundErrorResult(lookupResult) ? 404 : 500,
},
});
}
const { task, reservations } = lookupResult.unwrap();
if (!isSupervisor(tokenResult) && !reservations?.length) {
return newErr({
message: `Unauthorized: Endpoint cannot be invoked unless the calling worker is a supervisor or has a reservation on the target task with one of these statuses: ${VALID_RESERVATION_STATUSES_FOR_TASK_OWNER}`,
error: { statusCode: 403 },
});
}
const closeResult = await closeTaskAssignment(
await getTwilioClient(accountSid),
task,
JSON.stringify({}),
);

return isOk(closeResult)
? closeResult
: { ...closeResult, error: { ...closeResult.error, statusCode: 500 } };
};
Loading
Loading