Skip to content

Commit

Permalink
Merge pull request #621 from techmatters/CHI-2770-post_survey_convers…
Browse files Browse the repository at this point in the history
…ations

CHI-2770: Adjust post survey to fix conversations tasks
  • Loading branch information
stephenhand authored Jun 14, 2024
2 parents 370acb7 + 0ff3c2a commit 4b74bd3
Show file tree
Hide file tree
Showing 10 changed files with 2,083 additions and 1,741 deletions.
8 changes: 6 additions & 2 deletions functions/channelCapture/captureChannelWithBot.protected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type EnvVars = {
HRM_STATIC_KEY: string;
} & AWSCredentials;

export type Body = Partial<HandleChannelCaptureParams> & { isConversation: string };
export type Body = HandleChannelCaptureParams & { isConversation: string };

export const handler = async (
context: Context<EnvVars>,
Expand Down Expand Up @@ -74,7 +74,12 @@ export const handler = async (
const handlerPath = Runtime.getFunctions()['channelCapture/channelCaptureHandlers'].path;
const channelCaptureHandlers = require(handlerPath) as ChannelCaptureHandlers;

if (!channelSid) {
resolve(error400('channelSid is required'));
return;
}
const result = await channelCaptureHandlers.handleChannelCapture(context, {
...(isConversation ? { conversationSid: channelSid } : { channelSid }),
channelSid,
message,
language,
Expand All @@ -86,7 +91,6 @@ export const handler = async (
releaseFlag,
additionControlTaskAttributes,
controlTaskTTL,
isConversation,
channelType,
});

Expand Down
112 changes: 66 additions & 46 deletions functions/channelCapture/channelCaptureHandlers.private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import type {
BuildSurveyInsightsData,
OneToManyConfigSpec,
} from '../helpers/insightsService.private';
import {
ChatChannelSid,
ConversationSid,
} from '../helpers/customChannels/customChannelToFlex.private';

type EnvVars = AWSCredentials & {
TWILIO_WORKSPACE_SID: string;
Expand Down Expand Up @@ -342,8 +346,13 @@ const triggerWithNextMessage = async (
});
};

export type HandleChannelCaptureParams = {
channelSid: string; // The channel to capture (in Studio Flow, flow.channel.address)
export type HandleChannelCaptureParams = (
| {
channelSid: ChatChannelSid;
conversationSid?: ConversationSid;
}
| { conversationSid: ConversationSid; channelSid?: ChatChannelSid }
) & {
message: string; // The triggering message (in Studio Flow, trigger.message.Body)
language: string; // (in Studio Flow, {{trigger.message.ChannelAttributes.pre_engagement_data.language | default: 'en-US'}} )
botSuffix: string;
Expand All @@ -354,7 +363,6 @@ export type HandleChannelCaptureParams = {
releaseFlag?: string; // The flag we want to set true in the channel attributes when the channel is released
additionControlTaskAttributes?: string; // Optional attributes to include in the control task, in the string representation of a JSON
controlTaskTTL?: number;
isConversation: boolean;
channelType: string;
};

Expand All @@ -363,8 +371,8 @@ type ValidationResult = { status: 'valid' } | { status: 'invalid'; error: string
const createValidationError = (error: string): ValidationResult => ({ status: 'invalid', error });

const validateHandleChannelCaptureParams = (params: Partial<HandleChannelCaptureParams>) => {
if (!params.channelSid) {
return createValidationError('Missing channelSid');
if (!params.channelSid && !params.conversationSid) {
return createValidationError('No channelSid or conversationSid provided');
}
if (!params.message) {
return createValidationError('Missing message');
Expand Down Expand Up @@ -398,15 +406,18 @@ const validateHandleChannelCaptureParams = (params: Partial<HandleChannelCapture

export const handleChannelCapture = async (
context: Context<EnvVars>,
params: Partial<HandleChannelCaptureParams>,
params: HandleChannelCaptureParams,
) => {
console.log('handleChannelCapture', params);
const validationResult = validateHandleChannelCaptureParams(params);
if (validationResult.status === 'invalid') {
console.error('Invalid params', validationResult.error);
return { status: 'failure', validationResult } as const;
}

const {
channelSid,
conversationSid,
message,
language,
botSuffix,
Expand All @@ -417,63 +428,72 @@ export const handleChannelCapture = async (
releaseFlag,
additionControlTaskAttributes,
controlTaskTTL,
isConversation,
channelType,
} = params as HandleChannelCaptureParams;

const parsedAdditionalControlTaskAttributes = additionControlTaskAttributes
? JSON.parse(additionControlTaskAttributes)
: {};

const [, , controlTask] = await Promise.all([
// Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions
!isConversation &&
let controlTask: TaskInstance;
if (conversationSid) {
const conversationContext = await context
.getTwilioClient()
.conversations.conversations(conversationSid);
console.log('conversation state:', (await conversationContext.fetch()).state);
// Create control task to prevent channel going stale
controlTask = await context
.getTwilioClient()
.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID)
.tasks.create({
workflowSid: context.SURVEY_WORKFLOW_SID,
taskChannel: 'survey',
attributes: JSON.stringify({
isChatCaptureControl: true,
conversationSid,
...parsedAdditionalControlTaskAttributes,
}),
timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours
});
const webhooks = await conversationContext.webhooks.list();
for (const webhook of webhooks) {
if (webhook.target === 'studio') {
// eslint-disable-next-line no-await-in-loop
await webhook.remove();
}
}
} else {
[, controlTask] = await Promise.all([
// Remove the studio trigger webhooks to prevent this channel to trigger subsequent Studio flows executions
context
.getTwilioClient()
.chat.services(context.CHAT_SERVICE_SID)
.channels(channelSid)
.webhooks.list()
.then((channelWebhooks) =>
channelWebhooks.map(async (w) => {
.then((webhooks) =>
webhooks.map(async (w) => {
if (w.type === 'studio') {
await w.remove();
}
}),
),

/*
* Doing the "same" as above but for Conversations. Differences to the Studio Webhook in this case:
* - It's NOT found under the channel webhooks, but under the conversation webhooks
* - It uses the property 'target' instead of 'type'
*/
isConversation &&
// Create control task to prevent channel going stale
context
.getTwilioClient()
.conversations.conversations(channelSid)
.webhooks.list()
.then((channelWebhooks) =>
channelWebhooks.map(async (w) => {
if (w.target === 'studio') {
await w.remove();
}
.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID)
.tasks.create({
workflowSid: context.SURVEY_WORKFLOW_SID,
taskChannel: 'survey',
attributes: JSON.stringify({
isChatCaptureControl: true,
channelSid,
conversationSid,
...parsedAdditionalControlTaskAttributes,
}),
),

// Create control task to prevent channel going stale
context
.getTwilioClient()
.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID)
.tasks.create({
workflowSid: context.SURVEY_WORKFLOW_SID,
taskChannel: 'survey',
attributes: JSON.stringify({
isChatCaptureControl: true,
channelSid,
...parsedAdditionalControlTaskAttributes,
timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours
}),
timeout: controlTaskTTL || 45600, // 720 minutes or 12 hours
}),
]);
]);
}

const { ENVIRONMENT, HELPLINE_CODE } = context;
let languageSanitized = language.replace('-', '_'); // Lex doesn't accept '-'
Expand All @@ -485,8 +505,8 @@ export const handleChannelCapture = async (

const botName = `${ENVIRONMENT}_${HELPLINE_CODE.toLowerCase()}_${languageSanitized}_${botSuffix}`;

const channelOrConversation: ChannelInstance | ConversationInstance = isConversation
? await context.getTwilioClient().conversations.conversations(channelSid).fetch()
const channelOrConversation: ChannelInstance | ConversationInstance = conversationSid
? await context.getTwilioClient().conversations.conversations(conversationSid).fetch()
: await context
.getTwilioClient()
.chat.services(context.CHAT_SERVICE_SID)
Expand All @@ -503,7 +523,7 @@ export const handleChannelCapture = async (
inputText: message,
userId: channelOrConversation.sid,
controlTaskSid: controlTask.sid,
isConversation,
isConversation: Boolean(conversationSid),
channelType,
};

Expand All @@ -529,7 +549,7 @@ const createStudioFlowTrigger = async (
controlTask: TaskInstance,
) => {
// Canceling tasks triggers janitor (see functions/taskrouterListeners/janitorListener.private.ts), so we remove this one since is not needed
controlTask.remove();
await controlTask.remove();
const { isConversation } = capturedChannelAttributes;

if (isConversation) {
Expand Down
11 changes: 1 addition & 10 deletions functions/helpers/chatChannelJanitor.private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,9 @@ const deactivateConversation = async (
conversationSid: ConversationSid,
) => {
const client = context.getTwilioClient();
const conversationContext = client.conversations.conversations(conversationSid);
const webhooks = await conversationContext.webhooks.list();
console.log('webhooks');
webhooks.forEach((wh) => {
console.log(wh.sid, wh.configuration.method, wh.configuration.url, wh.configuration.filters);
});
const conversation = await client.conversations.conversations(conversationSid).fetch();
const attributes = JSON.parse(conversation.attributes);

console.log('conversation properties', ...Object.entries(conversation));
console.log('conversation attributes', ...Object.entries(attributes));

if (conversation.state !== 'closed') {
if (attributes.proxySession) {
await deleteProxySession(context, attributes.proxySession);
Expand All @@ -149,7 +140,7 @@ const deactivateConversation = async (
return { message: 'Conversation deactivated', updated };
}

return { message: 'Conversation already INACTIVE, event ignored' };
return { message: 'Conversation already closed, event ignored' };
};

export const chatChannelJanitor = async (
Expand Down
91 changes: 91 additions & 0 deletions functions/interaction/transitionAgentParticipants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* 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 { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types';
import {
bindResolve,
error400,
functionValidator as TokenValidator,
responseWithCors,
success,
} from '@tech-matters/serverless-helpers';
import { InteractionChannelParticipantStatus } from 'twilio/lib/rest/flexApi/v1/interaction/interactionChannel/interactionChannelParticipant';

type EnvVars = {
TWILIO_WORKSPACE_SID: string;
};

type Body = {
taskSid: string;
targetStatus: InteractionChannelParticipantStatus;
request: { cookies: {}; headers: {} };
};

/**
* This function looks up a Flex interaction & interaction channel using the attributes of the provided Task.
* It will then transition any participants in the interaction channel of type 'agent' to the pspecified state.
* This will automatically wrap up or complete the task in question WITHOUT closing the attached conversation - allowing post wrapup activity like post surveys to be performed
* This approach is required because the default WrapupTask / CompleteTask Flex Actions will close the conversation, and the ChatOrchestrator cannot be used to prevent this behaviour like it could with Programmable Chat tasks.
*/
export const handler = TokenValidator(
async (context: Context<EnvVars>, event: Body, callback: ServerlessCallback) => {
console.log('==== transitionAgentParticipants ====');
const response = responseWithCors();
const resolve = bindResolve(callback)(response);
const { taskSid, targetStatus } = event;

if (!taskSid) return resolve(error400('taskSid'));
if (!targetStatus) return resolve(error400('targetStatus'));

const client = context.getTwilioClient();
const task = await client.taskrouter.workspaces
.get(context.TWILIO_WORKSPACE_SID)
.tasks.get(taskSid)
.fetch();
const { flexInteractionSid, flexInteractionChannelSid } = JSON.parse(task.attributes);

if (!flexInteractionSid || !flexInteractionChannelSid) {
console.warn(
"transitionAgentParticipants called with a task without a flexInteractionSid or flexInteractionChannelSid set in it's attributes - is it being called with a Programmable Chat task?",
task.attributes,
);
return resolve(
error400(
"Task specified must have a flexInteractionSid and flexInteractionChannelSid set in it's attributes",
),
);
}
const interactionParticipantContext = client.flexApi.v1.interaction
.get(flexInteractionSid)
.channels.get(flexInteractionChannelSid).participants;
const interactionAgentParticipants = (await interactionParticipantContext.list()).filter(
(p) => p.type === 'agent',
);

// Should only be 1, but just in case
await Promise.all(
interactionAgentParticipants.map((p) => {
console.log(
`Transitioning agent participant ${p.sid} to ${targetStatus}`,
p.interactionSid,
p.channelSid,
);
return p.update({ status: targetStatus });
}),
);
return resolve(success({ message: 'Transitioned agent participants' }));
},
);
Loading

0 comments on commit 4b74bd3

Please sign in to comment.