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
Kevin Aleman 2 years ago committed by GitHub
parent 871efa50e1
commit b9e897a8f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 7
      .changeset/rich-rocks-happen.md
  2. 18
      apps/meteor/app/livechat/server/api/lib/livechat.ts
  3. 74
      apps/meteor/client/views/omnichannel/triggers/ConditionForm.tsx
  4. 253
      apps/meteor/client/views/omnichannel/triggers/EditTrigger.tsx
  5. 97
      apps/meteor/client/views/omnichannel/triggers/actions/ActionExternalServiceUrl.tsx
  6. 113
      apps/meteor/client/views/omnichannel/triggers/actions/ActionForm.tsx
  7. 60
      apps/meteor/client/views/omnichannel/triggers/actions/ActionSender.tsx
  8. 110
      apps/meteor/client/views/omnichannel/triggers/actions/ExternalServiceActionForm.tsx
  9. 60
      apps/meteor/client/views/omnichannel/triggers/actions/SendMessageActionForm.tsx
  10. 1
      apps/meteor/client/views/omnichannel/triggers/hooks/index.ts
  11. 13
      apps/meteor/client/views/omnichannel/triggers/hooks/useFieldError.tsx
  12. 13
      apps/meteor/client/views/omnichannel/triggers/utils/getActionFormFields.tsx
  13. 1
      apps/meteor/client/views/omnichannel/triggers/utils/index.ts
  14. 1
      apps/meteor/ee/app/livechat-enterprise/server/api/index.ts
  15. 50
      apps/meteor/ee/app/livechat-enterprise/server/api/lib/triggers.ts
  16. 132
      apps/meteor/ee/app/livechat-enterprise/server/api/triggers.ts
  17. 12
      apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts
  18. 10
      apps/meteor/tests/e2e/page-objects/omnichannel-triggers.ts
  19. 192
      apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts
  20. 45
      packages/core-typings/src/ILivechatTrigger.ts
  21. 13
      packages/i18n/src/locales/en.i18n.json
  22. 12
      packages/i18n/src/locales/pt-BR.i18n.json
  23. 4
      packages/livechat/src/components/App/App.tsx
  24. 1
      packages/livechat/src/definitions/agents.d.ts
  25. 4
      packages/livechat/src/helpers/canRenderMessage.ts
  26. 4
      packages/livechat/src/lib/api.ts
  27. 13
      packages/livechat/src/lib/hooks.ts
  28. 109
      packages/livechat/src/lib/triggerActions.ts
  29. 64
      packages/livechat/src/lib/triggerConditions.ts
  30. 125
      packages/livechat/src/lib/triggerUtils.ts
  31. 280
      packages/livechat/src/lib/triggers.js
  32. 4
      packages/livechat/src/routes/Chat/connector.tsx
  33. 2
      packages/livechat/src/routes/Register/index.tsx
  34. 3
      packages/livechat/src/store/index.tsx
  35. 13
      packages/livechat/src/widget.ts
  36. 163
      packages/rest-typings/src/v1/omnichannel.ts

@ -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)"

