From b9e897a8f5cc67d01509c4f7213687a275a6f373 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 12 Mar 2024 13:06:35 -0600 Subject: [PATCH 1/3] 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> --- .changeset/rich-rocks-happen.md | 7 + .../app/livechat/server/api/lib/livechat.ts | 18 +- .../omnichannel/triggers/ConditionForm.tsx | 74 +++++ .../omnichannel/triggers/EditTrigger.tsx | 253 ++++++---------- .../actions/ActionExternalServiceUrl.tsx | 97 ++++++ .../triggers/actions/ActionForm.tsx | 113 +++++++ .../triggers/actions/ActionSender.tsx | 60 ++++ .../actions/ExternalServiceActionForm.tsx | 110 +++++++ .../actions/SendMessageActionForm.tsx | 60 ++++ .../views/omnichannel/triggers/hooks/index.ts | 1 + .../triggers/hooks/useFieldError.tsx | 13 + .../triggers/utils/getActionFormFields.tsx | 13 + .../views/omnichannel/triggers/utils/index.ts | 1 + .../livechat-enterprise/server/api/index.ts | 1 + .../server/api/lib/triggers.ts | 50 ++++ .../server/api/triggers.ts | 132 +++++++++ .../omnichannel/omnichannel-triggers.spec.ts | 12 +- .../e2e/page-objects/omnichannel-triggers.ts | 10 +- .../end-to-end/api/livechat/08-triggers.ts | 192 +++++++++++- packages/core-typings/src/ILivechatTrigger.ts | 45 ++- packages/i18n/src/locales/en.i18n.json | 13 + packages/i18n/src/locales/pt-BR.i18n.json | 12 + packages/livechat/src/components/App/App.tsx | 4 +- packages/livechat/src/definitions/agents.d.ts | 1 + .../livechat/src/helpers/canRenderMessage.ts | 4 - packages/livechat/src/lib/api.ts | 4 +- packages/livechat/src/lib/hooks.ts | 13 +- packages/livechat/src/lib/triggerActions.ts | 109 +++++++ .../livechat/src/lib/triggerConditions.ts | 64 ++++ packages/livechat/src/lib/triggerUtils.ts | 125 ++++++++ packages/livechat/src/lib/triggers.js | 280 ++++++------------ .../livechat/src/routes/Chat/connector.tsx | 4 +- .../livechat/src/routes/Register/index.tsx | 2 + packages/livechat/src/store/index.tsx | 3 +- packages/livechat/src/widget.ts | 13 + packages/rest-typings/src/v1/omnichannel.ts | 163 +++++++++- 36 files changed, 1666 insertions(+), 410 deletions(-) create mode 100644 .changeset/rich-rocks-happen.md create mode 100644 apps/meteor/client/views/omnichannel/triggers/ConditionForm.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/actions/ActionExternalServiceUrl.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/actions/ActionForm.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/actions/ActionSender.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/actions/ExternalServiceActionForm.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/actions/SendMessageActionForm.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/hooks/index.ts create mode 100644 apps/meteor/client/views/omnichannel/triggers/hooks/useFieldError.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/utils/getActionFormFields.tsx create mode 100644 apps/meteor/client/views/omnichannel/triggers/utils/index.ts create mode 100644 apps/meteor/ee/app/livechat-enterprise/server/api/lib/triggers.ts create mode 100644 apps/meteor/ee/app/livechat-enterprise/server/api/triggers.ts create mode 100644 packages/livechat/src/lib/triggerActions.ts create mode 100644 packages/livechat/src/lib/triggerConditions.ts create mode 100644 packages/livechat/src/lib/triggerUtils.ts diff --git a/.changeset/rich-rocks-happen.md b/.changeset/rich-rocks-happen.md new file mode 100644 index 000000000000..7c7b13f6f02c --- /dev/null +++ b/.changeset/rich-rocks-happen.md @@ -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)" diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index 1c59ad2d334c..24f5f8cc7c36 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -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[]> { 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( diff --git a/apps/meteor/client/views/omnichannel/triggers/ConditionForm.tsx b/apps/meteor/client/views/omnichannel/triggers/ConditionForm.tsx new file mode 100644 index 000000000000..1073c8e702cc --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/ConditionForm.tsx @@ -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 & { + index: number; + control: Control; +}; + +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 ( + + + {t('Condition')} + + ( + } - /> - - {conditionValuePlaceholder && ( - - { - if (conditions[index].name === 'time-on-site') { - return ; - } - return ; - }} - /> - - )} - - ); - })} - {actionsFields.map((_, index) => ( - - {t('Action')} - - - - - ( - ; + }} + /> + + + {senderNameFieldValue === 'custom' && ( + + { + return ; + }} + /> + + )} + + ); +}; diff --git a/apps/meteor/client/views/omnichannel/triggers/actions/ExternalServiceActionForm.tsx b/apps/meteor/client/views/omnichannel/triggers/actions/ExternalServiceActionForm.tsx new file mode 100644 index 000000000000..e71e81139950 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/actions/ExternalServiceActionForm.tsx @@ -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 & { + index: number; + control: Control; + trigger: UseFormTrigger; +}; + +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 ( + + + + + + + + {t('Timeout_in_miliseconds')} + + + { + return ( + ) => v.currentTarget.select()} + disabled={!hasLicense} + /> + ); + }} + /> + + + {timeoutError && ( + + {timeoutError.message} + + )} + + {t('Timeout_in_miliseconds_hint')} + + + + {t('Fallback_message')} + + ( + + )} + /> + + + {fallbackMessageError && ( + + {fallbackMessageError.message} + + )} + + {t('Service_fallback_message_hint')} + + + ); +}; diff --git a/apps/meteor/client/views/omnichannel/triggers/actions/SendMessageActionForm.tsx b/apps/meteor/client/views/omnichannel/triggers/actions/SendMessageActionForm.tsx new file mode 100644 index 000000000000..83154648971f --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/actions/SendMessageActionForm.tsx @@ -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 & { + index: number; + control: Control; +}; + +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 ( + <> + + + + + {t('Message')} + + + ( + + )} + /> + + + {messageError && ( + + {messageError.message} + + )} + + + ); +}; diff --git a/apps/meteor/client/views/omnichannel/triggers/hooks/index.ts b/apps/meteor/client/views/omnichannel/triggers/hooks/index.ts new file mode 100644 index 000000000000..b1478d68d70b --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/hooks/index.ts @@ -0,0 +1 @@ +export * from './useFieldError'; diff --git a/apps/meteor/client/views/omnichannel/triggers/hooks/useFieldError.tsx b/apps/meteor/client/views/omnichannel/triggers/hooks/useFieldError.tsx new file mode 100644 index 000000000000..b11ccc2a70bc --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/hooks/useFieldError.tsx @@ -0,0 +1,13 @@ +import type { Control, FieldError, FieldPath, FieldValues } from 'react-hook-form'; +import { get, useFormState } from 'react-hook-form'; + +type UseFieldErrorProps = { + control: Control; + name: FieldPath | FieldPath[]; +}; + +export const useFieldError = ({ control, name }: UseFieldErrorProps) => { + const names = Array.isArray(name) ? name : [name]; + const { errors } = useFormState({ control, name }); + return names.map((name) => get(errors, name)); +}; diff --git a/apps/meteor/client/views/omnichannel/triggers/utils/getActionFormFields.tsx b/apps/meteor/client/views/omnichannel/triggers/utils/getActionFormFields.tsx new file mode 100644 index 000000000000..2964df5c0b06 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/utils/getActionFormFields.tsx @@ -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']; +}; diff --git a/apps/meteor/client/views/omnichannel/triggers/utils/index.ts b/apps/meteor/client/views/omnichannel/triggers/utils/index.ts new file mode 100644 index 000000000000..30240078bbbd --- /dev/null +++ b/apps/meteor/client/views/omnichannel/triggers/utils/index.ts @@ -0,0 +1 @@ +export * from './getActionFormFields'; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/api/index.ts b/apps/meteor/ee/app/livechat-enterprise/server/api/index.ts index b20eb0512e77..f76bfdf259c6 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/api/index.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/api/index.ts @@ -10,3 +10,4 @@ import './business-hours'; import './rooms'; import './transcript'; import './reports'; +import './triggers'; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/api/lib/triggers.ts b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/triggers.ts new file mode 100644 index 000000000000..21bb7ad8708b --- /dev/null +++ b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/triggers.ts @@ -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; + headers: Record; +}) { + 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, + }; + } +} diff --git a/apps/meteor/ee/app/livechat-enterprise/server/api/triggers.ts b/apps/meteor/ee/app/livechat-enterprise/server/api/triggers.ts new file mode 100644 index 000000000000..1b066da0fa54 --- /dev/null +++ b/apps/meteor/ee/app/livechat-enterprise/server/api/triggers.ts @@ -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('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('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, + }); + }, + }, +); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts index 548aeb6a2717..e6afc8263ce3 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-triggers.spec.ts @@ -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(); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-triggers.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-triggers.ts index 407bd6a34997..8e1e1cb82881 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-triggers.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-triggers.ts @@ -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(); } diff --git a/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts b/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts index 9f467b4eeaab..92409c2c89d1 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/08-triggers.ts @@ -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', () => { diff --git a/packages/core-typings/src/ILivechatTrigger.ts b/packages/core-typings/src/ILivechatTrigger.ts index f34764394fd4..1c9eafbb9bd3 100644 --- a/packages/core-typings/src/ILivechatTrigger.ts +++ b/packages/core-typings/src/ILivechatTrigger.ts @@ -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; + }[]; + }; +} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index aebd4c14ed70..71b4f2b34102 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -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", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index 0976838bf4bd..f8cc3513463f 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -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", diff --git a/packages/livechat/src/components/App/App.tsx b/packages/livechat/src/components/App/App.tsx index 685b781cb3e3..ba1f435409f3 100644 --- a/packages/livechat/src/components/App/App.tsx +++ b/packages/livechat/src/components/App/App.tsx @@ -107,9 +107,9 @@ export class App extends Component { 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'); diff --git a/packages/livechat/src/definitions/agents.d.ts b/packages/livechat/src/definitions/agents.d.ts index fc8316bfa770..7bf4e6278d20 100644 --- a/packages/livechat/src/definitions/agents.d.ts +++ b/packages/livechat/src/definitions/agents.d.ts @@ -11,5 +11,6 @@ export type Agent = { description: string; src: string; }; + ts: number; [key: string]: unknown; }; diff --git a/packages/livechat/src/helpers/canRenderMessage.ts b/packages/livechat/src/helpers/canRenderMessage.ts index 75ab8604caf9..b7dd462498e6 100644 --- a/packages/livechat/src/helpers/canRenderMessage.ts +++ b/packages/livechat/src/helpers/canRenderMessage.ts @@ -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); diff --git a/packages/livechat/src/lib/api.ts b/packages/livechat/src/lib/api.ts index 84cf63a97ce3..1346bc06bd51 100644 --- a/packages/livechat/src/lib/api.ts +++ b/packages/livechat/src/lib/api.ts @@ -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) => agentData && { name: agentData.name, username: agentData.username, status: agentData.status }; export const normalizeQueueAlert = async (queueInfo: any) => { diff --git a/packages/livechat/src/lib/hooks.ts b/packages/livechat/src/lib/hooks.ts index ec47dcfb9b0c..79cb3129dd66 100644 --- a/packages/livechat/src/lib/hooks.ts +++ b/packages/livechat/src/lib/hooks.ts @@ -24,6 +24,7 @@ const createOrUpdateGuest = async (guest: StoreState['guest']) => { } store.setState({ user } as Omit); await loadConfig(); + Triggers.callbacks?.emit('chat-visitor-registered'); }; const updateIframeGuestData = (data: Partial) => { @@ -49,11 +50,7 @@ const updateIframeGuestData = (data: Partial) => { 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>) { diff --git a/packages/livechat/src/lib/triggerActions.ts b/packages/livechat/src/lib/triggerActions.ts new file mode 100644 index 000000000000..2ac9587aa84e --- /dev/null +++ b/packages/livechat/src/lib/triggerActions.ts @@ -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, +}; diff --git a/packages/livechat/src/lib/triggerConditions.ts b/packages/livechat/src/lib/triggerConditions.ts new file mode 100644 index 000000000000..da19a2b914c4 --- /dev/null +++ b/packages/livechat/src/lib/triggerConditions.ts @@ -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((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((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((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, +}; diff --git a/packages/livechat/src/lib/triggerUtils.ts b/packages/livechat/src/lib/triggerUtils.ts new file mode 100644 index 000000000000..9984f947b5d5 --- /dev/null +++ b/packages/livechat/src/lib/triggerUtils.ts @@ -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 | null; + +let agentPromise: Promise | null = null; + +const agentCacheExpiry = 3600000; + +const isAgentWithInfo = (agent: any): agent is Serialized => !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 => { + 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) => { + 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; + 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 }]; + } +}; diff --git a/packages/livechat/src/lib/triggers.js b/packages/livechat/src/lib/triggers.js index cf73a74f20be..1e03e3ee252c 100644 --- a/packages/livechat/src/lib/triggers.js +++ b/packages/livechat/src/lib/triggers.js @@ -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 }); } } diff --git a/packages/livechat/src/routes/Chat/connector.tsx b/packages/livechat/src/routes/Chat/connector.tsx index a0897964ddb7..2e727a3fe556 100644 --- a/packages/livechat/src/routes/Chat/connector.tsx +++ b/packages/livechat/src/routes/Chat/connector.tsx @@ -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} diff --git a/packages/livechat/src/routes/Register/index.tsx b/packages/livechat/src/routes/Register/index.tsx index 8582bde03d04..c752281e7372 100644 --- a/packages/livechat/src/routes/Register/index.tsx +++ b/packages/livechat/src/routes/Register/index.tsx @@ -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); parentCall('callback', 'pre-chat-form-submit', fields); + Triggers.callbacks?.emit('chat-visitor-registered'); registerCustomFields(customFields); } finally { dispatch({ loading: false }); diff --git a/packages/livechat/src/store/index.tsx b/packages/livechat/src/store/index.tsx index c6d5cd5ac089..06a8f05c4d06 100644 --- a/packages/livechat/src/store/index.tsx +++ b/packages/livechat/src/store/index.tsx @@ -60,7 +60,8 @@ export type StoreState = { enabled: boolean; }; iframe: { - guest: Serialized | Record; + guest: Partial>; + guestMetadata?: Record; theme: { title?: string; color?: string; diff --git a/packages/livechat/src/widget.ts b/packages/livechat/src/widget.ts index e43bd3943844..2f1e8f4ea318 100644 --- a/packages/livechat/src/widget.ts +++ b/packages/livechat/src/widget.ts @@ -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) { for (const initKey in initParams) { if (!initParams.hasOwnProperty(initKey)) { @@ -395,6 +404,9 @@ function initialize(initParams: Partial) { 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 diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index d0338e3ea986..354794a00f59 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -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(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(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(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': { From d3cb975af8f00e45812439165f5e78b1abacaa4c Mon Sep 17 00:00:00 2001 From: Aleksander Nicacio da Silva Date: Tue, 12 Mar 2024 17:26:23 -0300 Subject: [PATCH 2/3] regression: Livechat offline color not being applied (#31962) --- packages/livechat/src/components/Screen/index.js | 4 ++-- packages/livechat/src/routes/LeaveMessage/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/livechat/src/components/Screen/index.js b/packages/livechat/src/components/Screen/index.js index aaf5c826f011..f9bf036e19e2 100644 --- a/packages/livechat/src/components/Screen/index.js +++ b/packages/livechat/src/components/Screen/index.js @@ -75,7 +75,7 @@ const CssVar = ({ theme }) => { }; /** @type {{ (props: any) => JSX.Element; Content: (props: any) => JSX.Element; Footer: (props: any) => JSX.Element }} */ -export const Screen = ({ title, agent, children, className, unread, triggered = false, queueInfo, onSoundStop }) => { +export const Screen = ({ title, color, agent, children, className, unread, triggered = false, queueInfo, onSoundStop }) => { const { theme = {}, notificationsEnabled, @@ -96,7 +96,7 @@ export const Screen = ({ title, agent, children, className, unread, triggered = return (
- + {triggered && (