chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './start-hitl'
export * from './stop-hitl'
+204
View File
@@ -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,
},
})
}
+46
View File
@@ -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 {}
}
+29
View File
@@ -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)
+10
View File
@@ -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)[]
+188
View File
@@ -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'
+4
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export * as beforeIncomingMessage from './before-incoming-message'
export * as beforeIncomingEvent from './before-incoming-event'
+58
View File
@@ -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
}
+68
View File
@@ -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
+32
View File
@@ -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']>>>
+88
View File
@@ -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`
}
}
+93
View File
@@ -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
}
}