@ -6,6 +6,7 @@ import type {
IOmnichannelRoom,
SelectedAgent,
} from '@rocket.chat/core-typings';
import { License } from '@rocket.chat/license';
import { EmojiCustom, LivechatTrigger, LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { Meteor } from 'meteor/meteor';
@ -21,12 +22,17 @@ export function online(department: string, skipSettingCheck = false, skipFallbac
async function findTriggers(): Promise<Pick<ILivechatTrigger, '_id' | 'actions' | 'conditions' | 'runOnce'>[]> {
const triggers = await LivechatTrigger.findEnabled().toArray();
return triggers.map(({ _id, actions, conditions, runOnce }) => ({
_id,
actions,
conditions,
runOnce,
}));
const hasLicense = License.hasModule('livechat-enterprise');
const premiumActions = ['use-external-service'];
return triggers
.filter(({ actions }) => hasLicense || actions.some((c) => !premiumActions.includes(c.name)))
.map(({ _id, actions, conditions, runOnce }) => ({
_id,
actions,
conditions,
runOnce,
}));
}
async function findDepartments(

@ -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>
);
};

@ -1,19 +1,5 @@
import type { ILivechatTrigger, ILivechatTriggerCondition, Serialized } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import {
FieldGroup,
Button,
ButtonGroup,
Field,
FieldLabel,
FieldRow,
FieldError,
TextInput,
ToggleSwitch,
Select,
TextAreaInput,
NumberInput,
} from '@rocket.chat/fuselage';
import { type ILivechatTrigger, type ILivechatTriggerAction, type Serialized } from '@rocket.chat/core-typings';
import { FieldGroup, Button, ButtonGroup, Field, FieldLabel, FieldRow, FieldError, TextInput, ToggleSwitch } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useRouter, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@ -28,45 +14,65 @@ import {
ContextualbarHeader,
ContextualbarClose,
} from '../../../components/Contextualbar';
import { ConditionForm } from './ConditionForm';
import { ActionForm } from './actions/ActionForm';
const getInitialValues = (triggerData: Serialized<ILivechatTrigger> | undefined) => ({
name: triggerData?.name || '',
description: triggerData?.description || '',
enabled: triggerData?.enabled || true,
runOnce: !!triggerData?.runOnce || false,
conditions: triggerData?.conditions.map(({ name, value }) => ({ name: name || 'page-url', value: value || '' })) || [
{ name: 'page-url' as unknown as ILivechatTriggerCondition['name'], value: '' },
],
actions: triggerData?.actions.map(({ name, params }) => ({
name: name || '',
params: {
sender: params?.sender || 'queue',
msg: params?.msg || '',
name: params?.name || '',
},
})) || [
{
name: 'send-message',
params: {
sender: 'queue',
msg: '',
name: '',
},
},
],
});
type TriggersPayload = {
export type TriggersPayload = {
name: string;
description: string;
enabled: boolean;
runOnce: boolean;
// In the future, this will be an array
conditions: ILivechatTrigger['conditions'];
// In the future, this will be an array
actions: ILivechatTrigger['actions'];
};
const DEFAULT_SEND_MESSAGE_ACTION = {
name: 'send-message',
params: {
sender: 'queue',
name: '',
msg: '',
},
} as const;
const DEFAULT_PAGE_URL_CONDITION = { name: 'page-url', value: '' } as const;
export const getDefaultAction = (action: ILivechatTriggerAction): ILivechatTriggerAction => {
switch (action.name) {
case 'send-message':
return {
name: 'send-message',
params: {
name: action.params?.name || '',
msg: action.params?.msg || '',
sender: action.params?.sender || 'queue',
},
};
case 'use-external-service':
return {
name: 'use-external-service',
params: {
name: action.params?.name || '',
sender: action.params?.sender || 'queue',
serviceUrl: action.params?.serviceUrl || '',
serviceTimeout: action.params?.serviceTimeout || 0,
serviceFallbackMessage: action.params?.serviceFallbackMessage || '',
},
};
}
};
const getInitialValues = (triggerData: Serialized<ILivechatTrigger> | undefined): TriggersPayload => ({
name: triggerData?.name ?? '',
description: triggerData?.description || '',
enabled: triggerData?.enabled ?? true,
runOnce: !!triggerData?.runOnce ?? false,
conditions: triggerData?.conditions.map(({ name, value }) => ({ name: name || 'page-url', value: value || '' })) ?? [
DEFAULT_PAGE_URL_CONDITION,
],
actions: triggerData?.actions.map((action) => getDefaultAction(action)) ?? [DEFAULT_SEND_MESSAGE_ACTION],
});
const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigger> }) => {
const t = useTranslation();
const router = useRouter();
@ -74,13 +80,24 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
const dispatchToastMessage = useToastMessageDispatch();
const saveTrigger = useEndpoint('POST', '/v1/livechat/triggers');
const initValues = getInitialValues(triggerData);
const formId = useUniqueId();
const enabledField = useUniqueId();
const runOnceField = useUniqueId();
const nameField = useUniqueId();
const descriptionField = useUniqueId();
const {
control,
handleSubmit,
formState: { isDirty, errors },
watch,
} = useForm<TriggersPayload>({ mode: 'onBlur', values: getInitialValues(triggerData) });
trigger,
formState: { isDirty, isSubmitting, errors },
} = useForm<TriggersPayload>({ mode: 'onBlur', reValidateMode: 'onBlur', values: initValues });
// Alternative way of checking isValid in order to not trigger validation on every render
// https://github.com/react-hook-form/documentation/issues/944
const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
const { fields: conditionsFields } = useFieldArray({
control,
@ -92,38 +109,11 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
name: 'actions',
});
const { description, conditions, actions } = watch();
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 conditionValuePlaceholders: { [conditionName: string]: string } = useMemo(
() => ({
'page-url': t('Enter_a_regex'),
'time-on-site': t('Time_in_seconds'),
}),
[t],
);
const senderOptions: SelectOption[] = useMemo(
() => [
['queue', t('Impersonate_next_agent_from_queue')],
['custom', t('Custom_agent')],
],
[t],
);
const saveTriggerMutation = useMutation({
mutationFn: saveTrigger,
onSuccess: () => {
dispatchToastMessage({ type: 'success', message: t('Saved') });
queryClient.invalidateQueries(['livechat-getTriggersById']);
queryClient.invalidateQueries(['livechat-triggers']);
router.navigate('/omnichannel/triggers');
},
@ -133,18 +123,13 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
});
const handleSave = async (data: TriggersPayload) => {
saveTriggerMutation.mutateAsync({ ...data, _id: triggerData?._id });
return saveTriggerMutation.mutateAsync({
...data,
_id: triggerData?._id,
actions: data.actions.map(getDefaultAction),
});
};
const formId = useUniqueId();
const enabledField = useUniqueId();
const runOnceField = useUniqueId();
const nameField = useUniqueId();
const descriptionField = useUniqueId();
const conditionField = useUniqueId();
const actionField = useUniqueId();
const actionMessageField = useUniqueId();
return (
<Contextualbar>
<ContextualbarHeader>
@ -164,6 +149,7 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={runOnceField}>{t('Run_only_once_for_each_visitor')}</FieldLabel>
@ -174,6 +160,7 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={nameField} required>
{t('Name')}
@ -201,94 +188,20 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={descriptionField}>{t('Description')}</FieldLabel>
<FieldRow>
<Controller
name='description'
control={control}
render={({ field }) => <TextInput id={descriptionField} {...field} value={description} />}
/>
<Controller name='description' control={control} render={({ field }) => <TextInput id={descriptionField} {...field} />} />
</FieldRow>
</Field>
{conditionsFields.map((_, index) => {
const conditionValuePlaceholder = conditionValuePlaceholders[conditions[index].name];
return (
<Field key={index}>
<FieldLabel htmlFor={conditionField}>{t('Condition')}</FieldLabel>
<FieldRow>
<Controller
name={`conditions.${index}.name`}
control={control}
render={({ field }) => <Select id={conditionField} {...field} options={conditionOptions} />}
/>
</FieldRow>
{conditionValuePlaceholder && (
<FieldRow>
<Controller
name={`conditions.${index}.value`}
control={control}
render={({ field }) => {
if (conditions[index].name === 'time-on-site') {
return <NumberInput {...field} placeholder={conditionValuePlaceholder} />;
}
return <TextInput {...field} placeholder={conditionValuePlaceholder} />;
}}
/>
</FieldRow>
)}
</Field>
);
})}
{actionsFields.map((_, index) => (
<Field key={index}>
<FieldLabel htmlFor={actionField}>{t('Action')}</FieldLabel>
<FieldRow>
<TextInput value={t('Send_a_message')} readOnly />
</FieldRow>
<FieldRow>
<Controller
name={`actions.${index}.params.sender`}
control={control}
render={({ field }) => (
<Select id={actionField} {...field} options={senderOptions} placeholder={t('Select_an_option')} />
)}
/>
</FieldRow>
{actions[index].params?.sender === 'custom' && (
<FieldRow>
<Controller
name={`actions.${index}.params.name`}
control={control}
render={({ field }) => <TextInput {...field} placeholder={t('Name_of_agent')} />}
/>
</FieldRow>
)}
<FieldRow>
<Controller
name={`actions.${index}.params.msg`}
control={control}
rules={{ required: t('The_field_is_required', t('Message')) }}
render={({ field }) => (
<TextAreaInput
error={errors.actions?.[index]?.params?.msg?.message}
aria-invalid={Boolean(errors.actions?.[index]?.params?.msg)}
aria-describedby={`${actionMessageField}-error`}
aria-required={true}
{...field}
rows={3}
placeholder={`${t('Message')}*`}
/>
)}
/>
</FieldRow>
{errors.actions?.[index]?.params?.msg && (
<FieldError aria-live='assertive' id={`${actionMessageField}-error`}>
{errors.actions?.[index]?.params?.msg?.message}
</FieldError>
)}
</Field>
{conditionsFields.map((field, index) => (
<ConditionForm key={field.id} control={control} index={index} />
))}
{actionsFields.map((field, index) => (
<ActionForm key={field.id} control={control} trigger={trigger} index={index} />
))}
</FieldGroup>
</form>
@ -296,7 +209,7 @@ const EditTrigger = ({ triggerData }: { triggerData?: Serialized<ILivechatTrigge
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={() => router.navigate('/omnichannel/triggers')}>{t('Cancel')}</Button>
<Button form={formId} type='submit' primary disabled={!isDirty}>
<Button form={formId} type='submit' primary disabled={!isDirty || !isValid} loading={isSubmitting}>
{t('Save')}
</Button>
</ButtonGroup>

@ -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,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';

@ -10,3 +10,4 @@ import './business-hours';
import './rooms';
import './transcript';
import './reports';
import './triggers';

@ -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,
});
},
},
);

@ -6,7 +6,7 @@ import { Users } from '../fixtures/userStates';
import { OmnichannelLiveChat, HomeOmnichannel } from '../page-objects';
import { test, expect } from '../utils/test';
test.describe.serial('omnichannel-triggers', () => {
test.describe.serial('OC - Livechat Triggers', () => {
let triggersName: string;
let triggerMessage: string;
let poLiveChat: OmnichannelLiveChat;
@ -46,7 +46,7 @@ test.describe.serial('omnichannel-triggers', () => {
await agent.page.close();
});
test('trigger baseline', async ({ page }) => {
test('OC - Livechat Triggers - Baseline', async ({ page }) => {
await page.goto('/livechat');
await poLiveChat.openLiveChat();
@ -67,7 +67,7 @@ test.describe.serial('omnichannel-triggers', () => {
});
});
test('create and edit trigger', async () => {
test('OC - Livechat Triggers - Create and edit trigger', async () => {
await test.step('expect create new trigger', async () => {
await agent.poHomeOmnichannel.triggers.createTrigger(triggersName, triggerMessage);
await agent.poHomeOmnichannel.triggers.btnCloseToastMessage.click();
@ -80,7 +80,7 @@ test.describe.serial('omnichannel-triggers', () => {
});
});
test('trigger condition: chat opened by visitor', async ({ page }) => {
test('OC - Livechat Triggers - Condition: chat opened by visitor', async ({ page }) => {
await test.step('expect to start conversation', async () => {
await page.goto('/livechat');
await poLiveChat.openLiveChat();
@ -111,7 +111,7 @@ test.describe.serial('omnichannel-triggers', () => {
});
});
test('trigger condition: after guest registration', async ({ page }) => {
test('OC - Livechat Triggers - Condition: after guest registration', async ({ page }) => {
await test.step('expect update trigger to after guest registration', async () => {
await agent.poHomeOmnichannel.triggers.firstRowInTriggerTable(`edited-${triggersName}`).click();
await agent.poHomeOmnichannel.triggers.fillTriggerForm({ condition: 'after-guest-registration', triggerMessage });
@ -150,7 +150,7 @@ test.describe.serial('omnichannel-triggers', () => {
});
});
test('delete trigger', async () => {
test('OC - Livechat Triggers - Delete trigger', async () => {
await agent.poHomeOmnichannel.triggers.btnDeletefirstRowInTable.click();
await agent.poHomeOmnichannel.triggers.btnModalRemove.click();
await expect(agent.poHomeOmnichannel.triggers.removeToastMessage).toBeVisible();

@ -53,7 +53,7 @@ export class OmnichannelTriggers {
}
get conditionLabel(): Locator {
return this.page.locator('label >> text="Condition"')
return this.page.locator('label >> text="Condition"');
}
get inputConditionValue(): Locator {
@ -61,7 +61,11 @@ export class OmnichannelTriggers {
}
get actionLabel(): Locator {
return this.page.locator('label >> text="Action"')
return this.page.locator('label >> text="Action"');
}
get senderLabel(): Locator {
return this.page.locator('label >> text="Sender"');
}
get inputAgentName(): Locator {
@ -78,7 +82,7 @@ export class OmnichannelTriggers {
}
async selectSender(sender: 'queue' | 'custom') {
await this.actionLabel.click();
await this.senderLabel.click();
await this.page.locator(`li.rcx-option[data-key="${sender}"]`).click();
}

@ -1,10 +1,11 @@
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import { before, describe, it, after } from 'mocha';
import type { Response } from 'supertest';
import { getCredentials, api, request, credentials } from '../../../data/api-data';
import { createTrigger, fetchTriggers } from '../../../data/livechat/triggers';
import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper';
import { IS_EE } from '../../../e2e/config/constants';
describe('LIVECHAT - triggers', function () {
this.retries(0);
@ -190,7 +191,7 @@ describe('LIVECHAT - triggers', function () {
})
.expect(403);
});
it('should save a new trigger', async () => {
it('should save a new trigger of type send-message', async () => {
await restorePermissionToRoles('view-livechat-manager');
await request
.post(api('livechat/triggers'))
@ -205,6 +206,193 @@ describe('LIVECHAT - triggers', function () {
})
.expect(200);
});
it('should fail if type is use-external-service but serviceUrl is not a present', async () => {
await request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name: 'test2',
description: 'test2',
enabled: true,
runOnce: true,
conditions: [{ name: 'page-url', value: 'http://localhost:3000' }],
actions: [
{
name: 'use-external-service',
params: {
serviceTimeout: 5000,
serviceFallbackMessage: 'Were sorry, we cannot complete your request',
},
},
],
})
.expect(400);
});
it('should fail if type is use-external-service but serviceTimeout is not a present', async () => {
await request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name: 'test2',
description: 'test2',
enabled: true,
runOnce: true,
conditions: [{ name: 'page-url', value: 'http://localhost:3000' }],
actions: [
{
name: 'use-external-service',
params: {
serviceUrl: 'http://localhost:3000/api/vX',
serviceFallbackMessage: 'Were sorry, we cannot complete your request',
},
},
],
})
.expect(400);
});
it('should fail if type is use-external-service but serviceFallbackMessage is not a present', async () => {
await request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name: 'test2',
description: 'test2',
enabled: true,
runOnce: true,
conditions: [{ name: 'page-url', value: 'http://localhost:3000' }],
actions: [
{
name: 'use-external-service',
params: {
serviceUrl: 'http://localhost:3000/api/vX',
serviceTimeout: 5000,
},
},
],
})
.expect(400);
});
it('should save a new trigger of type use-external-service', async () => {
await request
.post(api('livechat/triggers'))
.set(credentials)
.send({
name: 'test3',
description: 'test3',
enabled: true,
runOnce: true,
conditions: [{ name: 'page-url', value: 'http://localhost:3000' }],
actions: [
{
name: 'use-external-service',
params: {
serviceUrl: 'http://localhost:3000/api/vX',
serviceTimeout: 5000,
serviceFallbackMessage: 'Were sorry, we cannot complete your request',
},
},
],
})
.expect(200);
});
});
(IS_EE ? describe : describe.skip)('POST livechat/triggers/external-service/test', () => {
const webhookUrl = process.env.WEBHOOK_TEST_URL || 'https://httpbin.org';
after(() => Promise.all([updateSetting('Livechat_secret_token', ''), restorePermissionToRoles('view-livechat-manager')]));
it('should fail if user is not logged in', async () => {
await request.post(api('livechat/triggers/external-service/test')).send({}).expect(401);
});
it('should fail if no data is sent', async () => {
await request.post(api('livechat/triggers/external-service/test')).set(credentials).send({}).expect(400);
});
it('should fail if invalid data is sent', async () => {
await request.post(api('livechat/triggers/external-service/test')).set(credentials).send({ webhookUrl: 'test' }).expect(400);
});
it('should fail if webhookUrl is not an string', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 1, timeout: 1000, fallbackMessage: 'test', extraData: [] })
.expect(400);
});
it('should fail if timeout is not an number', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 'test', timeout: '1000', fallbackMessage: 'test', extraData: [] })
.expect(400);
});
it('should fail if fallbackMessage is not an string', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 1, extraData: [] })
.expect(400);
});
it('should fail if params is not an array', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: 1 })
.expect(400);
});
it('should fail if user doesnt have view-livechat-webhooks permission', async () => {
await removePermissionFromAllRoles('view-livechat-manager');
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: [] })
.expect(403);
});
it('should fail if Livechat_secret_token setting is empty', async () => {
await restorePermissionToRoles('view-livechat-manager');
await updateSetting('Livechat_secret_token', '');
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: 'test', timeout: 1000, fallbackMessage: 'test', extraData: [] })
.expect(400);
});
it('should return error when webhook returns error', async () => {
await updateSetting('Livechat_secret_token', 'test');
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: `${webhookUrl}/status/500`, timeout: 5000, fallbackMessage: 'test', extraData: [] })
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-invalid-external-service-response');
expect(res.body).to.have.property('response').to.be.a('string');
});
});
it('should return error when webhook times out', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: `${webhookUrl}/delay/2`, timeout: 1000, fallbackMessage: 'test', extraData: [] })
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-timeout');
expect(res.body).to.have.property('response').to.be.a('string');
});
});
it('should fail when webhook returns an answer that doesnt match the format', async () => {
await request
.post(api('livechat/triggers/external-service/test'))
.set(credentials)
.send({ webhookUrl: `${webhookUrl}/anything`, timeout: 5000, fallbackMessage: 'test', extraData: [] })
.expect(400)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error', 'error-invalid-external-service-response');
});
});
});
describe('livechat/triggers/:id', () => {

@ -1,18 +1,13 @@
import type { IRocketChatRecord } from './IRocketChatRecord';
export enum ILivechatTriggerType {
TIME_ON_SITE = 'time-on-site',
PAGE_URL = 'page-url',
CHAT_OPENED_BY_VISITOR = 'chat-opened-by-visitor',
AFTER_GUEST_REGISTRATION = 'after-guest-registration',
}
export type ILivechatTriggerType = 'time-on-site' | 'page-url' | 'chat-opened-by-visitor' | 'after-guest-registration';
export interface ILivechatTriggerCondition {
name: ILivechatTriggerType;
value?: string | number;
}
export interface ILivechatTriggerAction {
export interface ILivechatSendMessageAction {
name: 'send-message';
params?: {
sender: 'queue' | 'custom';
@ -21,6 +16,31 @@ export interface ILivechatTriggerAction {
};
}
export interface ILivechatUseExternalServiceAction {
name: 'use-external-service';
params?: {
sender: 'queue' | 'custom';
name: string;
serviceUrl: string;
serviceTimeout: number;
serviceFallbackMessage: string;
};
}
export const isExternalServiceTrigger = (
trigger: ILivechatTrigger,
): trigger is ILivechatTrigger & { actions: ILivechatUseExternalServiceAction[] } => {
return trigger.actions.every((action) => action.name === 'use-external-service');
};
export const isSendMessageTrigger = (
trigger: ILivechatTrigger,
): trigger is ILivechatTrigger & { actions: ILivechatSendMessageAction[] } => {
return trigger.actions.every((action) => action.name === 'send-message');
};
export type ILivechatTriggerAction = ILivechatSendMessageAction | ILivechatUseExternalServiceAction;
export interface ILivechatTrigger extends IRocketChatRecord {
name: string;
description: string;
@ -29,3 +49,14 @@ export interface ILivechatTrigger extends IRocketChatRecord {
conditions: ILivechatTriggerCondition[];
actions: ILivechatTriggerAction[];
}
export interface ILivechatTriggerActionResponse {
_id: string;
response: {
statusCode: number;
contents: {
msg: string;
order: number;
}[];
};
}

@ -2075,6 +2075,7 @@
"error-invalid-username": "Invalid username",
"error-invalid-value": "Invalid value",
"error-invalid-webhook-response": "The webhook URL responded with a status other than 200",
"error-invalid-external-service-response": "The external service response is not valid",
"error-license-user-limit-reached": "The maximum number of users has been reached.",
"error-logged-user-not-in-room": "You are not in the room `%s`",
"error-max-departments-number-reached": "You reached the maximum number of departments allowed by your license. Contact sale@rocket.chat for a new license.",
@ -2150,6 +2151,7 @@
"error-cannot-place-chat-on-hold": "You cannot place chat on-hold",
"error-contact-sent-last-message-so-cannot-place-on-hold": "You cannot place chat on-hold, when the Contact has sent the last message",
"error-unserved-rooms-cannot-be-placed-onhold": "Room cannot be placed on hold before being served",
"error-timeout": "The request has timed out",
"Workspace_exceeded_MAC_limit_disclaimer": "The workspace has exceeded the monthly limit of active contacts. Talk to your workspace admin to address this issue.",
"You_do_not_have_permission_to_do_this": "You do not have permission to do this",
"You_do_not_have_permission_to_execute_this_command": "You do not have enough permissions to execute command: `/{{command}}`",
@ -2203,6 +2205,10 @@
"External_Domains": "External Domains",
"External_Queue_Service_URL": "External Queue Service URL",
"External_Service": "External Service",
"External_service_url": "External service URL",
"External_service_action_hint": "Send a custom message using external service. For more details please check our docs.",
"External_service_test_hint": "Click on \"Send test\" before saving the trigger.",
"External_service_returned_valid_response": "External service returned a valid response",
"External_Users": "External Users",
"Extremely_likely": "Extremely likely",
"Facebook": "Facebook",
@ -2222,6 +2228,7 @@
"False": "False",
"Fallback_forward_department": "Fallback department for forwarding",
"Fallback_forward_department_description": "Allows you to define a fallback department which will receive the chats forwarded to this one in case there's no online agents at the moment",
"Fallback_message": "Fallback message",
"Favorite": "Favorite",
"Favorite_Rooms": "Enable Favorite Rooms",
"Favorites": "Favorites",
@ -4697,6 +4704,7 @@
"Selecting_users": "Selecting users",
"Send": "Send",
"Send_a_message": "Send a message",
"Send_a_message_external_service": "Send a message (external service)",
"Send_a_test_mail_to_my_user": "Send a test mail to my user",
"Send_a_test_push_to_my_user": "Send a test push to my user",
"Send_confirmation_email": "Send confirmation email",
@ -4735,6 +4743,7 @@
"send-many-messages_description": "Permission to bypasses rate limit of 5 messages per second",
"send-omnichannel-chat-transcript": "Send Omnichannel Conversation Transcript",
"send-omnichannel-chat-transcript_description": "Permission to send omnichannel conversation transcript",
"Sender": "Sender",
"Sender_Info": "Sender Info",
"Sending": "Sending...",
"Sending_Invitations": "Sending invitations",
@ -4755,6 +4764,7 @@
"Server_Type": "Server Type",
"Service": "Service",
"Service_account_key": "Service account key",
"Service_fallback_message_hint": "External service is currently active. Leave the field empty if you do not wish to send the message after the timeout ends.",
"Set_as_favorite": "Set as favorite",
"Set_as_leader": "Set as leader",
"Set_as_moderator": "Set as moderator",
@ -5237,6 +5247,9 @@
"Time_in_minutes": "Time in minutes",
"Time_in_seconds": "Time in seconds",
"Timeout": "Timeout",
"Timeout_in_miliseconds": "Timeout (in miliseconds)",
"Timeout_in_miliseconds_cant_be_negative_number": "Timeout (in miliseconds) can't a negative number",
"Timeout_in_miliseconds_hint": "The time in milliseconds to wait for an external service to respond before canceling the request.",
"Timeouts": "Timeouts",
"Timezone": "Timezone",
"Title": "Title",

@ -1833,6 +1833,7 @@
"error-no-owner-channel": "Apenas proprietários podem adicionar este canal à equipe",
"error-you-are-last-owner": "Você é o último proprietário da sala. Defina um novo proprietário antes de sair.",
"error-cannot-place-chat-on-hold": "Você não pode colocar a conversa em espera",
"error-timeout": "A solicitação atingiu o tempo limite",
"Errors_and_Warnings": "Erros e avisos",
"Esc_to": "Esc para",
"Estimated_wait_time": "Tempo estimado de espera (tempo em minutos)",
@ -1874,6 +1875,10 @@
"External_Domains": "Domínios externos",
"External_Queue_Service_URL": "URL do serviço de fila externa",
"External_Service": "Serviço Externo",
"External_service_url": "URL do serviço externo",
"External_service_action_hint": "Envie uma mensagem personalizada usando serviço externo. Para mais detalhes, consulte nossa documentação.",
"External_service_test_hint": "Clique em \"Enviar teste\" antes de salvar o gatilho.",
"External_service_returned_valid_response": "O serviço externo retornou uma resposta válida",
"External_Users": "Usuários Externos",
"Extremely_likely": "Muito provável",
"Facebook": "Facebook",
@ -1891,6 +1896,7 @@
"False": "Falso",
"Fallback_forward_department": "Departamento alternativo para encaminhamento",
"Fallback_forward_department_description": "Permite definir um departamento alternativo que vai receber as conversas encaminhadas a este, caso não haja agentes online no momento",
"Fallback_message": "Mensagem alternativa",
"Favorite": "Adicionar aos Favoritos",
"Favorite_Rooms": "Ativar salas favoritas",
"Favorites": "Favoritos",
@ -3860,6 +3866,7 @@
"Selecting_users": "Selecionando usuários",
"Send": "Enviar",
"Send_a_message": "Enviar uma mensagem",
"Send_a_message_external_service": "Enviar uma mensagem (serviço externo)",
"Send_a_test_mail_to_my_user": "Enviar um e-mail de teste para o meu usuário",
"Send_a_test_push_to_my_user": "Enviar um push de teste para o meu usuário",
"Send_confirmation_email": "Enviar e-mail de confirmação",
@ -3894,6 +3901,7 @@
"send-many-messages_description": "Permissão para ignorar a taxa de limite de 5 mensagens por segundo",
"send-omnichannel-chat-transcript": "Enviar transcrição de conversa do omnichannel",
"send-omnichannel-chat-transcript_description": "Permissão para enviar transcrição de conversas do omnichannel",
"Sender": "Remetente",
"Sender_Info": "Informações do remetente",
"Sending": "Enviando ...",
"Sent_an_attachment": "Enviou um anexo",
@ -3908,6 +3916,7 @@
"Server_Type": "Tipo de servidor",
"Service": "Serviço",
"Service_account_key": "Chave de conta de serviço",
"Service_fallback_message_hint": "O serviço externo está atualmente ativo. Deixe o campo vazio caso não queira enviar a mensagem após o término do tempo limite.",
"Set_as_favorite": "Definir como favorito",
"Set_as_leader": "Definir como líder",
"Set_as_moderator": "Definir como moderador",
@ -4308,6 +4317,9 @@
"Time_in_minutes": "Tempo em minutos",
"Time_in_seconds": "Tempo em segundos",
"Timeout": "Tempo limite",
"Timeout_in_miliseconds": "Tempo limite (em milisegundos)",
"Timeout_in_miliseconds_cant_be_negative_number": "O tempo limite em milissegundos não pode ser um número negativo",
"Timeout_in_miliseconds_hint": "O tempo em milissegundos de espera pela resposta de um serviço externo antes de cancelar a solicitação.",
"Timeouts": "Tempos limite",
"Timezone": "Fuso horário",
"Title": "Título",

@ -107,9 +107,9 @@ export class App extends Component<AppProps, AppState> {
return route('/leave-message');
}
const showDepartment = departments.filter((dept) => dept.showOnRegistration).length > 0;
const showDepartment = departments.some((dept) => dept.showOnRegistration);
const isAnyFieldVisible = nameFieldRegistrationForm || emailFieldRegistrationForm || showDepartment;
const showRegistrationForm = !user?.token && registrationForm && isAnyFieldVisible && !Triggers.showTriggerMessages();
const showRegistrationForm = !user?.token && registrationForm && isAnyFieldVisible && !Triggers.hasTriggersBeforeRegistration();
if (url === '/' && showRegistrationForm) {
return route('/register');

@ -11,5 +11,6 @@ export type Agent = {
description: string;
src: string;
};
ts: number;
[key: string]: unknown;
};

@ -25,7 +25,3 @@ const msgTypesNotRendered = [
];
export const canRenderMessage = ({ t }: { t: string }) => !msgTypesNotRendered.includes(t);
export const canRenderTriggerMessage =
(user: { token: string } | undefined) => (message: { trigger?: boolean; triggerAfterRegistration?: boolean }) =>
!message.trigger || (!user && !message.triggerAfterRegistration) || (user && message.triggerAfterRegistration);

@ -1,9 +1,9 @@
import type { IOmnichannelAgent } from '@rocket.chat/core-typings';
import type { IOmnichannelAgent, Serialized } from '@rocket.chat/core-typings';
import i18next from 'i18next';
import { getDateFnsLocale } from './locale';
export const normalizeAgent = (agentData: IOmnichannelAgent) =>
export const normalizeAgent = (agentData: Serialized<IOmnichannelAgent>) =>
agentData && { name: agentData.name, username: agentData.username, status: agentData.status };
export const normalizeQueueAlert = async (queueInfo: any) => {

@ -24,6 +24,7 @@ const createOrUpdateGuest = async (guest: StoreState['guest']) => {
}
store.setState({ user } as Omit<StoreState['user'], 'ts'>);
await loadConfig();
Triggers.callbacks?.emit('chat-visitor-registered');
};
const updateIframeGuestData = (data: Partial<StoreState['guest']>) => {
@ -49,11 +50,7 @@ const updateIframeGuestData = (data: Partial<StoreState['guest']>) => {
export type HooksWidgetAPI = typeof api;
const api = {
pageVisited: (info: { change: string; title: string; location: { href: string } }) => {
if (info.change === 'url') {
Triggers.processRequest(info);
}
pageVisited(info: { change: string; title: string; location: { href: string } }) {
const { token, room } = store.state;
const { _id: rid } = room || {};
@ -147,10 +144,10 @@ const api = {
store.setState({
defaultAgent: {
...props,
_id,
username,
ts: Date.now(),
...props,
},
});
},
@ -224,6 +221,10 @@ const api = {
setParentUrl: (parentUrl: StoreState['parentUrl']) => {
store.setState({ parentUrl });
},
setGuestMetadata(metadata: StoreState['iframe']['guestMetadata']) {
const { iframe } = store.state;
store.setState({ iframe: { ...iframe, guestMetadata: metadata } });
},
};
function onNewMessageHandler(event: MessageEvent<LivechatMessageEventData<HooksWidgetAPI>>) {

@ -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 }];
}
};

@ -1,64 +1,10 @@
import mitt from 'mitt';
import { route } from 'preact-router';
import { Livechat } from '../api';
import { asyncForEach } from '../helpers/asyncForEach';
import { upsert } from '../helpers/upsert';
import store from '../store';
import { normalizeAgent } from './api';
import { processUnread } from './main';
import { parentCall } from './parentCall';
import { createToken } from './random';
const agentCacheExpiry = 3600000;
let agentPromise;
const getAgent = (triggerAction) => {
if (agentPromise) {
return agentPromise;
}
agentPromise = new Promise(async (resolve, reject) => {
const { params } = triggerAction;
if (params.sender === 'queue') {
const { state } = store;
const {
defaultAgent,
iframe: {
guest: { department },
},
} = state;
if (defaultAgent && defaultAgent.ts && Date.now() - defaultAgent.ts < agentCacheExpiry) {
return resolve(defaultAgent); // cache valid for 1
}
let agent;
try {
agent = await Livechat.nextAgent({ department });
} catch (error) {
return reject(error);
}
store.setState({ defaultAgent: { ...agent, department, ts: Date.now() } });
resolve(agent);
} else if (params.sender === 'custom') {
resolve({
username: params.name,
});
} else {
reject('Unknown sender');
}
});
// expire the promise cache as well
setTimeout(() => {
agentPromise = null;
}, agentCacheExpiry);
return agentPromise;
};
const isInIframe = () => window.self !== window.top;
import { actions } from './triggerActions';
import { conditions } from './triggerConditions';
import { hasTriggerCondition, isInIframe } from './triggerUtils';
class Triggers {
/** @property {Triggers} instance*/
@ -76,16 +22,27 @@ class Triggers {
constructor() {
if (!Triggers.instance) {
this._started = false;
this._requests = [];
this._triggers = [];
this._enabled = true;
Triggers.instance = this;
this.callbacks = mitt();
Triggers.instance = this;
}
return Triggers.instance;
}
set triggers(newTriggers) {
this._triggers = [...newTriggers];
}
set enabled(value) {
this._enabled = value;
}
get parentUrl() {
return isInIframe() ? store.state.parentUrl : window.location.href;
}
init() {
if (this._started) {
return;
@ -93,7 +50,6 @@ class Triggers {
const {
token,
firedTriggers = [],
config: { triggers },
} = store.state;
Livechat.credentials.token = token;
@ -103,163 +59,121 @@ class Triggers {
}
this._started = true;
this._triggers = [...triggers];
this._triggers.forEach((trigger) => {
if (firedTriggers.includes(trigger._id)) {
trigger.skip = true;
}
});
this._triggers = triggers;
store.on('change', ([state, prevState]) => {
if (prevState.parentUrl !== state.parentUrl) {
this.processPageUrlTriggers();
}
});
}
this._syncTriggerRecords();
async fire(trigger) {
const { token, user } = store.state;
this._listenParentUrlChanges();
}
if (!this._enabled || user) {
return;
async when(id, condition) {
if (!this._enabled) {
return Promise.reject('Triggers disabled');
}
const { actions, conditions } = trigger;
await asyncForEach(actions, (action) => {
if (action.name === 'send-message') {
trigger.skip = true;
getAgent(action).then(async (agent) => {
const ts = new Date();
const message = {
msg: action.params.msg,
token,
u: agent,
ts: ts.toISOString(),
_id: createToken(),
trigger: true,
triggerAfterRegistration: conditions.some((c) => c.name === 'after-guest-registration'),
};
await store.setState({
triggered: true,
messages: upsert(
store.state.messages,
message,
({ _id }) => _id === message._id,
({ ts }) => new Date(ts).getTime(),
),
});
await processUnread();
if (agent && agent._id) {
await store.setState({ agent });
parentCall('callback', 'assign-agent', normalizeAgent(agent));
}
const foundCondition = trigger.conditions.find((c) => ['chat-opened-by-visitor', 'after-guest-registration'].includes(c.name));
if (!foundCondition) {
route('/trigger-messages');
}
store.setState({ minimized: false });
});
}
this._updateRecord(id, {
status: 'scheduled',
condition: condition.name,
error: null,
});
if (trigger.runOnce) {
trigger.skip = true;
store.setState({ firedTriggers: [...store.state.firedTriggers, trigger._id] });
try {
return await conditions[condition.name](condition);
} catch (error) {
this._updateRecord(id, { status: 'error', error });
throw error;
}
}
async fire(id, action, params) {
if (!this._enabled) {
return Promise.reject('Triggers disabled');
}
try {
await actions[action.name](id, action, params);
this._updateRecord(id, { status: 'fired', action: action.name });
} catch (error) {
this._updateRecord(id, { status: 'error', error });
throw error;
}
}
processRequest(request) {
this._requests.push(request);
schedule(trigger) {
const id = trigger._id;
const [condition] = trigger.conditions;
const [action] = trigger.actions;
return this.when(id, condition)
.then(() => this.fire(id, action, condition))
.catch((error) => console.error(`[Livechat Triggers]: ${error}`));
}
ready(triggerId, condition) {
const { activeTriggers = [] } = store.state;
store.setState({ activeTriggers: { ...activeTriggers, [triggerId]: condition } });
scheduleAll(triggers) {
triggers.map((trigger) => this.schedule(trigger));
}
showTriggerMessages() {
const { activeTriggers = [] } = store.state;
async processTriggers({ force = false, filter = () => true } = {}) {
const triggers = this._triggers.filter((trigger) => force || this._isValid(trigger)).filter(filter);
const triggers = Object.entries(activeTriggers);
this.scheduleAll(triggers);
}
if (!triggers.length) {
hasTriggersBeforeRegistration() {
if (!this._triggers.length) {
return false;
}
return triggers.some(([, condition]) => condition.name !== 'after-guest-registration');
const records = this._findRecordsByStatus(['scheduled', 'fired']);
return records.some((r) => r.condition !== 'after-guest-registration');
}
processTriggers() {
this._triggers.forEach((trigger) => {
if (trigger.skip) {
return;
_listenParentUrlChanges() {
store.on('change', ([state, prevState]) => {
if (prevState.parentUrl !== state.parentUrl) {
this.processTriggers({ force: true, filter: hasTriggerCondition('page-url') });
}
trigger.conditions.forEach((condition) => {
switch (condition.name) {
case 'page-url':
const hrefRegExp = new RegExp(condition.value, 'g');
if (this.parentUrl && hrefRegExp.test(this.parentUrl)) {
this.ready(trigger._id, condition);
this.fire(trigger);
}
break;
case 'time-on-site':
this.ready(trigger._id, condition);
trigger.timeout = setTimeout(() => {
this.fire(trigger);
}, parseInt(condition.value, 10) * 1000);
break;
case 'chat-opened-by-visitor':
case 'after-guest-registration':
const openFunc = () => {
this.fire(trigger);
this.callbacks.off('chat-opened-by-visitor', openFunc);
};
this.ready(trigger._id, condition);
this.callbacks.on('chat-opened-by-visitor', openFunc);
break;
}
});
});
this._requests = [];
}
processPageUrlTriggers() {
if (!this.parentUrl) return;
this._triggers.forEach((trigger) => {
if (trigger.skip) return;
_isValid(trigger) {
const record = this._findRecordById(trigger._id);
return !trigger.runOnce || !record?.status === 'fired';
}
trigger.conditions.forEach((condition) => {
if (condition.name !== 'page-url') return;
_updateRecord(id, data) {
const { triggersRecords = {} } = store.state;
const oldRecord = this._findRecordById(id);
const newRecord = { ...oldRecord, id, ...data };
const hrefRegExp = new RegExp(condition.value, 'g');
if (hrefRegExp.test(this.parentUrl)) {
this.fire(trigger);
}
});
});
store.setState({ triggersRecords: { ...triggersRecords, [id]: newRecord } });
}
set triggers(newTriggers) {
this._triggers = [...newTriggers];
_findRecordsByStatus(status) {
const { triggersRecords = {} } = store.state;
const records = Object.values(triggersRecords);
return records.filter((e) => status.includes(e.status));
}
set enabled(value) {
this._enabled = value;
_findRecordById(id) {
const { triggersRecords = {} } = store.state;
return triggersRecords[id];
}
get parentUrl() {
return isInIframe() ? store.state.parentUrl : window.location.href;
_syncTriggerRecords() {
const { triggersRecords = {} } = store.state;
const syncedTriggerRecords = this._triggers
.filter((trigger) => trigger.id in triggersRecords)
.reduce((acc, trigger) => {
acc[trigger.id] = triggersRecords[trigger.id];
return acc;
}, {});
store.setState({ triggersRecords: syncedTriggerRecords });
}
}

@ -3,7 +3,7 @@ import type { FunctionalComponent } from 'preact';
import { withTranslation } from 'react-i18next';
import { ChatContainer } from '.';
import { canRenderMessage, canRenderTriggerMessage } from '../../helpers/canRenderMessage';
import { canRenderMessage } from '../../helpers/canRenderMessage';
import { formatAgent } from '../../helpers/formatAgent';
import { Consumer } from '../../store';
@ -54,7 +54,7 @@ export const ChatConnector: FunctionalComponent<{ path: string; default: boolean
user={user}
agent={formatAgent(agent)}
room={room}
messages={messages?.filter(canRenderMessage).filter(canRenderTriggerMessage(user))}
messages={messages?.filter(canRenderMessage)}
noMoreMessages={noMoreMessages}
emoji={true}
uploads={uploads}

@ -16,6 +16,7 @@ import { sortArrayByColumn } from '../../helpers/sortArrayByColumn';
import CustomFields from '../../lib/customFields';
import { validateEmail } from '../../lib/email';
import { parentCall } from '../../lib/parentCall';
import Triggers from '../../lib/triggers';
import { StoreContext } from '../../store';
import type { StoreState } from '../../store';
import styles from './styles.scss';
@ -87,6 +88,7 @@ export const Register: FunctionalComponent<{ path: string }> = () => {
await dispatch({ user } as Omit<StoreState['user'], 'ts'>);
parentCall('callback', 'pre-chat-form-submit', fields);
Triggers.callbacks?.emit('chat-visitor-registered');
registerCustomFields(customFields);
} finally {
dispatch({ loading: false });

@ -60,7 +60,8 @@ export type StoreState = {
enabled: boolean;
};
iframe: {
guest: Serialized<ILivechatVisitorDTO> | Record<string, unknown>;
guest: Partial<Serialized<ILivechatVisitorDTO>>;
guestMetadata?: Record<string, string>;
theme: {
title?: string;
color?: string;

@ -41,6 +41,7 @@ type InitializeParams = {
language: string;
agent: StoreState['defaultAgent'];
parentUrl: string;
setGuestMetadata: StoreState['iframe']['guestMetadata'];
};
const WIDGET_OPEN_WIDTH = 365;
@ -346,6 +347,14 @@ function setParentUrl(url: string) {
callHook('setParentUrl', url);
}
function setGuestMetadata(metadata: StoreState['iframe']['guestMetadata']) {
if (typeof metadata !== 'object') {
throw new Error('Invalid metadata');
}
callHook('setGuestMetadata', metadata);
}
function initialize(initParams: Partial<InitializeParams>) {
for (const initKey in initParams) {
if (!initParams.hasOwnProperty(initKey)) {
@ -395,6 +404,9 @@ function initialize(initParams: Partial<InitializeParams>) {
case 'parentUrl':
setParentUrl(params as InitializeParams['parentUrl']);
continue;
case 'setGuestMetadata':
setGuestMetadata(params as InitializeParams['setGuestMetadata']);
continue;
default:
continue;
}
@ -492,6 +504,7 @@ const livechatWidgetAPI = {
setBusinessUnit,
clearBusinessUnit,
setParentUrl,
setGuestMetadata,
clearAllCallbacks,
// callbacks

@ -26,6 +26,7 @@ import type {
ReportResult,
ReportWithUnmatchingElements,
SMSProviderResponse,
ILivechatTriggerActionResponse,
} from '@rocket.chat/core-typings';
import { ILivechatAgentStatus } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
@ -3083,34 +3084,74 @@ const POSTLivechatTriggersParamsSchema = {
actions: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
enum: ['send-message'],
},
params: {
oneOf: [
{
type: 'object',
nullable: true,
properties: {
sender: {
name: {
type: 'string',
enum: ['queue', 'custom'],
enum: ['send-message'],
},
msg: {
type: 'string',
params: {
type: 'object',
nullable: true,
properties: {
sender: {
type: 'string',
enum: ['queue', 'custom'],
},
msg: {
type: 'string',
},
name: {
type: 'string',
nullable: true,
},
},
required: ['sender', 'msg'],
additionalProperties: false,
},
},
required: ['name'],
additionalProperties: false,
},
{
type: 'object',
properties: {
name: {
type: 'string',
enum: ['use-external-service'],
},
params: {
type: 'object',
nullable: true,
properties: {
sender: {
type: 'string',
enum: ['queue', 'custom'],
},
name: {
type: 'string',
nullable: true,
},
serviceUrl: {
type: 'string',
},
serviceTimeout: {
type: 'number',
},
serviceFallbackMessage: {
type: 'string',
},
},
required: ['serviceUrl', 'serviceTimeout', 'serviceFallbackMessage'],
additionalProperties: false,
},
},
required: ['sender', 'msg'],
required: ['name'],
additionalProperties: false,
},
},
required: ['name'],
additionalProperties: false,
],
},
minItems: 1,
},
@ -3238,6 +3279,90 @@ const LivechatAnalyticsOverviewPropsSchema = {
export const isLivechatAnalyticsOverviewProps = ajv.compile<LivechatAnalyticsOverviewProps>(LivechatAnalyticsOverviewPropsSchema);
type LivechatTriggerWebhookTestParams = {
webhookUrl: string;
timeout: number;
fallbackMessage: string;
extraData: {
key: string;
value: string;
}[];
};
const LivechatTriggerWebhookTestParamsSchema = {
type: 'object',
properties: {
webhookUrl: {
type: 'string',
},
timeout: {
type: 'number',
},
fallbackMessage: {
type: 'string',
},
extraData: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
},
value: {
type: 'string',
},
},
required: ['key', 'value'],
additionalProperties: false,
},
nullable: true,
},
},
required: ['webhookUrl', 'timeout', 'fallbackMessage'],
additionalProperties: false,
};
export const isLivechatTriggerWebhookTestParams = ajv.compile<LivechatTriggerWebhookTestParams>(LivechatTriggerWebhookTestParamsSchema);
type LivechatTriggerWebhookCallParams = {
token: string;
extraData?: {
key: string;
value: string;
}[];
};
const LivechatTriggerWebhookCallParamsSchema = {
type: 'object',
properties: {
token: {
type: 'string',
},
extraData: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
},
value: {
type: 'string',
},
},
required: ['key', 'value'],
additionalProperties: false,
},
nullable: true,
},
},
required: ['token'],
additionalProperties: false,
};
export const isLivechatTriggerWebhookCallParams = ajv.compile<LivechatTriggerWebhookCallParams>(LivechatTriggerWebhookCallParamsSchema);
export type OmnichannelEndpoints = {
'/v1/livechat/appearance': {
GET: () => {
@ -3721,6 +3846,12 @@ export type OmnichannelEndpoints = {
'/v1/livechat/sms-incoming/:service': {
POST: (params: unknown) => SMSProviderResponse;
};
'/v1/livechat/triggers/external-service/test': {
POST: (params: LivechatTriggerWebhookTestParams) => ILivechatTriggerActionResponse;
};
'/v1/livechat/triggers/:_id/external-service/call': {
POST: (params: LivechatTriggerWebhookCallParams) => ILivechatTriggerActionResponse;
};
} & {
// EE
'/v1/livechat/analytics/agents/average-service-time': {

Loading…
Cancel
Save