chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './start-hitl'
|
||||
export * from './stop-hitl'
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { DEFAULT_HITL_HANDOFF_MESSAGE } from '../../plugin.definition'
|
||||
import * as configuration from '../configuration'
|
||||
import * as conv from '../conv-manager'
|
||||
import type * as types from '../types'
|
||||
import * as user from '../user-linker'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type StartHitlInput = bp.actions.startHitl.input.Input
|
||||
type MessageHistoryElement = NonNullable<bp.interfaces.hitl.actions.startHitl.input.Input['messageHistory']>[number]
|
||||
type Props = Parameters<bp.PluginProps['actions']['startHitl']>[0]
|
||||
|
||||
export const startHitl: bp.PluginProps['actions']['startHitl'] = async (props) => {
|
||||
const { conversationId: upstreamConversationId, userId: upstreamUserId, userEmail: upstreamUserEmail } = props.input
|
||||
if (!upstreamConversationId.length) {
|
||||
throw new sdk.RuntimeError('conversationId is required to start HITL')
|
||||
}
|
||||
if (!upstreamUserId.length) {
|
||||
throw new sdk.RuntimeError('userId is required to start HITL')
|
||||
}
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({ id: upstreamConversationId })
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
|
||||
if (upstreamConversation.tags.upstream || upstreamConversation.integration === props.interfaces.hitl.name) {
|
||||
// Without this check, closing the downstream conversation (the ticket) can
|
||||
// result in the bot calling startHitl a second time, but using the
|
||||
// downstream conversation as if it was the upstream conversation. Human
|
||||
// support agents would thus see "I have escalated this to a human" inside
|
||||
// the ticket after closing it.
|
||||
return {}
|
||||
}
|
||||
|
||||
if (await upstreamCm.isHitlActive()) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const lastMessageByUser = await _getLastUserMessageId({ upstreamConversation })
|
||||
|
||||
if (upstreamConversation.tags.startMessageId && upstreamConversation.tags.startMessageId === lastMessageByUser) {
|
||||
// This is a hack because of a bug in the studio or webchat that causes it
|
||||
// to call startHitl by replaying the same message.
|
||||
return {}
|
||||
}
|
||||
|
||||
const sessionConfig = await configuration.configureNewHitlSession({
|
||||
...props,
|
||||
upstreamConversationId,
|
||||
configurationOverrides: props.input.configurationOverrides,
|
||||
})
|
||||
await _sendHandoffMessage(upstreamCm, sessionConfig)
|
||||
|
||||
const users = new user.UserLinker(props)
|
||||
const downstreamUserId = await users.getDownstreamUserId(upstreamUserId, { email: upstreamUserEmail })
|
||||
|
||||
const messageHistory = await _buildMessageHistory(upstreamConversation, users)
|
||||
|
||||
const downstreamConversation = await _createDownstreamConversation(
|
||||
props,
|
||||
downstreamUserId,
|
||||
props.input,
|
||||
messageHistory
|
||||
)
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
await upstreamCm.setUserId(upstreamUserId)
|
||||
|
||||
await _linkConversations(upstreamConversation, downstreamConversation)
|
||||
await _saveStartMessageId({ upstreamConversation, startMessageId: lastMessageByUser })
|
||||
await _activateHitl(upstreamCm, downstreamCm)
|
||||
await _startHitlTimeout(props, upstreamCm, downstreamCm, upstreamUserId, sessionConfig)
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
const _sendHandoffMessage = (
|
||||
upstreamCm: conv.ConversationManager,
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
): Promise<void> => upstreamCm.maybeRespondText(sessionConfig.onHitlHandoffMessage, DEFAULT_HITL_HANDOFF_MESSAGE)
|
||||
|
||||
const _buildMessageHistory = async (
|
||||
upstreamConversation: types.ActionableConversation,
|
||||
users: user.UserLinker
|
||||
): Promise<MessageHistoryElement[]> => {
|
||||
const upstreamMessages = await upstreamConversation.listMessages().takePage(1)
|
||||
|
||||
// Sort messages by creation date, with the oldest first:
|
||||
upstreamMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
|
||||
const messageHistory: MessageHistoryElement[] = await Promise.all(
|
||||
upstreamMessages.map(
|
||||
async (message) =>
|
||||
({
|
||||
source:
|
||||
message.direction === 'outgoing'
|
||||
? { type: 'bot' }
|
||||
: {
|
||||
type: 'user',
|
||||
userId: await users.getDownstreamUserId(message.userId),
|
||||
},
|
||||
type: message.type,
|
||||
payload: message.payload,
|
||||
}) as MessageHistoryElement
|
||||
)
|
||||
)
|
||||
|
||||
return messageHistory
|
||||
}
|
||||
|
||||
const _createDownstreamConversation = async (
|
||||
props: Props,
|
||||
downstreamUserId: string,
|
||||
input: StartHitlInput,
|
||||
messageHistory: MessageHistoryElement[]
|
||||
): Promise<types.ActionableConversation> => {
|
||||
// Call startHitl in the hitl integration (zendesk, etc.):
|
||||
const { conversationId: downstreamConversationId } = await props.actions.hitl.startHitl({
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
hitlSession: input.hitlSession,
|
||||
userId: downstreamUserId,
|
||||
messageHistory,
|
||||
})
|
||||
|
||||
return await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
}
|
||||
|
||||
const _linkConversations = (
|
||||
upstreamConversation: types.ActionableConversation,
|
||||
downstreamConversation: types.ActionableConversation
|
||||
) =>
|
||||
Promise.all([
|
||||
upstreamConversation.update({
|
||||
tags: {
|
||||
downstream: downstreamConversation.id,
|
||||
},
|
||||
}),
|
||||
downstreamConversation.update({
|
||||
tags: {
|
||||
upstream: upstreamConversation.id,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const _activateHitl = (upstreamCm: conv.ConversationManager, downstreamCm: conv.ConversationManager) =>
|
||||
Promise.all([upstreamCm.setHitlActive(), downstreamCm.setHitlActive()])
|
||||
|
||||
const _startHitlTimeout = async (
|
||||
props: Props,
|
||||
upstreamCm: conv.ConversationManager,
|
||||
downstreamCm: conv.ConversationManager,
|
||||
upstreamUserId: string,
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
) => {
|
||||
const { agentAssignedTimeoutSeconds } = sessionConfig
|
||||
|
||||
if (!agentAssignedTimeoutSeconds) {
|
||||
return
|
||||
}
|
||||
|
||||
await props.events.humanAgentAssignedTimeout
|
||||
.withConversationId(upstreamCm.conversationId)
|
||||
.withUserId(upstreamUserId)
|
||||
.schedule(
|
||||
{
|
||||
sessionStartedAt: new Date().toISOString(),
|
||||
downstreamConversationId: downstreamCm.conversationId,
|
||||
},
|
||||
{ delay: agentAssignedTimeoutSeconds * 1000 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the id of the last message sent by the user in the conversation.
|
||||
*
|
||||
* This is effectively a hack to ensure that the studio/webchat does not call
|
||||
* startHitl twice for the same user message.
|
||||
*/
|
||||
const _getLastUserMessageId = async (props: {
|
||||
upstreamConversation: types.ActionableConversation
|
||||
}): Promise<string | undefined> => {
|
||||
for await (const message of props.upstreamConversation.listMessages()) {
|
||||
if (message.direction === 'incoming') {
|
||||
return message.id
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const _saveStartMessageId = async (props: {
|
||||
upstreamConversation: types.ActionableConversation
|
||||
startMessageId: string | undefined
|
||||
}) => {
|
||||
if (!props.startMessageId) {
|
||||
return
|
||||
}
|
||||
|
||||
await props.upstreamConversation.update({
|
||||
tags: {
|
||||
startMessageId: props.startMessageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DEFAULT_USER_HITL_CANCELLED_MESSAGE } from 'plugin.definition'
|
||||
import * as configuration from '../configuration'
|
||||
import * as conv from '../conv-manager'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const stopHitl: bp.PluginProps['actions']['stopHitl'] = async (props) => {
|
||||
const { conversationId: upstreamConversationId } = props.input
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({ id: upstreamConversationId })
|
||||
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
const isHitlActive = await upstreamCm.isHitlActive()
|
||||
if (!isHitlActive) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const downstreamConversationId = upstreamConversation.tags.downstream
|
||||
if (!downstreamConversationId) {
|
||||
props.logger
|
||||
.withConversationId(upstreamConversationId)
|
||||
.error('Upstream conversation is not bound to a downstream conversation')
|
||||
return {}
|
||||
}
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId,
|
||||
})
|
||||
|
||||
await downstreamCm.maybeRespondText(sessionConfig.onUserHitlCancelledMessage, DEFAULT_USER_HITL_CANCELLED_MESSAGE)
|
||||
|
||||
await Promise.allSettled([
|
||||
upstreamCm.setHitlInactive(conv.HITL_END_REASON.CLOSE_ACTION_CALLED),
|
||||
downstreamCm.setHitlInactive(conv.HITL_END_REASON.CLOSE_ACTION_CALLED),
|
||||
])
|
||||
|
||||
// Call stopHitl in the hitl integration (zendesk, etc.):
|
||||
await props.actions.hitl.stopHitl({ conversationId: downstreamConversationId })
|
||||
|
||||
// TODO: possibly send the workflowContinue event here
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const configureNewHitlSession = async (props: {
|
||||
states: bp.ActionHandlerProps['states']
|
||||
configuration: bp.configuration.Configuration
|
||||
configurationOverrides?: Partial<bp.configuration.Configuration>
|
||||
upstreamConversationId: string
|
||||
}): Promise<bp.configuration.Configuration> => {
|
||||
const effectiveSessionConfiguration = {
|
||||
...props.configuration,
|
||||
...props.configurationOverrides,
|
||||
}
|
||||
|
||||
await props.states.conversation.effectiveSessionConfig.set(
|
||||
props.upstreamConversationId,
|
||||
effectiveSessionConfiguration
|
||||
)
|
||||
|
||||
return effectiveSessionConfiguration
|
||||
}
|
||||
|
||||
export const retrieveSessionConfig = async (props: {
|
||||
states: bp.ActionHandlerProps['states']
|
||||
configuration: bp.configuration.Configuration
|
||||
upstreamConversationId: string
|
||||
}): Promise<bp.configuration.Configuration> =>
|
||||
await props.states.conversation.effectiveSessionConfig
|
||||
.get(props.upstreamConversationId)
|
||||
.catch(() => props.configuration)
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const SUPPORTED_MESSAGE_TYPES = [
|
||||
'text',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'file',
|
||||
'bloc',
|
||||
] as const satisfies (keyof typeof sdk.messages.defaults)[]
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NULL_MESSAGE_CODE } from 'plugin.definition'
|
||||
import * as types from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
// this state is hardcoded in the Studio/DM
|
||||
type TypingIndicatorState = { enabled: boolean }
|
||||
const TYPING_INDICATOR_STATE_NAME = 'typingIndicatorEnabled'
|
||||
|
||||
type HitlState = bp.states.hitl.Hitl['payload']
|
||||
|
||||
const DEFAULT_STATE: HitlState = { hitlActive: false }
|
||||
|
||||
export const HITL_END_REASON = {
|
||||
// PATIENT_LEFT: 'patient-left',
|
||||
PATIENT_USED_TERMINATION_COMMAND: 'patient-used-termination-command',
|
||||
AGENT_ASSIGNMENT_TIMEOUT: 'agent-assignment-timeout',
|
||||
// AGENT_RESPONSE_TIMEOUT: 'agent-response-timeout',
|
||||
AGENT_CLOSED_TICKET: 'agent-closed-ticket',
|
||||
CLOSE_ACTION_CALLED: 'close-action-called',
|
||||
INTERNAL_ERROR: 'internal-error',
|
||||
} as const
|
||||
type HitlEndReason = (typeof HITL_END_REASON)[keyof typeof HITL_END_REASON]
|
||||
|
||||
export class ConversationManager {
|
||||
public static from(props: types.AnyHandlerProps, conversation: types.ActionableConversation): ConversationManager {
|
||||
return new ConversationManager(props, conversation)
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private _props: types.AnyHandlerProps,
|
||||
private _conversation: types.ActionableConversation
|
||||
) {}
|
||||
|
||||
public get conversationId(): string {
|
||||
return this._conversation.id
|
||||
}
|
||||
|
||||
public async setHumanAgent(humanAgentId: string, humanAgentName: string) {
|
||||
await this._patchConversationTags({ humanAgentId, humanAgentName })
|
||||
}
|
||||
|
||||
public isHumanAgentAssigned(): boolean {
|
||||
return !!this._conversation.tags.humanAgentId?.length
|
||||
}
|
||||
|
||||
public async isHitlActive(): Promise<boolean> {
|
||||
const hitlState = await this._getHitlState()
|
||||
return hitlState.hitlActive
|
||||
}
|
||||
|
||||
public async setHitlActive(): Promise<void> {
|
||||
await this._setHitlState({ hitlActive: true })
|
||||
await this._toggleTypingIndicator({ enabled: false })
|
||||
}
|
||||
|
||||
public async setHitlInactive(reason: HitlEndReason): Promise<void> {
|
||||
await Promise.all([
|
||||
this._setHitlState({ hitlActive: false }),
|
||||
this._patchConversationTags({ hitlEndReason: reason }),
|
||||
this._toggleTypingIndicator({ enabled: true }),
|
||||
])
|
||||
}
|
||||
|
||||
public async continueWorkflow(): Promise<void> {
|
||||
const initiatingUserId = await this._props.states.conversation.initiatingUser
|
||||
.get(this._conversation.id)
|
||||
.then((state) => state.upstreamUserId)
|
||||
|
||||
let eventEmitter = this._props.events.continueWorkflow.withConversationId(this._conversation.id)
|
||||
|
||||
if (initiatingUserId) {
|
||||
eventEmitter = eventEmitter.withUserId(initiatingUserId)
|
||||
}
|
||||
|
||||
await eventEmitter.emit({
|
||||
conversationId: this._conversation.id,
|
||||
})
|
||||
}
|
||||
|
||||
public async maybeRespondText(message: string | undefined, defaultMsg: string): Promise<void> {
|
||||
if (message === NULL_MESSAGE_CODE) {
|
||||
return
|
||||
}
|
||||
const text = message || defaultMsg
|
||||
await this.respond({ type: 'text', text })
|
||||
}
|
||||
|
||||
public async respond(messagePayload: types.MessagePayload, tags: types.MessageTags = {}): Promise<void> {
|
||||
// FIXME: in the future, we should use the provided UserId so that messages
|
||||
// on Botpress appear to come from the agent/user instead of the
|
||||
// bot user. For now, this is not possible because of checks in the
|
||||
// backend.
|
||||
|
||||
// FIXME: typescript has trouble narrowing the type here, so we use a switch
|
||||
// statement as a workaround.
|
||||
|
||||
switch (messagePayload.type) {
|
||||
case 'text':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
case 'image':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
case 'audio':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
case 'file':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
case 'video':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
case 'bloc':
|
||||
await this._conversation.createMessage({
|
||||
type: messagePayload.type,
|
||||
userId: this._props.ctx.botId,
|
||||
payload: messagePayload,
|
||||
tags,
|
||||
})
|
||||
break
|
||||
default:
|
||||
messagePayload satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
public async abortHitlSession(errorMessage: string): Promise<void> {
|
||||
await this.setHitlInactive(HITL_END_REASON.INTERNAL_ERROR)
|
||||
await this.respond({ type: 'text', text: errorMessage })
|
||||
}
|
||||
|
||||
public async setUserId(userId: string): Promise<void> {
|
||||
return await this._props.states.conversation.initiatingUser.set(this._conversation.id, { upstreamUserId: userId })
|
||||
}
|
||||
|
||||
private async _getHitlState(): Promise<bp.states.hitl.Hitl['payload']> {
|
||||
return await this._props.states.conversation.hitl.getOrSet(this._conversation.id, DEFAULT_STATE)
|
||||
}
|
||||
|
||||
private async _setHitlState(state: HitlState): Promise<void> {
|
||||
return await this._props.states.conversation.hitl.set(this._conversation.id, state)
|
||||
}
|
||||
|
||||
private async _patchConversationTags(tags: Record<string, string>): Promise<void> {
|
||||
await this._conversation.update({ tags })
|
||||
}
|
||||
|
||||
private async _toggleTypingIndicator(payload: TypingIndicatorState): Promise<void> {
|
||||
try {
|
||||
await this._props.client.setState({
|
||||
id: this._conversation.id,
|
||||
type: 'conversation',
|
||||
name: TYPING_INDICATOR_STATE_NAME,
|
||||
payload,
|
||||
})
|
||||
} catch (thrown) {
|
||||
// because this state is hardcoded in the Studio / DM, it might not exist in some bot-as-code or ADK bots
|
||||
const errorMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
this._props.logger
|
||||
.withConversationId(this._conversation.id)
|
||||
.debug(`Could not set typing indicator state: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as conv from '../../conv-manager'
|
||||
import * as consts from '../consts'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const getConversationId = (props: bp.HookHandlerProps['before_incoming_event']): string | undefined => {
|
||||
const { data: event } = props
|
||||
if (event.conversationId) {
|
||||
return event.conversationId
|
||||
}
|
||||
if ('conversationId' in event.payload && typeof event.payload.conversationId === 'string') {
|
||||
return event.payload.conversationId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const handleEvent: bp.HookHandlers['before_incoming_event']['*'] = async (props) => {
|
||||
const conversationId = getConversationId(props)
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: conversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
const isHitlActive = await downstreamCm.isHitlActive()
|
||||
if (isHitlActive) {
|
||||
/**
|
||||
* if conversation is downstream; we prevent the bot from answering in the ticket
|
||||
* if conversation is upstream; we prevent the bot from answering in the chat
|
||||
*/
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as conv from '../../conv-manager'
|
||||
import * as consts from '../consts'
|
||||
import { assignAgent } from '../operations'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent: bp.HookHandlers['before_incoming_event']['hitl:hitlAssigned'] = async (props) => {
|
||||
const { conversationId: downstreamConversationId, userId: humanAgentUserId } = props.data.payload
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
const isHitlActive = await downstreamCm.isHitlActive()
|
||||
if (!isHitlActive) {
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
await assignAgent({
|
||||
props,
|
||||
downstreamConversation,
|
||||
humanAgentUserId,
|
||||
})
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DEFAULT_HITL_STOPPED_MESSAGE } from '../../../plugin.definition'
|
||||
import * as configuration from '../../configuration'
|
||||
import * as conv from '../../conv-manager'
|
||||
import * as consts from '../consts'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent: bp.HookHandlers['before_incoming_event']['hitl:hitlStopped'] = async (props) => {
|
||||
const { conversationId: downstreamConversationId } = props.data.payload
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
const isHitlActive = await downstreamCm.isHitlActive()
|
||||
if (!isHitlActive) {
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const upstreamConversationId = downstreamConversation.tags.upstream
|
||||
if (!upstreamConversationId) {
|
||||
props.logger
|
||||
.withConversationId(downstreamConversationId)
|
||||
.error('Downstream conversation was not binded to upstream conversation')
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({ id: upstreamConversationId })
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId,
|
||||
})
|
||||
|
||||
await Promise.allSettled([
|
||||
upstreamCm.maybeRespondText(sessionConfig.onHitlStoppedMessage, DEFAULT_HITL_STOPPED_MESSAGE),
|
||||
downstreamCm.setHitlInactive(conv.HITL_END_REASON.AGENT_CLOSED_TICKET),
|
||||
upstreamCm.setHitlInactive(conv.HITL_END_REASON.AGENT_CLOSED_TICKET),
|
||||
])
|
||||
|
||||
if (sessionConfig.flowOnHitlStopped) {
|
||||
// the bot will continue the conversation without the patient having to send another message
|
||||
await upstreamCm.continueWorkflow()
|
||||
}
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { DEFAULT_AGENT_ASSIGNED_TIMEOUT_MESSAGE, DEFAULT_USER_HITL_CANCELLED_MESSAGE } from '../../../plugin.definition'
|
||||
import * as configuration from '../../configuration'
|
||||
import * as conv from '../../conv-manager'
|
||||
import * as consts from '../consts'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleEvent: bp.HookHandlers['before_incoming_event']['humanAgentAssignedTimeout'] = async (props) => {
|
||||
const {
|
||||
conversationId: upstreamConversationId,
|
||||
payload: { downstreamConversationId },
|
||||
} = props.data
|
||||
|
||||
if (!upstreamConversationId || !downstreamConversationId) {
|
||||
props.logger.error('Missing conversationId in event payload')
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({ id: upstreamConversationId })
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
|
||||
if (upstreamCm.isHumanAgentAssigned()) {
|
||||
props.logger.info('Human agent assigned timeout event ignored because the agent is already assigned')
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
const isHitlActive = (await upstreamCm.isHitlActive()) && (await downstreamCm.isHitlActive())
|
||||
|
||||
if (!isHitlActive) {
|
||||
props.logger.info('Human agent assigned timeout event ignored because hitl is inactive')
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId,
|
||||
})
|
||||
|
||||
if (!_isTimeoutElapsed(props, sessionConfig)) {
|
||||
props.logger.info('Human agent assigned timeout event ignored because the timeout has not lapsed yet')
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
await _handleTimeout(props, upstreamCm, downstreamCm, sessionConfig)
|
||||
|
||||
if (sessionConfig.flowOnHitlStopped) {
|
||||
// the bot will continue the conversation without the patient having to send another message
|
||||
await upstreamCm.continueWorkflow()
|
||||
}
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const _isTimeoutElapsed = (
|
||||
props: bp.HookHandlerProps['before_incoming_event'],
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
): boolean => {
|
||||
if (!_isTimeoutEnabled(sessionConfig)) {
|
||||
props.logger.info('Human agent assigned timeout is not enabled')
|
||||
return false
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const timeWhenCreated = new Date(props.data.payload.sessionStartedAt).getTime()
|
||||
const elapsedSeconds = (now - timeWhenCreated) / 1000
|
||||
const timeoutSeconds = sessionConfig.agentAssignedTimeoutSeconds ?? 0
|
||||
|
||||
return elapsedSeconds >= timeoutSeconds
|
||||
}
|
||||
|
||||
const _isTimeoutEnabled = (sessionConfig: bp.configuration.Configuration): boolean =>
|
||||
!!sessionConfig.agentAssignedTimeoutSeconds
|
||||
|
||||
const _handleTimeout = async (
|
||||
props: bp.HookHandlerProps['before_incoming_event'],
|
||||
upstreamCm: conv.ConversationManager,
|
||||
downstreamCm: conv.ConversationManager,
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
) => {
|
||||
await downstreamCm.maybeRespondText(sessionConfig.onUserHitlCancelledMessage, DEFAULT_USER_HITL_CANCELLED_MESSAGE)
|
||||
|
||||
await Promise.allSettled([
|
||||
upstreamCm.setHitlInactive(conv.HITL_END_REASON.AGENT_ASSIGNMENT_TIMEOUT),
|
||||
downstreamCm.setHitlInactive(conv.HITL_END_REASON.AGENT_ASSIGNMENT_TIMEOUT),
|
||||
])
|
||||
|
||||
props.logger.withConversationId(upstreamCm.conversationId).info('HITL session ended due to agent assignment timeout')
|
||||
props.logger
|
||||
.withConversationId(downstreamCm.conversationId)
|
||||
.info('HITL session ended due to agent assignment timeout')
|
||||
|
||||
// Call stopHitl in the hitl integration (zendesk, etc.):
|
||||
await props.actions.hitl.stopHitl({ conversationId: downstreamCm.conversationId })
|
||||
|
||||
await upstreamCm.maybeRespondText(sessionConfig.onAgentAssignedTimeoutMessage, DEFAULT_AGENT_ASSIGNED_TIMEOUT_MESSAGE)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * as hitlAssigned from './hitl-assigned'
|
||||
export * as hitlStopped from './hitl-stopped'
|
||||
export * as humanAgentAssignedTimeout from './human-agent-assigned-timeout'
|
||||
export * as all from './all'
|
||||
@@ -0,0 +1,235 @@
|
||||
import * as client from '@botpress/client'
|
||||
import {
|
||||
DEFAULT_INCOMPATIBLE_MSGTYPE_MESSAGE,
|
||||
DEFAULT_USER_HITL_CANCELLED_MESSAGE,
|
||||
DEFAULT_USER_HITL_CLOSE_COMMAND,
|
||||
DEFAULT_USER_HITL_COMMAND_MESSAGE,
|
||||
} from 'plugin.definition'
|
||||
import { assignAgent } from 'src/hooks/operations'
|
||||
import { tryLinkWebchatUser } from 'src/webchat'
|
||||
import * as configuration from '../../configuration'
|
||||
import * as conv from '../../conv-manager'
|
||||
import type * as types from '../../types'
|
||||
import * as consts from '../consts'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleMessage: bp.HookHandlers['before_incoming_message']['*'] = async (props) => {
|
||||
const conversation = await props.conversations.hitl.hitl.getById({
|
||||
id: props.data.conversationId,
|
||||
})
|
||||
|
||||
const { integration } = conversation
|
||||
if (integration === props.interfaces.hitl.name) {
|
||||
return await _handleDownstreamMessage(props, conversation)
|
||||
}
|
||||
return await _handleUpstreamMessage(props, conversation)
|
||||
}
|
||||
|
||||
const _handleDownstreamMessage = async (
|
||||
props: bp.HookHandlerProps['before_incoming_message'],
|
||||
downstreamConversation: types.ActionableConversation
|
||||
) => {
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
const isHitlActive = await downstreamCm.isHitlActive()
|
||||
if (!isHitlActive) {
|
||||
return consts.STOP_EVENT_HANDLING // we don't want the bot to chat with the human agent in a closed ticket
|
||||
}
|
||||
|
||||
const downstreamUserId = props.data.userId
|
||||
const downstreamUser = await props.users.getById({ id: downstreamUserId })
|
||||
|
||||
const upstreamConversationId = downstreamConversation.tags['upstream']
|
||||
|
||||
if (!upstreamConversationId) {
|
||||
return await _abortHitlSession({
|
||||
cm: downstreamCm,
|
||||
internalReason: 'Downstream conversation was not bound to upstream conversation',
|
||||
reasonShownToUser: 'Something went wrong, you are not connected to a human agent...',
|
||||
props,
|
||||
})
|
||||
}
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId,
|
||||
})
|
||||
|
||||
const messagePayload = _getMessagePayloadIfSupported(props.data)
|
||||
|
||||
if (!messagePayload) {
|
||||
props.logger.with(props.data).error('Downstream conversation received a non-text message')
|
||||
await downstreamCm.maybeRespondText(
|
||||
sessionConfig.onIncompatibleMsgTypeMessage,
|
||||
DEFAULT_INCOMPATIBLE_MSGTYPE_MESSAGE
|
||||
)
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({ id: upstreamConversationId })
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
|
||||
props.logger.withConversationId(downstreamConversation.id).info('Sending message to upstream')
|
||||
|
||||
const upstreamUserId = await tryLinkWebchatUser(props, { downstreamUser, upstreamConversation })
|
||||
|
||||
if (!downstreamConversation.tags.humanAgentId?.length) {
|
||||
// Try to assing here, if there is no human agent assigned to the downstream conversation
|
||||
await assignAgent({
|
||||
props,
|
||||
downstreamConversation,
|
||||
humanAgentUserId: downstreamUser.id,
|
||||
})
|
||||
}
|
||||
|
||||
await upstreamCm.respond(
|
||||
{ ...messagePayload, userId: upstreamUserId },
|
||||
{ downstream: props.data.id, additionalData: _getMessageAdditionalData(props.data) }
|
||||
)
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const _getMessagePayloadIfSupported = (msg: client.Message): types.MessagePayload | undefined =>
|
||||
consts.SUPPORTED_MESSAGE_TYPES.includes(msg.type as types.SupportedMessageTypes)
|
||||
? ({ type: msg.type, ...msg.payload } as types.MessagePayload)
|
||||
: undefined
|
||||
|
||||
const _getMessageAdditionalData = (msg: client.Message): string | undefined => {
|
||||
// should be typed by the SDK because it's part of the hitl interface
|
||||
const propName = 'additionalData'
|
||||
if (propName in msg.payload && typeof msg.payload[propName] === 'string') {
|
||||
return msg.payload[propName]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const _handleUpstreamMessage = async (
|
||||
props: bp.HookHandlerProps['before_incoming_message'],
|
||||
upstreamConversation: types.ActionableConversation
|
||||
) => {
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
const isHitlActive = await upstreamCm.isHitlActive()
|
||||
if (!isHitlActive) {
|
||||
return consts.LET_BOT_HANDLE_EVENT
|
||||
}
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId: upstreamCm.conversationId,
|
||||
})
|
||||
|
||||
const messagePayload = _getMessagePayloadIfSupported(props.data)
|
||||
|
||||
if (!messagePayload) {
|
||||
props.logger.with(props.data).error('Upstream conversation received a non-text message')
|
||||
|
||||
const supportedMessageTypes = consts.SUPPORTED_MESSAGE_TYPES.join(', ')
|
||||
await upstreamCm.respond({
|
||||
type: 'text',
|
||||
text: `Sorry, I can only handle one of the following message types: ${supportedMessageTypes}`,
|
||||
})
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const downstreamConversationId = upstreamConversation.tags['downstream']
|
||||
if (!downstreamConversationId) {
|
||||
return await _abortHitlSession({
|
||||
cm: upstreamCm,
|
||||
internalReason: 'Upstream conversation was not bound to downstream conversation',
|
||||
reasonShownToUser: 'Something went wrong, you are not connected to a human agent...',
|
||||
props,
|
||||
})
|
||||
}
|
||||
|
||||
const patientUpstreamUser = await props.users.getById({ id: props.data.userId })
|
||||
|
||||
const patientDownstreamUserId = patientUpstreamUser.tags['downstream']
|
||||
if (!patientDownstreamUserId) {
|
||||
return await _abortHitlSession({
|
||||
cm: upstreamCm,
|
||||
internalReason: 'Upstream user was not bound to downstream user',
|
||||
reasonShownToUser: 'Something went wrong, you are not connected to a human agent...',
|
||||
props,
|
||||
})
|
||||
}
|
||||
|
||||
const downstreamConversation = await props.conversations.hitl.hitl.getById({ id: downstreamConversationId })
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
|
||||
if (_isHitlCloseCommand(props, sessionConfig)) {
|
||||
await _handleHitlCloseCommand(props, { downstreamCm, upstreamCm, sessionConfig })
|
||||
|
||||
if (sessionConfig.flowOnHitlStopped) {
|
||||
// the bot will continue the conversation without the patient having to send another message
|
||||
await upstreamCm.continueWorkflow()
|
||||
}
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
props.logger.withConversationId(upstreamConversation.id).info('Sending message to downstream')
|
||||
await downstreamCm.respond({ ...messagePayload, userId: patientDownstreamUserId }, { upstream: props.data.id })
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const _abortHitlSession = async ({
|
||||
cm,
|
||||
internalReason,
|
||||
reasonShownToUser,
|
||||
props,
|
||||
}: {
|
||||
cm: conv.ConversationManager
|
||||
internalReason: string
|
||||
reasonShownToUser: string
|
||||
props: bp.HookHandlerProps['before_incoming_message']
|
||||
}) => {
|
||||
props.logger.withConversationId(cm.conversationId).error(internalReason)
|
||||
|
||||
await cm.abortHitlSession(reasonShownToUser)
|
||||
|
||||
return consts.STOP_EVENT_HANDLING
|
||||
}
|
||||
|
||||
const _isHitlCloseCommand = (
|
||||
props: bp.HookHandlerProps['before_incoming_message'],
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
) => {
|
||||
const closeCommand = sessionConfig.userHitlCloseCommand?.length
|
||||
? sessionConfig.userHitlCloseCommand
|
||||
: DEFAULT_USER_HITL_CLOSE_COMMAND
|
||||
|
||||
const inputText: string | undefined = props.data.payload.text
|
||||
return inputText && inputText.trim().toLowerCase() === closeCommand.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const _handleHitlCloseCommand = async (
|
||||
props: bp.HookHandlerProps['before_incoming_message'],
|
||||
{
|
||||
downstreamCm,
|
||||
upstreamCm,
|
||||
sessionConfig,
|
||||
}: {
|
||||
downstreamCm: conv.ConversationManager
|
||||
upstreamCm: conv.ConversationManager
|
||||
sessionConfig: bp.configuration.Configuration
|
||||
}
|
||||
) => {
|
||||
await downstreamCm.maybeRespondText(sessionConfig.onUserHitlCancelledMessage, DEFAULT_USER_HITL_CANCELLED_MESSAGE)
|
||||
|
||||
await Promise.allSettled([
|
||||
upstreamCm.setHitlInactive(conv.HITL_END_REASON.PATIENT_USED_TERMINATION_COMMAND),
|
||||
downstreamCm.setHitlInactive(conv.HITL_END_REASON.PATIENT_USED_TERMINATION_COMMAND),
|
||||
])
|
||||
|
||||
props.logger
|
||||
.withConversationId(upstreamCm.conversationId)
|
||||
.info('User ended the HITL session using the termination command')
|
||||
props.logger
|
||||
.withConversationId(downstreamCm.conversationId)
|
||||
.info('User ended the HITL session using the termination command')
|
||||
|
||||
// Call stopHitl in the hitl integration (zendesk, etc.):
|
||||
await props.actions.hitl.stopHitl({ conversationId: downstreamCm.conversationId })
|
||||
|
||||
await upstreamCm.maybeRespondText(sessionConfig.onUserHitlCloseMessage, DEFAULT_USER_HITL_COMMAND_MESSAGE)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as all from './all'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from '../consts'
|
||||
|
||||
export const LET_BOT_HANDLE_EVENT = { stop: false } as const // let the event / message propagate to the bot
|
||||
export const STOP_EVENT_HANDLING = { stop: true } as const // prevent the event / message from propagating to the bot
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as beforeIncomingMessage from './before-incoming-message'
|
||||
export * as beforeIncomingEvent from './before-incoming-event'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { DEFAULT_HUMAN_AGENT_ASSIGNED_MESSAGE } from '../../plugin.definition'
|
||||
import * as configuration from '../configuration'
|
||||
import * as conv from '../conv-manager'
|
||||
import * as types from '../types'
|
||||
import { tryLinkWebchatUser } from '../webchat'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type AssignAgentProps = bp.HookHandlerProps['before_incoming_message'] | bp.HookHandlerProps['before_incoming_event']
|
||||
|
||||
export type AssignAgentOptions = {
|
||||
props: AssignAgentProps
|
||||
downstreamConversation: types.ActionableConversation
|
||||
humanAgentUserId: string
|
||||
forceLinkWebchatUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a human agent to both downstream and upstream conversations.
|
||||
* Also links the webchat user if applicable and sends the agent assigned message.
|
||||
*/
|
||||
export const assignAgent = async (options: AssignAgentOptions): Promise<boolean> => {
|
||||
const { props, downstreamConversation, humanAgentUserId, forceLinkWebchatUser = true } = options
|
||||
|
||||
const upstreamConversationId = downstreamConversation.tags.upstream
|
||||
if (!upstreamConversationId?.length) {
|
||||
props.logger
|
||||
.withConversationId(downstreamConversation.id)
|
||||
.error('Downstream conversation was not binded to upstream conversation')
|
||||
return false
|
||||
}
|
||||
|
||||
const upstreamConversation = await props.conversations.hitl.hitl.getById({
|
||||
id: upstreamConversationId,
|
||||
})
|
||||
|
||||
const upstreamCm = conv.ConversationManager.from(props, upstreamConversation)
|
||||
const downstreamCm = conv.ConversationManager.from(props, downstreamConversation)
|
||||
const humanAgentUser = await props.users.getById({ id: humanAgentUserId })
|
||||
const humanAgentName = humanAgentUser?.name?.length ? humanAgentUser.name : 'A Human Agent'
|
||||
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId: upstreamConversation.id,
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
downstreamCm.setHumanAgent(humanAgentUserId, humanAgentName),
|
||||
upstreamCm.setHumanAgent(humanAgentUserId, humanAgentName),
|
||||
upstreamCm.maybeRespondText(sessionConfig.onHumanAgentAssignedMessage, DEFAULT_HUMAN_AGENT_ASSIGNED_MESSAGE),
|
||||
tryLinkWebchatUser(props, {
|
||||
downstreamUser: humanAgentUser,
|
||||
upstreamConversation,
|
||||
forceLink: forceLinkWebchatUser,
|
||||
}),
|
||||
])
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { isBrowser } from 'browser-or-node'
|
||||
import * as actions from './actions'
|
||||
import * as hooks from './hooks'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const plugin = new bp.Plugin({
|
||||
actions: {
|
||||
startHitl: async (props) => {
|
||||
if (isBrowser) {
|
||||
throw new sdk.RuntimeError('HITL is not supported in the browser')
|
||||
}
|
||||
return await actions.startHitl(props)
|
||||
},
|
||||
stopHitl: async (props) => {
|
||||
if (isBrowser) {
|
||||
throw new sdk.RuntimeError('HITL is not supported in the browser')
|
||||
}
|
||||
return await actions.stopHitl(props)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
plugin.on.beforeIncomingMessage('*', async (props) => {
|
||||
if (isBrowser) {
|
||||
props.logger.warn('HITL is not supported in the browser')
|
||||
return
|
||||
}
|
||||
props.logger.info('Before incoming message', props.data.payload)
|
||||
return await hooks.beforeIncomingMessage.all.handleMessage(props)
|
||||
})
|
||||
|
||||
plugin.on.beforeIncomingEvent('hitl:hitlAssigned', async (props) => {
|
||||
if (isBrowser) {
|
||||
props.logger.warn('HITL is not supported in the browser')
|
||||
return
|
||||
}
|
||||
props.logger.info('HITL assigned', props.data.payload)
|
||||
return await hooks.beforeIncomingEvent.hitlAssigned.handleEvent(props)
|
||||
})
|
||||
|
||||
plugin.on.beforeIncomingEvent('hitl:hitlStopped', async (props) => {
|
||||
if (isBrowser) {
|
||||
props.logger.warn('HITL is not supported in the browser')
|
||||
return
|
||||
}
|
||||
props.logger.info('HITL stopped', props.data.payload)
|
||||
return await hooks.beforeIncomingEvent.hitlStopped.handleEvent(props)
|
||||
})
|
||||
|
||||
plugin.on.beforeIncomingEvent('humanAgentAssignedTimeout', async (props) => {
|
||||
if (isBrowser) {
|
||||
props.logger.warn('HITL is not supported in the browser')
|
||||
return
|
||||
}
|
||||
props.logger.info('HITL agent assigned timeout', props.data.payload)
|
||||
return await hooks.beforeIncomingEvent.humanAgentAssignedTimeout.handleEvent(props)
|
||||
})
|
||||
|
||||
plugin.on.beforeIncomingEvent('*', async (props) => {
|
||||
if (isBrowser) {
|
||||
return
|
||||
}
|
||||
props.logger.info('Before incoming event', props.data.payload)
|
||||
return await hooks.beforeIncomingEvent.all.handleEvent(props)
|
||||
})
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,32 @@
|
||||
import type * as sdk from '@botpress/sdk'
|
||||
import * as consts from './consts'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type AnyHandlerProps =
|
||||
| bp.MessageHandlerProps
|
||||
| bp.EventHandlerProps
|
||||
| bp.ActionHandlerProps
|
||||
| bp.HookHandlerProps['before_incoming_message']
|
||||
| bp.HookHandlerProps['before_incoming_event']
|
||||
|
||||
export type ValueOf<T> = T[Extract<keyof T, string>]
|
||||
type ArrayToUnion<T> = T extends Array<infer U> ? U : never
|
||||
|
||||
export type SupportedMessageTypes = ArrayToUnion<typeof consts.SUPPORTED_MESSAGE_TYPES>
|
||||
type BaseMessagePayloads = Pick<typeof sdk.messages.defaults, SupportedMessageTypes>
|
||||
export type MessagePayload = {
|
||||
[TMsgType in keyof BaseMessagePayloads]: {
|
||||
type: TMsgType
|
||||
userId?: string
|
||||
} & sdk.z.infer<BaseMessagePayloads[TMsgType]['schema']>
|
||||
}[keyof BaseMessagePayloads]
|
||||
|
||||
export type MessageTags = {
|
||||
[T in keyof bp.message.Message['tags']]?: string
|
||||
}
|
||||
|
||||
export type ActionableConversation = NonNullable<
|
||||
Awaited<ReturnType<bp.ActionHandlerProps['conversations']['hitl']['hitl']['getById']>>
|
||||
>
|
||||
export type ActionableMessage = NonNullable<Awaited<ReturnType<bp.ActionHandlerProps['messages']['getById']>>>
|
||||
export type ActionableUser = NonNullable<Awaited<ReturnType<bp.ActionHandlerProps['users']['getById']>>>
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as types from './types'
|
||||
|
||||
export type UserOverrides = { name?: string; email?: string; pictureUrl?: string }
|
||||
|
||||
export class UserLinker {
|
||||
public constructor(
|
||||
private _props: types.AnyHandlerProps,
|
||||
private _users: Record<string, types.ActionableUser> = {}
|
||||
) {}
|
||||
|
||||
public async getDownstreamUserId(upstreamUserId: string, upstreamUserOverrides?: UserOverrides): Promise<string> {
|
||||
let upstreamUser = this._users[upstreamUserId]
|
||||
if (!upstreamUser) {
|
||||
const fetchedUser = await this._props.users.getById({ id: upstreamUserId })
|
||||
this._users[upstreamUserId] = fetchedUser
|
||||
upstreamUser = fetchedUser
|
||||
}
|
||||
|
||||
const existingDownstreamUserId = await this._getExistingDownstreamUserId(upstreamUser)
|
||||
|
||||
if (existingDownstreamUserId !== null) {
|
||||
return existingDownstreamUserId
|
||||
}
|
||||
|
||||
const {
|
||||
downstreamUser: { id: downstreamUserId },
|
||||
upstreamUser: updatedUpstreamUser,
|
||||
} = await this._linkUser(upstreamUser, upstreamUserOverrides)
|
||||
|
||||
this._users[upstreamUserId] = updatedUpstreamUser
|
||||
|
||||
return downstreamUserId
|
||||
}
|
||||
|
||||
private async _getExistingDownstreamUserId(upstreamUser: types.ActionableUser) {
|
||||
const downstreamUserId = upstreamUser?.tags?.downstream
|
||||
|
||||
if (!downstreamUserId || upstreamUser?.tags?.integrationName !== this._props.interfaces.hitl.name) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
await this._props.users.getById({ id: downstreamUserId })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return downstreamUserId
|
||||
}
|
||||
|
||||
private async _linkUser(upstreamUser: types.ActionableUser, upstreamUserOverrides?: UserOverrides) {
|
||||
// To access bot-level tags:
|
||||
const untypedUserTags: Record<string, string> = upstreamUser.tags
|
||||
|
||||
// Call createUser in the hitl integration (zendesk, etc.):
|
||||
const { userId: downstreamUserId } = await this._props.actions.hitl.createUser({
|
||||
name: upstreamUserOverrides?.name ?? untypedUserTags['name'] ?? upstreamUser.name ?? 'Unknown User',
|
||||
pictureUrl: upstreamUserOverrides?.pictureUrl ?? untypedUserTags['pictureUrl'] ?? upstreamUser.pictureUrl,
|
||||
email: upstreamUserOverrides?.email ?? untypedUserTags['email'] ?? this._generateFakeEmail(upstreamUser),
|
||||
})
|
||||
const downstreamUser = await this._props.users.getById({ id: downstreamUserId })
|
||||
|
||||
const [updatedUpstreamUser, updatedDownstreamUser] = await Promise.all([
|
||||
upstreamUser.update({
|
||||
tags: {
|
||||
downstream: downstreamUserId,
|
||||
integrationName: this._props.interfaces.hitl.name,
|
||||
},
|
||||
}),
|
||||
downstreamUser.update({
|
||||
tags: {
|
||||
upstream: upstreamUser.id,
|
||||
integrationName: this._props.interfaces.hitl.name,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
upstreamUser: updatedUpstreamUser,
|
||||
downstreamUser: updatedDownstreamUser,
|
||||
}
|
||||
}
|
||||
|
||||
private _generateFakeEmail(user: types.ActionableUser) {
|
||||
const botId = this._props.ctx.botId.replaceAll('_', '-')
|
||||
return `${user.id}@no-reply.${botId}.botpress.com`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as configuration from './configuration'
|
||||
import type * as types from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WebchatGetOrCreateUserInput = {
|
||||
name?: string
|
||||
pictureUrl?: string
|
||||
email?: string
|
||||
user: {
|
||||
id: string
|
||||
conversationId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TryLinkWebchatUserOptions = {
|
||||
downstreamUser: types.ActionableUser
|
||||
upstreamConversation: types.ActionableConversation
|
||||
forceLink?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This functions tries to create a copy of the downstream user in the upstream system.
|
||||
* The goal is for the patient to have the illusion that they receive messages from a human agent instead of the bot.
|
||||
*
|
||||
* This only works when the hitl frontend is webchat.
|
||||
* Currently, this dependency of hitl plugin on webchat integration is not declared.
|
||||
* If it fails, the plugin still works, but patients will see messages as sent by the bot.
|
||||
*
|
||||
* This workaround is extremely flaky. If we need this feature in another integration, we should do it properly.
|
||||
*
|
||||
* @param props hook handler props
|
||||
* @param upstreamConversationId the upstream conversation id
|
||||
* @returns the fake upstream user id that the bot pretends to be when sending messages
|
||||
*/
|
||||
export const tryLinkWebchatUser = async (
|
||||
props: bp.HookHandlerProps['before_incoming_message'] | bp.HookHandlerProps['before_incoming_event'],
|
||||
{ forceLink, downstreamUser, upstreamConversation }: TryLinkWebchatUserOptions
|
||||
): Promise<string | undefined> => {
|
||||
const sessionConfig = await configuration.retrieveSessionConfig({
|
||||
...props,
|
||||
upstreamConversationId: upstreamConversation.id,
|
||||
})
|
||||
|
||||
const { integration: upstreamIntegration } = upstreamConversation
|
||||
|
||||
if (upstreamIntegration !== 'webchat' || !sessionConfig.useHumanAgentInfo) {
|
||||
// this only works when the hitl frontend is webchat
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
props.logger.info(
|
||||
`Trying to link downstream user ${downstreamUser.id} to upstream conversation ${upstreamConversation.id}`
|
||||
)
|
||||
|
||||
const upstreamUserId = downstreamUser.tags['upstream']
|
||||
if (upstreamUserId && !forceLink) {
|
||||
// the user is already linked
|
||||
return upstreamUserId
|
||||
}
|
||||
|
||||
// To access bot-level tags:
|
||||
const untypedDownstreamUserTags: Record<string, string> = downstreamUser.tags
|
||||
|
||||
const { output } = await props.client.callAction({
|
||||
type: 'webchat:getOrCreateUser',
|
||||
input: {
|
||||
name: downstreamUser.name,
|
||||
pictureUrl: downstreamUser.pictureUrl,
|
||||
email: untypedDownstreamUserTags['email'],
|
||||
user: {
|
||||
id: downstreamUser.id,
|
||||
conversationId: upstreamConversation.id,
|
||||
},
|
||||
} as WebchatGetOrCreateUserInput,
|
||||
})
|
||||
|
||||
const { userId } = output as Record<string, unknown>
|
||||
if (typeof userId !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
await downstreamUser.update({
|
||||
tags: {
|
||||
upstream: userId,
|
||||
},
|
||||
})
|
||||
|
||||
return userId
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user