feat: Use external service for triggers (#31268)
Co-authored-by: Aleksander Nicacio da Silva <6494543+aleksandernsilva@users.noreply.github.com> Co-authored-by: Martin Schoeler <20868078+MartinSchoeler@users.noreply.github.com> Co-authored-by: Marcos Spessatto Defendi <15324204+MarcosSpessatto@users.noreply.github.com>pull/31962/head^2
parent
871efa50e1
commit
b9e897a8f5
@ -0,0 +1,7 @@ |
||||
--- |
||||
'@rocket.chat/meteor': minor |
||||
'@rocket.chat/core-typings': minor |
||||
'@rocket.chat/livechat': minor |
||||
--- |
||||
|
||||
Added new Livechat trigger action "Send message (external service)" |
||||
@ -0,0 +1,74 @@ |
||||
import type { SelectOption } from '@rocket.chat/fuselage'; |
||||
import { Field, FieldGroup, FieldLabel, FieldRow, NumberInput, Select, TextInput } from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import type { ComponentProps } from 'react'; |
||||
import React, { useMemo } from 'react'; |
||||
import type { Control } from 'react-hook-form'; |
||||
import { Controller, useWatch } from 'react-hook-form'; |
||||
|
||||
import type { TriggersPayload } from './EditTrigger'; |
||||
|
||||
type ConditionFormType = ComponentProps<typeof Field> & { |
||||
index: number; |
||||
control: Control<TriggersPayload>; |
||||
}; |
||||
|
||||
export const ConditionForm = ({ control, index, ...props }: ConditionFormType) => { |
||||
const conditionFieldId = useUniqueId(); |
||||
const t = useTranslation(); |
||||
const conditionName = useWatch({ control, name: `conditions.${index}.name` }); |
||||
|
||||
const placeholders: { [conditionName: string]: string } = useMemo( |
||||
() => ({ |
||||
'page-url': t('Enter_a_regex'), |
||||
'time-on-site': t('Time_in_seconds'), |
||||
}), |
||||
[t], |
||||
); |
||||
|
||||
const conditionOptions: SelectOption[] = useMemo( |
||||
() => [ |
||||
['page-url', t('Visitor_page_URL')], |
||||
['time-on-site', t('Visitor_time_on_site')], |
||||
['chat-opened-by-visitor', t('Chat_opened_by_visitor')], |
||||
['after-guest-registration', t('After_guest_registration')], |
||||
], |
||||
[t], |
||||
); |
||||
|
||||
const conditionValuePlaceholder = placeholders[conditionName]; |
||||
|
||||
return ( |
||||
<FieldGroup {...props}> |
||||
<Field> |
||||
<FieldLabel htmlFor={conditionFieldId}>{t('Condition')}</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
name={`conditions.${index}.name`} |
||||
control={control} |
||||
render={({ field }) => ( |
||||
<Select {...field} id={conditionFieldId} options={conditionOptions} placeholder={t('Select_an_option')} /> |
||||
)} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{conditionValuePlaceholder && ( |
||||
<FieldRow> |
||||
<Controller |
||||
name={`conditions.${index}.value`} |
||||
control={control} |
||||
render={({ field }) => { |
||||
if (conditionName === 'time-on-site') { |
||||
return <NumberInput {...field} placeholder={conditionValuePlaceholder} />; |
||||
} |
||||
|
||||
return <TextInput {...field} placeholder={conditionValuePlaceholder} />; |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
)} |
||||
</Field> |
||||
</FieldGroup> |
||||
); |
||||
}; |
||||
@ -0,0 +1,97 @@ |
||||
import { Box, Button, Field, FieldError, FieldHint, FieldLabel, FieldRow, Icon, TextInput } from '@rocket.chat/fuselage'; |
||||
import { useSafely, useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts'; |
||||
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import { useMutation } from '@tanstack/react-query'; |
||||
import type { ComponentProps } from 'react'; |
||||
import React, { useState } from 'react'; |
||||
import type { Control, UseFormTrigger } from 'react-hook-form'; |
||||
import { Controller, useWatch } from 'react-hook-form'; |
||||
|
||||
import type { TriggersPayload } from '../EditTrigger'; |
||||
import { useFieldError } from '../hooks'; |
||||
|
||||
type ActionExternaServicelUrlType = ComponentProps<typeof Field> & { |
||||
index: number; |
||||
control: Control<TriggersPayload>; |
||||
trigger: UseFormTrigger<TriggersPayload>; |
||||
disabled?: boolean; |
||||
}; |
||||
|
||||
export const ActionExternalServiceUrl = ({ control, trigger, index, disabled, ...props }: ActionExternaServicelUrlType) => { |
||||
const t = useTranslation(); |
||||
|
||||
const serviceUrlFieldId = useUniqueId(); |
||||
const serviceUrlFieldName = `actions.${index}.params.serviceUrl` as const; |
||||
const serviceTimeoutFieldName = `actions.${index}.params.serviceTimeout` as const; |
||||
|
||||
const serviceTimeoutValue = useWatch({ control, name: serviceTimeoutFieldName }); |
||||
const [serviceUrlError] = useFieldError({ control, name: serviceUrlFieldName }); |
||||
const [isSuccessMessageVisible, setSuccessMessageVisible] = useSafely(useState(false)); |
||||
const webhookTestEndpoint = useEndpoint('POST', '/v1/livechat/triggers/external-service/test'); |
||||
|
||||
const showSuccessMesssage = () => { |
||||
setSuccessMessageVisible(true); |
||||
setTimeout(() => setSuccessMessageVisible(false), 3000); |
||||
}; |
||||
|
||||
const webhookTest = useMutation({ |
||||
mutationFn: webhookTestEndpoint, |
||||
onSuccess: showSuccessMesssage, |
||||
}); |
||||
|
||||
const testExternalService = async (serviceUrl: string) => { |
||||
if (!serviceUrl) { |
||||
return true; |
||||
} |
||||
|
||||
try { |
||||
await webhookTest.mutateAsync({ |
||||
webhookUrl: serviceUrl, |
||||
timeout: serviceTimeoutValue || 10000, |
||||
fallbackMessage: '', |
||||
extraData: [], |
||||
}); |
||||
return true; |
||||
} catch (e) { |
||||
return t((e as { error: TranslationKey }).error); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<Field {...props}> |
||||
<FieldLabel required htmlFor={serviceUrlFieldId}> |
||||
{t('External_service_url')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
name={serviceUrlFieldName} |
||||
control={control} |
||||
defaultValue='' |
||||
rules={{ |
||||
required: t('The_field_is_required', t('External_service_url')), |
||||
validate: testExternalService, |
||||
deps: serviceTimeoutFieldName, |
||||
}} |
||||
render={({ field }) => { |
||||
return <TextInput {...field} disabled={webhookTest.isLoading || disabled} error={serviceUrlError?.message} />; |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{serviceUrlError && <FieldError>{serviceUrlError.message}</FieldError>} |
||||
|
||||
<FieldHint>{t('External_service_test_hint')}</FieldHint> |
||||
|
||||
<Button loading={webhookTest.isLoading} disabled={disabled || isSuccessMessageVisible} onClick={() => trigger(serviceUrlFieldName)}> |
||||
{isSuccessMessageVisible ? ( |
||||
<Box is='span' color='status-font-on-success'> |
||||
<Icon name='success-circle' size='x20' verticalAlign='middle' /> {t('Success')}! |
||||
</Box> |
||||
) : ( |
||||
t('Send_Test') |
||||
)} |
||||
</Button> |
||||
</Field> |
||||
); |
||||
}; |
||||
@ -0,0 +1,113 @@ |
||||
import { |
||||
Box, |
||||
Field, |
||||
FieldGroup, |
||||
FieldHint, |
||||
FieldLabel, |
||||
FieldRow, |
||||
Option, |
||||
SelectLegacy, |
||||
Tag, |
||||
type SelectOption, |
||||
} from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import type { ComponentProps } from 'react'; |
||||
import React, { useCallback, useMemo } from 'react'; |
||||
import type { Control, UseFormTrigger } from 'react-hook-form'; |
||||
import { Controller, useWatch } from 'react-hook-form'; |
||||
|
||||
import { useHasLicenseModule } from '../../../../../ee/client/hooks/useHasLicenseModule'; |
||||
import { type TriggersPayload } from '../EditTrigger'; |
||||
import { getActionFormFields } from '../utils'; |
||||
|
||||
type SendMessageFormType = ComponentProps<typeof Field> & { |
||||
control: Control<TriggersPayload>; |
||||
trigger: UseFormTrigger<TriggersPayload>; |
||||
index: number; |
||||
}; |
||||
|
||||
const ACTION_HINTS: Record<string, TranslationKey> = { |
||||
'use-external-service': 'External_service_action_hint', |
||||
} as const; |
||||
|
||||
const PREMIUM_ACTIONS = ['use-external-service']; |
||||
|
||||
export const ActionForm = ({ control, trigger, index, ...props }: SendMessageFormType) => { |
||||
const t = useTranslation(); |
||||
|
||||
const actionFieldId = useUniqueId(); |
||||
const actionFieldName = `actions.${index}.name` as const; |
||||
const actionFieldValue = useWatch({ control, name: actionFieldName }); |
||||
|
||||
const hasLicense = useHasLicenseModule('livechat-enterprise'); |
||||
|
||||
const actionOptions = useMemo<SelectOption[]>(() => { |
||||
return [ |
||||
['send-message', t('Send_a_message')], |
||||
['use-external-service', t('Send_a_message_external_service')], |
||||
]; |
||||
}, [t]); |
||||
|
||||
const ActionFormFields = getActionFormFields(actionFieldValue); |
||||
const actionHint = useMemo(() => ACTION_HINTS[actionFieldValue] || '', [actionFieldValue]); |
||||
const isOptionDisabled = useCallback((value: string) => !hasLicense && PREMIUM_ACTIONS.includes(value), [hasLicense]); |
||||
|
||||
const renderOption = useCallback( |
||||
(label: TranslationKey, value: string) => { |
||||
return ( |
||||
<> |
||||
{isOptionDisabled(value) ? ( |
||||
<Box justifyContent='space-between' flexDirection='row' display='flex' width='100%'> |
||||
{t(label)} |
||||
<Tag variant='featured'>{t('Premium')}</Tag> |
||||
</Box> |
||||
) : ( |
||||
t(label) |
||||
)} |
||||
</> |
||||
); |
||||
}, |
||||
[isOptionDisabled, t], |
||||
); |
||||
|
||||
return ( |
||||
<FieldGroup {...props}> |
||||
<Field> |
||||
<FieldLabel htmlFor={actionFieldId}>{t('Action')}</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
name={actionFieldName} |
||||
control={control} |
||||
render={({ field: { onChange, value, name, ref } }) => { |
||||
return ( |
||||
// TODO: Remove SelectLegacy once we have a new Select component
|
||||
<SelectLegacy |
||||
ref={ref} |
||||
name={name} |
||||
onChange={onChange} |
||||
value={value} |
||||
id={actionFieldId} |
||||
options={actionOptions} |
||||
renderSelected={({ label, value }) => <Box flexGrow='1'>{renderOption(label, value)}</Box>} |
||||
renderItem={({ label, value, onMouseDown, ...props }) => ( |
||||
<Option |
||||
{...props} |
||||
onMouseDown={isOptionDisabled(value) ? (e) => e.preventDefault() : onMouseDown} |
||||
disabled={isOptionDisabled(value)} |
||||
label={renderOption(label, value)} |
||||
/> |
||||
)} |
||||
/> |
||||
); |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
{actionHint && <FieldHint>{t(actionHint)}</FieldHint>} |
||||
</Field> |
||||
|
||||
<ActionFormFields control={control} trigger={trigger} index={index} /> |
||||
</FieldGroup> |
||||
); |
||||
}; |
||||
@ -0,0 +1,60 @@ |
||||
import { FieldRow, Select, TextInput, type SelectOption, Field, FieldLabel } from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import type { ComponentProps } from 'react'; |
||||
import React, { useMemo } from 'react'; |
||||
import type { Control } from 'react-hook-form'; |
||||
import { Controller, useWatch } from 'react-hook-form'; |
||||
|
||||
import type { TriggersPayload } from '../EditTrigger'; |
||||
|
||||
type ActionSenderType = ComponentProps<typeof Field> & { |
||||
control: Control<TriggersPayload>; |
||||
index: number; |
||||
disabled?: boolean; |
||||
}; |
||||
|
||||
export const ActionSender = ({ control, index, disabled, ...props }: ActionSenderType) => { |
||||
const t = useTranslation(); |
||||
|
||||
const senderFieldId = useUniqueId(); |
||||
const senderFieldName = `actions.${index}.params.sender` as const; |
||||
const senderNameFieldName = `actions.${index}.params.name` as const; |
||||
const senderNameFieldValue = useWatch({ control, name: senderFieldName }); |
||||
|
||||
const senderOptions: SelectOption[] = useMemo( |
||||
() => [ |
||||
['queue', t('Impersonate_next_agent_from_queue')], |
||||
['custom', t('Custom_agent')], |
||||
], |
||||
[t], |
||||
); |
||||
|
||||
return ( |
||||
<Field {...props}> |
||||
<FieldLabel htmlFor={senderFieldId}>{t('Sender')}</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
control={control} |
||||
name={senderFieldName} |
||||
defaultValue='queue' |
||||
render={({ field }) => { |
||||
return <Select {...field} id={senderFieldId} options={senderOptions} placeholder={t('Select_an_option')} disabled={disabled} />; |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{senderNameFieldValue === 'custom' && ( |
||||
<FieldRow> |
||||
<Controller |
||||
control={control} |
||||
name={senderNameFieldName} |
||||
render={({ field }) => { |
||||
return <TextInput {...field} placeholder={t('Name_of_agent')} aria-label={t('Name_of_agent')} disabled={disabled} />; |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
)} |
||||
</Field> |
||||
); |
||||
}; |
||||
@ -0,0 +1,110 @@ |
||||
import { FieldError, Field, FieldHint, FieldLabel, FieldRow, NumberInput, TextAreaInput, FieldGroup } from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import type { ComponentProps, FocusEvent } from 'react'; |
||||
import React from 'react'; |
||||
import type { Control, UseFormTrigger } from 'react-hook-form'; |
||||
import { Controller } from 'react-hook-form'; |
||||
|
||||
import { useHasLicenseModule } from '../../../../../ee/client/hooks/useHasLicenseModule'; |
||||
import type { TriggersPayload } from '../EditTrigger'; |
||||
import { useFieldError } from '../hooks'; |
||||
import { ActionExternalServiceUrl } from './ActionExternalServiceUrl'; |
||||
import { ActionSender } from './ActionSender'; |
||||
|
||||
type SendMessageActionFormType = ComponentProps<typeof Field> & { |
||||
index: number; |
||||
control: Control<TriggersPayload>; |
||||
trigger: UseFormTrigger<TriggersPayload>; |
||||
}; |
||||
|
||||
export const ExternalServiceActionForm = ({ control, trigger, index, ...props }: SendMessageActionFormType) => { |
||||
const t = useTranslation(); |
||||
|
||||
const hasLicense = useHasLicenseModule('livechat-enterprise'); |
||||
|
||||
const timeoutFieldId = useUniqueId(); |
||||
const timeoutFieldName = `actions.${index}.params.serviceTimeout` as const; |
||||
const fallbackMessageFieldId = useUniqueId(); |
||||
const fallbackMessageFieldName = `actions.${index}.params.serviceFallbackMessage` as const; |
||||
|
||||
const [timeoutError, fallbackMessageError] = useFieldError({ control, name: [timeoutFieldName, fallbackMessageFieldName] }); |
||||
|
||||
return ( |
||||
<FieldGroup {...props}> |
||||
<ActionSender disabled={!hasLicense} control={control} index={index} /> |
||||
|
||||
<ActionExternalServiceUrl disabled={!hasLicense} control={control} trigger={trigger} index={index} /> |
||||
|
||||
<Field> |
||||
<FieldLabel required htmlFor={timeoutFieldId}> |
||||
{t('Timeout_in_miliseconds')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
control={control} |
||||
name={timeoutFieldName} |
||||
defaultValue={10000} |
||||
rules={{ |
||||
required: t('The_field_is_required', t('Timeout_in_miliseconds')), |
||||
min: { value: 0, message: t('Timeout_in_miliseconds_cant_be_negative_number') }, |
||||
}} |
||||
render={({ field }) => { |
||||
return ( |
||||
<NumberInput |
||||
{...field} |
||||
error={timeoutError?.message} |
||||
aria-invalid={Boolean(timeoutError)} |
||||
aria-describedby={`${timeoutFieldId}-hint`} |
||||
aria-required={true} |
||||
onFocus={(v: FocusEvent<HTMLInputElement>) => v.currentTarget.select()} |
||||
disabled={!hasLicense} |
||||
/> |
||||
); |
||||
}} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{timeoutError && ( |
||||
<FieldError aria-live='assertive' id={`${timeoutFieldId}-error`}> |
||||
{timeoutError.message} |
||||
</FieldError> |
||||
)} |
||||
|
||||
<FieldHint id={`${timeoutFieldId}-hint`}>{t('Timeout_in_miliseconds_hint')}</FieldHint> |
||||
</Field> |
||||
|
||||
<Field> |
||||
<FieldLabel htmlFor={fallbackMessageFieldId}>{t('Fallback_message')}</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
control={control} |
||||
name={fallbackMessageFieldName} |
||||
defaultValue='' |
||||
render={({ field }) => ( |
||||
<TextAreaInput |
||||
{...field} |
||||
id={fallbackMessageFieldId} |
||||
rows={3} |
||||
placeholder={t('Fallback_message')} |
||||
error={fallbackMessageError?.message} |
||||
aria-invalid={Boolean(fallbackMessageError)} |
||||
aria-describedby={`${fallbackMessageFieldId}-hint`} |
||||
aria-required={true} |
||||
disabled={!hasLicense} |
||||
/> |
||||
)} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{fallbackMessageError && ( |
||||
<FieldError aria-live='assertive' id={`${fallbackMessageFieldId}-error`}> |
||||
{fallbackMessageError.message} |
||||
</FieldError> |
||||
)} |
||||
|
||||
<FieldHint id={`${fallbackMessageFieldId}-hint`}>{t('Service_fallback_message_hint')}</FieldHint> |
||||
</Field> |
||||
</FieldGroup> |
||||
); |
||||
}; |
||||
@ -0,0 +1,60 @@ |
||||
import { Field, FieldError, FieldLabel, FieldRow, TextAreaInput } from '@rocket.chat/fuselage'; |
||||
import { useUniqueId } from '@rocket.chat/fuselage-hooks'; |
||||
import { useTranslation } from '@rocket.chat/ui-contexts'; |
||||
import type { ComponentProps } from 'react'; |
||||
import React from 'react'; |
||||
import type { Control } from 'react-hook-form'; |
||||
import { Controller } from 'react-hook-form'; |
||||
|
||||
import type { TriggersPayload } from '../EditTrigger'; |
||||
import { useFieldError } from '../hooks'; |
||||
import { ActionSender } from './ActionSender'; |
||||
|
||||
type SendMessageActionFormType = ComponentProps<typeof Field> & { |
||||
index: number; |
||||
control: Control<TriggersPayload>; |
||||
}; |
||||
|
||||
export const SendMessageActionForm = ({ control, index, ...props }: SendMessageActionFormType) => { |
||||
const t = useTranslation(); |
||||
const messageFieldId = useUniqueId(); |
||||
const name = `actions.${index}.params.msg` as const; |
||||
const [messageError] = useFieldError({ control, name }); |
||||
|
||||
return ( |
||||
<> |
||||
<ActionSender {...props} control={control} index={index} /> |
||||
|
||||
<Field {...props}> |
||||
<FieldLabel required htmlFor={messageFieldId}> |
||||
{t('Message')} |
||||
</FieldLabel> |
||||
<FieldRow> |
||||
<Controller |
||||
control={control} |
||||
name={name} |
||||
defaultValue='' |
||||
rules={{ required: t('The_field_is_required', t('Message')) }} |
||||
render={({ field }) => ( |
||||
<TextAreaInput |
||||
error={messageError?.message} |
||||
aria-invalid={Boolean(messageError)} |
||||
aria-describedby={`${messageFieldId}-error`} |
||||
aria-required={true} |
||||
{...field} |
||||
rows={3} |
||||
placeholder={`${t('Message')}*`} |
||||
/> |
||||
)} |
||||
/> |
||||
</FieldRow> |
||||
|
||||
{messageError && ( |
||||
<FieldError aria-live='assertive' id={`${messageFieldId}-error`}> |
||||
{messageError.message} |
||||
</FieldError> |
||||
)} |
||||
</Field> |
||||
</> |
||||
); |
||||
}; |
||||
@ -0,0 +1 @@ |
||||
export * from './useFieldError'; |
||||
@ -0,0 +1,13 @@ |
||||
import type { Control, FieldError, FieldPath, FieldValues } from 'react-hook-form'; |
||||
import { get, useFormState } from 'react-hook-form'; |
||||
|
||||
type UseFieldErrorProps<TFieldValues extends FieldValues> = { |
||||
control: Control<TFieldValues>; |
||||
name: FieldPath<TFieldValues> | FieldPath<TFieldValues>[]; |
||||
}; |
||||
|
||||
export const useFieldError = <TFieldValues extends FieldValues>({ control, name }: UseFieldErrorProps<TFieldValues>) => { |
||||
const names = Array.isArray(name) ? name : [name]; |
||||
const { errors } = useFormState<TFieldValues>({ control, name }); |
||||
return names.map<FieldError | undefined>((name) => get(errors, name)); |
||||
}; |
||||
@ -0,0 +1,13 @@ |
||||
import { ExternalServiceActionForm } from '../actions/ExternalServiceActionForm'; |
||||
import { SendMessageActionForm } from '../actions/SendMessageActionForm'; |
||||
|
||||
type TriggerActions = 'send-message' | 'use-external-service'; |
||||
|
||||
const actionForms = { |
||||
'send-message': SendMessageActionForm, |
||||
'use-external-service': ExternalServiceActionForm, |
||||
} as const; |
||||
|
||||
export const getActionFormFields = (actionName: TriggerActions) => { |
||||
return actionForms[actionName] || actionForms['send-message']; |
||||
}; |
||||
@ -0,0 +1 @@ |
||||
export * from './getActionFormFields'; |
||||
@ -0,0 +1,50 @@ |
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch'; |
||||
|
||||
export async function callTriggerExternalService({ |
||||
url, |
||||
timeout, |
||||
fallbackMessage, |
||||
body, |
||||
headers, |
||||
}: { |
||||
url: string; |
||||
timeout: number; |
||||
fallbackMessage: string; |
||||
body: Record<string, any>; |
||||
headers: Record<string, string>; |
||||
}) { |
||||
try { |
||||
const response = await fetch(url, { timeout: timeout || 1000, body, headers, method: 'POST' }); |
||||
|
||||
if (!response.ok || response.status !== 200) { |
||||
const text = await response.text(); |
||||
throw new Error(text); |
||||
} |
||||
|
||||
const data = await response.json(); |
||||
|
||||
const { contents } = data; |
||||
|
||||
if ( |
||||
!Array.isArray(contents) || |
||||
!contents.length || |
||||
!contents.every(({ msg, order }) => typeof msg === 'string' && typeof order === 'number') |
||||
) { |
||||
throw new Error('External service response does not match expected format'); |
||||
} |
||||
|
||||
return { |
||||
response: { |
||||
statusCode: response.status, |
||||
contents: data?.contents || [], |
||||
}, |
||||
}; |
||||
} catch (error: any) { |
||||
const isTimeout = error.message === 'The user aborted a request.'; |
||||
return { |
||||
error: isTimeout ? 'error-timeout' : 'error-invalid-external-service-response', |
||||
response: error.message, |
||||
fallbackMessage, |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,132 @@ |
||||
import { isExternalServiceTrigger } from '@rocket.chat/core-typings'; |
||||
import { LivechatTrigger } from '@rocket.chat/models'; |
||||
import { isLivechatTriggerWebhookCallParams } from '@rocket.chat/rest-typings'; |
||||
import { isLivechatTriggerWebhookTestParams } from '@rocket.chat/rest-typings/src/v1/omnichannel'; |
||||
|
||||
import { API } from '../../../../../app/api/server'; |
||||
import { settings } from '../../../../../app/settings/server'; |
||||
import { callTriggerExternalService } from './lib/triggers'; |
||||
|
||||
API.v1.addRoute( |
||||
'livechat/triggers/external-service/test', |
||||
{ |
||||
authRequired: true, |
||||
permissionsRequired: ['view-livechat-manager'], |
||||
validateParams: isLivechatTriggerWebhookTestParams, |
||||
rateLimiterOptions: { numRequestsAllowed: 15, intervalTimeInMS: 60000 }, |
||||
}, |
||||
{ |
||||
async post() { |
||||
const { webhookUrl, timeout, fallbackMessage, extraData: clientParams } = this.bodyParams; |
||||
|
||||
const token = settings.get<string>('Livechat_secret_token'); |
||||
|
||||
if (!token) { |
||||
throw new Error('Livechat secret token is not configured'); |
||||
} |
||||
|
||||
const body = { |
||||
metadata: clientParams, |
||||
visitorToken: '1234567890', |
||||
}; |
||||
|
||||
const headers = { |
||||
'Accept': 'application/json', |
||||
'Content-Type': 'application/json', |
||||
'X-RocketChat-Livechat-Token': token, |
||||
}; |
||||
|
||||
const response = await callTriggerExternalService({ |
||||
url: webhookUrl, |
||||
timeout, |
||||
fallbackMessage, |
||||
body, |
||||
headers, |
||||
}); |
||||
|
||||
if (response.error) { |
||||
return API.v1.failure({ |
||||
triggerId: 'test-trigger', |
||||
...response, |
||||
}); |
||||
} |
||||
|
||||
return API.v1.success({ |
||||
triggerId: 'test-trigger', |
||||
...response, |
||||
}); |
||||
}, |
||||
}, |
||||
); |
||||
|
||||
API.v1.addRoute( |
||||
'livechat/triggers/:_id/external-service/call', |
||||
{ |
||||
authRequired: false, |
||||
rateLimiterOptions: { |
||||
numRequestsAllowed: 10, |
||||
intervalTimeInMS: 60000, |
||||
}, |
||||
validateParams: isLivechatTriggerWebhookCallParams, |
||||
}, |
||||
{ |
||||
async post() { |
||||
const { _id: triggerId } = this.urlParams; |
||||
const { token: visitorToken, extraData } = this.bodyParams; |
||||
|
||||
const trigger = await LivechatTrigger.findOneById(triggerId); |
||||
|
||||
if (!trigger) { |
||||
throw new Error('Invalid trigger'); |
||||
} |
||||
|
||||
if (!trigger?.actions.length || !isExternalServiceTrigger(trigger)) { |
||||
throw new Error('Trigger is not configured to use an external service'); |
||||
} |
||||
|
||||
const { params: { serviceTimeout = 5000, serviceUrl, serviceFallbackMessage = 'trigger-default-fallback-message' } = {} } = |
||||
trigger.actions[0]; |
||||
|
||||
if (!serviceUrl) { |
||||
throw new Error('Invalid service URL'); |
||||
} |
||||
|
||||
const token = settings.get<string>('Livechat_secret_token'); |
||||
|
||||
if (!token) { |
||||
throw new Error('Livechat secret token is not configured'); |
||||
} |
||||
|
||||
const body = { |
||||
metadata: extraData, |
||||
visitorToken, |
||||
}; |
||||
|
||||
const headers = { |
||||
'Accept': 'application/json', |
||||
'Content-Type': 'application/json', |
||||
'X-RocketChat-Livechat-Token': token, |
||||
}; |
||||
|
||||
const response = await callTriggerExternalService({ |
||||
url: serviceUrl, |
||||
timeout: serviceTimeout, |
||||
fallbackMessage: serviceFallbackMessage, |
||||
body, |
||||
headers, |
||||
}); |
||||
|
||||
if (response.error) { |
||||
return API.v1.failure({ |
||||
triggerId, |
||||
...response, |
||||
}); |
||||
} |
||||
|
||||
return API.v1.success({ |
||||
triggerId, |
||||
...response, |
||||
}); |
||||
}, |
||||
}, |
||||
); |
||||
@ -0,0 +1,109 @@ |
||||
import type { ILivechatSendMessageAction, ILivechatTriggerCondition, ILivechatUseExternalServiceAction } from '@rocket.chat/core-typings'; |
||||
import { route } from 'preact-router'; |
||||
|
||||
import store from '../store'; |
||||
import { normalizeAgent } from './api'; |
||||
import { parentCall } from './parentCall'; |
||||
import { createToken } from './random'; |
||||
import { getAgent, removeMessage, requestTriggerMessages, upsertMessage } from './triggerUtils'; |
||||
import Triggers from './triggers'; |
||||
|
||||
export const sendMessageAction = async (_: string, action: ILivechatSendMessageAction, condition: ILivechatTriggerCondition) => { |
||||
const { token, minimized } = store.state; |
||||
|
||||
const agent = await getAgent(action); |
||||
|
||||
const message = { |
||||
msg: action.params?.msg, |
||||
token, |
||||
u: agent, |
||||
ts: new Date().toISOString(), |
||||
_id: createToken(), |
||||
}; |
||||
|
||||
await upsertMessage(message); |
||||
|
||||
if (agent && '_id' in agent) { |
||||
await store.setState({ agent }); |
||||
parentCall('callback', 'assign-agent', normalizeAgent(agent)); |
||||
} |
||||
|
||||
if (minimized) { |
||||
route('/trigger-messages'); |
||||
store.setState({ minimized: false }); |
||||
} |
||||
|
||||
if (condition.name !== 'after-guest-registration') { |
||||
const onVisitorRegistered = async () => { |
||||
await removeMessage(message._id); |
||||
Triggers.callbacks?.off('chat-visitor-registered', onVisitorRegistered); |
||||
}; |
||||
|
||||
Triggers.callbacks?.on('chat-visitor-registered', onVisitorRegistered); |
||||
} |
||||
}; |
||||
|
||||
export const sendMessageExternalServiceAction = async ( |
||||
triggerId: string, |
||||
action: ILivechatUseExternalServiceAction, |
||||
condition: ILivechatTriggerCondition, |
||||
) => { |
||||
const { token, minimized, typing, iframe } = store.state; |
||||
const metadata = iframe.guestMetadata || {}; |
||||
const agent = await getAgent(action); |
||||
|
||||
if (agent?.username) { |
||||
store.setState({ typing: [...typing, agent.username] }); |
||||
} |
||||
|
||||
try { |
||||
const { serviceFallbackMessage: fallbackMessage } = action.params || {}; |
||||
const triggerMessages = await requestTriggerMessages({ |
||||
token, |
||||
triggerId, |
||||
metadata, |
||||
fallbackMessage, |
||||
}); |
||||
|
||||
const messages = triggerMessages |
||||
.sort((a, b) => a.order - b.order) |
||||
.map((item) => item.msg) |
||||
.map((msg) => ({ |
||||
msg, |
||||
token, |
||||
u: agent, |
||||
ts: new Date().toISOString(), |
||||
_id: createToken(), |
||||
})); |
||||
|
||||
await Promise.all(messages.map((message) => upsertMessage(message))); |
||||
|
||||
if (agent && '_id' in agent) { |
||||
await store.setState({ agent }); |
||||
parentCall('callback', 'assign-agent', normalizeAgent(agent)); |
||||
} |
||||
|
||||
if (minimized) { |
||||
route('/trigger-messages'); |
||||
store.setState({ minimized: false }); |
||||
} |
||||
|
||||
if (condition.name !== 'after-guest-registration') { |
||||
const onVisitorRegistered = async () => { |
||||
await Promise.all(messages.map((message) => removeMessage(message._id))); |
||||
Triggers.callbacks?.off('chat-visitor-registered', onVisitorRegistered); |
||||
}; |
||||
|
||||
Triggers.callbacks?.on('chat-visitor-registered', onVisitorRegistered); |
||||
} |
||||
} finally { |
||||
store.setState({ |
||||
typing: store.state.typing.filter((u) => u !== agent?.username), |
||||
}); |
||||
} |
||||
}; |
||||
|
||||
export const actions = { |
||||
'send-message': sendMessageAction, |
||||
'use-external-service': sendMessageExternalServiceAction, |
||||
}; |
||||
@ -0,0 +1,64 @@ |
||||
import type { ILivechatTriggerCondition } from '@rocket.chat/core-typings'; |
||||
|
||||
import store from '../store'; |
||||
import Triggers from './triggers'; |
||||
|
||||
export const pageUrlCondition = (condition: ILivechatTriggerCondition) => { |
||||
const { parentUrl } = Triggers; |
||||
|
||||
if (!parentUrl || !condition.value) { |
||||
return Promise.reject(`condition ${condition.name} not met`); |
||||
} |
||||
|
||||
const hrefRegExp = new RegExp(`${condition?.value}`, 'g'); |
||||
|
||||
if (hrefRegExp.test(parentUrl)) { |
||||
return Promise.resolve(); |
||||
} |
||||
|
||||
return Promise.reject(); |
||||
}; |
||||
|
||||
export const timeOnSiteCondition = (condition: ILivechatTriggerCondition) => { |
||||
return new Promise<void>((resolve, reject) => { |
||||
const timeout = parseInt(`${condition?.value || 0}`, 10) * 1000; |
||||
setTimeout(() => { |
||||
const { user } = store.state; |
||||
if (user?.token) { |
||||
reject(`Condition "${condition.name}" is no longer valid`); |
||||
return; |
||||
} |
||||
|
||||
resolve(); |
||||
}, timeout); |
||||
}); |
||||
}; |
||||
|
||||
export const chatOpenedCondition = () => { |
||||
return new Promise<void>((resolve) => { |
||||
const openFunc = async () => { |
||||
Triggers.callbacks?.off('chat-opened-by-visitor', openFunc); |
||||
resolve(); |
||||
}; |
||||
|
||||
Triggers.callbacks?.on('chat-opened-by-visitor', openFunc); |
||||
}); |
||||
}; |
||||
|
||||
export const visitorRegisteredCondition = () => { |
||||
return new Promise<void>((resolve) => { |
||||
const openFunc = async () => { |
||||
Triggers.callbacks?.off('chat-visitor-registered', openFunc); |
||||
resolve(); |
||||
}; |
||||
|
||||
Triggers.callbacks?.on('chat-visitor-registered', openFunc); |
||||
}); |
||||
}; |
||||
|
||||
export const conditions = { |
||||
'page-url': pageUrlCondition, |
||||
'time-on-site': timeOnSiteCondition, |
||||
'chat-opened-by-visitor': chatOpenedCondition, |
||||
'after-guest-registration': visitorRegisteredCondition, |
||||
}; |
||||
@ -0,0 +1,125 @@ |
||||
import type { ILivechatAgent, ILivechatTrigger, ILivechatTriggerAction, ILivechatTriggerType, Serialized } from '@rocket.chat/core-typings'; |
||||
|
||||
import { Livechat } from '../api'; |
||||
import type { Agent } from '../definitions/agents'; |
||||
import { upsert } from '../helpers/upsert'; |
||||
import store from '../store'; |
||||
import { processUnread } from './main'; |
||||
|
||||
type AgentPromise = { username: string } | Serialized<ILivechatAgent> | null; |
||||
|
||||
let agentPromise: Promise<AgentPromise> | null = null; |
||||
|
||||
const agentCacheExpiry = 3600000; |
||||
|
||||
const isAgentWithInfo = (agent: any): agent is Serialized<ILivechatAgent> => !agent.hiddenInfo; |
||||
|
||||
const getNextAgentFromQueue = async () => { |
||||
const { |
||||
defaultAgent, |
||||
iframe: { guest: { department } = {} }, |
||||
} = store.state; |
||||
|
||||
if (defaultAgent?.ts && Date.now() - defaultAgent.ts < agentCacheExpiry) { |
||||
return defaultAgent; // cache valid for 1 hour
|
||||
} |
||||
|
||||
let agent = null; |
||||
try { |
||||
const tempAgent = await Livechat.nextAgent({ department }); |
||||
|
||||
if (isAgentWithInfo(tempAgent?.agent)) { |
||||
agent = tempAgent.agent; |
||||
} |
||||
} catch (error) { |
||||
return Promise.reject(error); |
||||
} |
||||
|
||||
store.setState({ defaultAgent: { ...agent, department, ts: Date.now() } as Agent }); |
||||
|
||||
return agent; |
||||
}; |
||||
|
||||
export const getAgent = async (triggerAction: ILivechatTriggerAction): Promise<AgentPromise> => { |
||||
if (agentPromise) { |
||||
return agentPromise; |
||||
} |
||||
|
||||
agentPromise = new Promise(async (resolve, reject) => { |
||||
const { sender, name = '' } = triggerAction.params || {}; |
||||
|
||||
if (sender === 'custom') { |
||||
resolve({ username: name }); |
||||
} |
||||
|
||||
if (sender === 'queue') { |
||||
try { |
||||
const agent = await getNextAgentFromQueue(); |
||||
resolve(agent); |
||||
} catch (_) { |
||||
resolve({ username: 'rocket.cat' }); |
||||
} |
||||
} |
||||
|
||||
return reject('Unknown sender type.'); |
||||
}); |
||||
|
||||
// expire the promise cache as well
|
||||
setTimeout(() => { |
||||
agentPromise = null; |
||||
}, agentCacheExpiry); |
||||
|
||||
return agentPromise; |
||||
}; |
||||
|
||||
export const upsertMessage = async (message: Record<string, unknown>) => { |
||||
await store.setState({ |
||||
messages: upsert( |
||||
store.state.messages, |
||||
message, |
||||
({ _id }) => _id === message._id, |
||||
({ ts }) => new Date(ts).getTime(), |
||||
), |
||||
}); |
||||
|
||||
await processUnread(); |
||||
}; |
||||
|
||||
export const removeMessage = async (messageId: string) => { |
||||
const { messages } = store.state; |
||||
await store.setState({ messages: messages.filter(({ _id }) => _id !== messageId) }); |
||||
}; |
||||
|
||||
export const hasTriggerCondition = (conditionName: ILivechatTriggerType) => (trigger: ILivechatTrigger) => { |
||||
return trigger.conditions.some((condition) => condition.name === conditionName); |
||||
}; |
||||
|
||||
export const isInIframe = () => window.self !== window.top; |
||||
|
||||
export const requestTriggerMessages = async ({ |
||||
triggerId, |
||||
token, |
||||
metadata = {}, |
||||
fallbackMessage, |
||||
}: { |
||||
triggerId: string; |
||||
token: string; |
||||
metadata: Record<string, string>; |
||||
fallbackMessage?: string; |
||||
}) => { |
||||
try { |
||||
const extraData = Object.entries(metadata).reduce<{ key: string; value: string }[]>( |
||||
(acc, [key, value]) => [...acc, { key, value }], |
||||
[], |
||||
); |
||||
|
||||
const { response } = await Livechat.rest.post(`/v1/livechat/triggers/${triggerId}/external-service/call`, { extraData, token }); |
||||
return response.contents; |
||||
} catch (_) { |
||||
if (!fallbackMessage) { |
||||
throw Error('Unable to fetch message from external service.'); |
||||
} |
||||
|
||||
return [{ msg: fallbackMessage, order: 0 }]; |
||||
} |
||||
}; |
||||
Loading…
Reference in new issue