improve proactive message setup

Explain custom events and guide users when contact attributes are missing. Accept Go durations for campaign cooldowns and stop condition fields from losing focus.
This commit is contained in:
Abhinav Raut
2026-09-22 02:13:41 +05:30
parent a6143729c7
commit 96d8bb91ec
15 changed files with 211 additions and 62 deletions
+21 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"slices"
"strconv"
"strings"
@@ -48,6 +49,10 @@ func handleWidgetCampaign(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
cooldown, err := parseCampaignCooldown(config.CampaignCooldown)
if err != nil {
return sendErrorEnvelope(r, campaignInputError(app))
}
if err := checkConversationPermissions(app, config, ctx.Visitor, contact.ID, inbox.ID); err != nil {
return r.SendEnvelope(nil)
}
@@ -91,7 +96,7 @@ func handleWidgetCampaign(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
for _, c := range candidates {
if proactive.Suppression(c.campaign, ctx, history, config.CampaignCooldownHours) != "" {
if proactive.Suppression(c.campaign, ctx, history, cooldown) != "" {
continue
}
delivery, err := app.proactive.Reserve(inbox.ID, c.campaign, ctx, c.snapshot)
@@ -236,7 +241,10 @@ func campaignInputError(app *App) error {
}
func validateWidgetFeatures(app *App, config livechat.Config) error {
if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || config.Previews.AutoHideSeconds < 0 || config.Previews.AutoHideSeconds > 300 || !slices.Contains([]string{"", "message", "generic"}, config.Previews.Content) || len(config.Campaigns) > 50 || config.CampaignCooldownHours < 0 || config.CampaignCooldownHours > 8760 {
if config.Help.HelpCenterID < 0 || len(config.Help.FeaturedIDs) > 10 || config.Previews.AutoHideSeconds < 0 || config.Previews.AutoHideSeconds > 300 || !slices.Contains([]string{"", "message", "generic"}, config.Previews.Content) || len(config.Campaigns) > 50 {
return campaignInputError(app)
}
if _, err := parseCampaignCooldown(config.CampaignCooldown); err != nil {
return campaignInputError(app)
}
if config.Help.HelpCenterID > 0 {
@@ -271,3 +279,14 @@ func validateWidgetFeatures(app *App, config livechat.Config) error {
}
return nil
}
func parseCampaignCooldown(value string) (time.Duration, error) {
duration, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if duration < 0 {
return 0, errors.New("campaign cooldown cannot be negative")
}
return duration, nil
}
+28
View File
@@ -2,6 +2,7 @@ package main
import (
"testing"
"time"
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
)
@@ -29,3 +30,30 @@ func TestWithinWorkingHours(t *testing.T) {
})
}
}
func TestParseCampaignCooldown(t *testing.T) {
tests := []struct {
value string
want time.Duration
valid bool
}{
{value: "0s", valid: true},
{value: "10m", want: 10 * time.Minute, valid: true},
{value: "1h30m", want: 90 * time.Minute, valid: true},
{value: ""},
{value: "tomorrow"},
{value: "-1m"},
}
for _, tc := range tests {
t.Run(tc.value, func(t *testing.T) {
got, err := parseCampaignCooldown(tc.value)
if tc.valid && (err != nil || got != tc.want) {
t.Fatalf("got %v, %v; want %v", got, err, tc.want)
}
if !tc.valid && err == nil {
t.Fatalf("accepted %q", tc.value)
}
})
}
}
@@ -947,7 +947,7 @@
:model-value="form.values.config.campaigns || []"
:inbox-id="initialValues.id || 0"
:brand-name="form.values.config.brand_name || ''"
:cooldown="form.values.config.campaign_cooldown_hours ?? 24"
:cooldown="form.values.config.campaign_cooldown ?? '24h'"
:show-errors="showCampaignErrors"
@update:model-value="updateCampaigns"
@update:cooldown="updateCampaignCooldown"
@@ -1732,7 +1732,7 @@ const form = useForm({
help: defaultWidgetHelp(),
previews: defaultWidgetPreviews(),
campaigns: [],
campaign_cooldown_hours: 24,
campaign_cooldown: '24h',
brand_name: '',
website_url: '',
theme: 'light',
@@ -1890,8 +1890,8 @@ const hasCampaignErrors = computed(() =>
)
const updateCampaigns = (campaigns) =>
form.setFieldValue('config.campaigns', campaigns, hasCampaignErrors.value)
const updateCampaignCooldown = (hours) =>
form.setFieldValue('config.campaign_cooldown_hours', hours, hasCampaignErrors.value)
const updateCampaignCooldown = (duration) =>
form.setFieldValue('config.campaign_cooldown', duration, hasCampaignErrors.value)
// home_apps in form.values only syncs on change events, so pull the live ref for the preview.
const previewTheme = inject('livechatPreviewTheme', ref('light'))
@@ -2130,7 +2130,7 @@ watch(
...audienceConfigs,
prechat_form: pc,
campaigns: newValues.config?.campaigns || [],
campaign_cooldown_hours: newValues.config?.campaign_cooldown_hours || 24,
campaign_cooldown: newValues.config?.campaign_cooldown || '24h',
help: newValues.config?.help || defaultWidgetHelp(),
previews: newValues.config?.previews || defaultWidgetPreviews(),
features: {
@@ -1,4 +1,6 @@
<script setup>
const HINT_CLASS = 'text-xs text-muted-foreground'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
@@ -46,6 +48,7 @@ import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import WidgetConditions from './WidgetConditions.vue'
import { createCampaignSchema, defaultCampaign, newCampaignId } from './livechatFormSchema.js'
import { handleHTTPError } from '@shared-ui/utils/http'
import { isGoDuration } from '@shared-ui/utils/string'
import {
ChevronRight,
ChevronDown,
@@ -75,7 +78,7 @@ const props = defineProps({
modelValue: { type: Array, default: () => [] },
inboxId: { type: Number, default: 0 },
brandName: { type: String, default: '' },
cooldown: { type: Number, default: 24 },
cooldown: { type: String, default: '24h' },
showErrors: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'update:cooldown', 'update:preview'])
@@ -92,6 +95,9 @@ const stats = ref([])
const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10))
const to = ref(new Date().toISOString().slice(0, 10))
const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot'))
const campaignEventExample = computed(
() => `window.Libredesk.trackEvent(${JSON.stringify(campaign.value?.event || 'pricing_viewed')})`
)
const senderName = (id) => {
const agent = usersStore.options.find((item) => String(item.value) === String(id))
return agent?.label || brandSenderName.value
@@ -113,10 +119,7 @@ const campaignSectionError = (field) =>
?.message || ''
const cooldownError = computed(() => {
if (!props.showErrors) return ''
const value = Number(props.cooldown)
return Number.isInteger(value) && value >= 0 && value <= 8760
? ''
: t('validation.minmaxNumber', { min: 0, max: 8760 })
return isGoDuration(props.cooldown) ? '' : t('validation.invalidDuration')
})
const audienceLabel = (item) => t(AUDIENCE_TRANSLATION_KEYS[item.audience])
@@ -205,7 +208,7 @@ const remove = () => {
}
const showInvalidField = async (field) => {
if (field === 'config.campaign_cooldown_hours') {
if (field === 'config.campaign_cooldown') {
root.value?.querySelector('#campaign-cooldown')?.focus()
return
}
@@ -280,13 +283,16 @@ onMounted(async () => {
<Label for="campaign-cooldown">{{ t('widget.campaignCooldown') }}</Label>
<Input
id="campaign-cooldown"
type="number"
min="0"
max="8760"
type="text"
placeholder="1h"
:model-value="cooldown"
@update:model-value="emit('update:cooldown', Number($event))"
aria-describedby="campaign-cooldown-hint"
@update:model-value="emit('update:cooldown', $event)"
:aria-invalid="!!cooldownError"
/>
<p id="campaign-cooldown-hint" :class="HINT_CLASS">
{{ t('globals.messages.golangDurationHoursMinutes') }}
</p>
<p v-if="cooldownError" role="alert" class="text-sm text-destructive">
{{ cooldownError }}
</p>
@@ -361,7 +367,7 @@ onMounted(async () => {
<Plus class="size-4" />
{{ t('widget.addCampaign') }}
</Button>
<p v-if="modelValue.length >= MAX_CAMPAIGNS" class="text-xs text-muted-foreground">
<p v-if="modelValue.length >= MAX_CAMPAIGNS" :class="HINT_CLASS">
{{ t('widget.campaignLimitReached') }}
</p>
</div>
@@ -580,7 +586,7 @@ onMounted(async () => {
@update:model-value="update(key, $event.split('\n'))"
:aria-invalid="!!campaignFieldError(key)"
/>
<p class="text-xs text-muted-foreground">{{ t('widget.urlPatternsHint') }}</p>
<p :class="HINT_CLASS">{{ t('widget.urlPatternsHint') }}</p>
<p v-if="campaignFieldError(key)" role="alert" class="text-sm text-destructive">
{{ campaignFieldError(key) }}
</p>
@@ -612,9 +618,19 @@ onMounted(async () => {
<Input
id="campaign-event"
:model-value="campaign.event"
placeholder="pricing_viewed"
maxlength="128"
aria-describedby="campaign-event-hint campaign-event-example"
@update:model-value="update('event', $event)"
/>
<p id="campaign-event-hint" :class="HINT_CLASS">
{{ t('widget.campaignEventHint') }}
</p>
<code
id="campaign-event-example"
class="block overflow-x-auto rounded-md border bg-muted px-3 py-2 text-xs"
>{{ campaignEventExample }}</code
>
</div>
<div class="space-y-2">
@@ -1,5 +1,9 @@
<script setup>
import { ref, watch, onMounted } from 'vue'
const MUTED_TEXT_CLASS = 'text-sm text-muted-foreground'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { buttonVariants } from '@shared-ui/components/ui/button'
import RuleBox from '@/features/admin/automation/RuleBox.vue'
import { useCustomAttributeStore } from '@/stores/customAttributes'
@@ -7,21 +11,25 @@ const props = defineProps({
modelValue: { type: Object, default: () => ({ logical_op: 'AND', rules: [] }) }
})
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const customAttributeStore = useCustomAttributeStore()
const group = ref({ logical_op: 'AND', rules: [] })
const isLoading = ref(true)
const hasContactCustomAttributes = computed(
() => customAttributeStore.contactAttributeOptions.length > 0
)
watch(
() => props.modelValue,
(value) => {
group.value = value ? JSON.parse(JSON.stringify(value)) : { logical_op: 'AND', rules: [] }
const next = value
? JSON.parse(JSON.stringify(value))
: { logical_op: 'AND', rules: [] }
if (JSON.stringify(next) !== JSON.stringify(group.value)) group.value = next
},
{ immediate: true, deep: true }
)
const update = () => {
const value = JSON.parse(JSON.stringify(group.value))
value.rules = value.rules.map((rule) => ({
...rule,
value: Array.isArray(rule.value) ? rule.value.join(',') : rule.value
}))
emit('update:modelValue', value)
emit('update:modelValue', JSON.parse(JSON.stringify(group.value)))
}
const add = () => {
group.value.rules.push({
@@ -37,11 +45,15 @@ const remove = (_, index) => {
group.value.rules.splice(index, 1)
update()
}
onMounted(() => useCustomAttributeStore().fetchCustomAttributes())
onMounted(async () => {
await customAttributeStore.refreshCustomAttributes()
isLoading.value = false
})
</script>
<template>
<RuleBox
v-if="hasContactCustomAttributes"
:rule-group="group"
:group-index="0"
type="new_conversation"
@@ -50,4 +62,18 @@ onMounted(() => useCustomAttributeStore().fetchCustomAttributes())
@add-condition="add"
@remove-condition="remove"
/>
<p v-else-if="isLoading" role="status" :class="MUTED_TEXT_CLASS">
{{ t('globals.terms.loading') }}
</p>
<div v-else class="space-y-3 rounded-md border border-dashed p-4">
<p :class="MUTED_TEXT_CLASS">
{{ t('widget.campaignContactAttributesEmpty') }}
</p>
<RouterLink
:to="{ name: 'custom-attributes' }"
:class="buttonVariants({ variant: 'link', size: 'sm' })"
>
{{ t('widget.addContactCustomAttribute') }}
</RouterLink>
</div>
</template>
@@ -251,7 +251,11 @@ export const createFormSchema = (t) =>
linked_email_inbox_id: z.number().nullable().optional(),
config: z.object({
campaigns: z.array(createCampaignSchema(t)).max(50).default([]),
campaign_cooldown_hours: rangeInteger(t, 0, 8760).default(24),
campaign_cooldown: z
.string()
.min(1, { message: t('globals.messages.required') })
.refine(isGoDuration, { message: t('validation.invalidDuration') })
.default('24h'),
help: z
.object({
help_center_id: z.number().int().min(0),
@@ -287,7 +287,7 @@ describe('Livechat Inbox Form Schema', () => {
repeat_hours: 0
}
],
campaign_cooldown_hours: -1
campaign_cooldown: 'tomorrow'
})
)
expect(result.success).toBe(false)
@@ -295,10 +295,19 @@ describe('Livechat Inbox Form Schema', () => {
'widget.campaignUrlLimit {}',
'validation.minmaxNumber {"min":0,"max":86400}',
'validation.minmaxNumber {"min":1,"max":8760}',
'validation.minmaxNumber {"min":0,"max":8760}'
'validation.invalidDuration {}'
])
})
test.each(['0s', '10m', '1h', '1h30m'])(
'campaign cooldown accepts %s',
(campaignCooldown) => {
expect(() =>
schema.parse(withConfig({ campaign_cooldown: campaignCooldown }))
).not.toThrow()
}
)
test('quick replies reject more than six non-empty lines', () => {
expect(() => schema.parse(withConfig({ quick_replies: '1\n2\n3\n4\n5\n6\n7' }))).toThrow()
expect(() => schema.parse(withConfig({ quick_replies: '1\n2\n\n3\n4\n5\n6' }))).not.toThrow()
@@ -1,9 +1,9 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import api from '@/api'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { useEmitter } from '@/composables/useEmitter'
export const useCustomAttributeStore = defineStore('customAttributes', () => {
const attributes = ref([])
@@ -28,8 +28,7 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => {
})
let inflight = null
let hasFetched = false
const fetchCustomAttributes = () => {
if (hasFetched) return Promise.resolve()
const loadCustomAttributes = () => {
if (inflight) return inflight
inflight = api.getCustomAttributes()
.then(response => {
@@ -45,10 +44,13 @@ export const useCustomAttributeStore = defineStore('customAttributes', () => {
.finally(() => { inflight = null })
return inflight
}
const fetchCustomAttributes = () => hasFetched ? Promise.resolve() : loadCustomAttributes()
const refreshCustomAttributes = () => loadCustomAttributes()
return {
attributes,
conversationAttributeOptions,
contactAttributeOptions,
fetchCustomAttributes,
refreshCustomAttributes,
}
})
})
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
vi.mock('@/api', () => ({
default: { getCustomAttributes: vi.fn() }
}))
vi.mock('@/composables/useEmitter', () => ({
useEmitter: () => ({ emit: vi.fn() })
}))
import api from '@/api'
import { useCustomAttributeStore } from '@/stores/customAttributes'
describe('custom attribute store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('refreshes attributes after the initial cached fetch', async () => {
api.getCustomAttributes
.mockResolvedValueOnce({ data: { data: [{ id: 1, applies_to: 'conversation' }] } })
.mockResolvedValueOnce({
data: { data: [{ id: 2, name: 'Plan', applies_to: 'contact' }] }
})
const store = useCustomAttributeStore()
await store.fetchCustomAttributes()
await store.fetchCustomAttributes()
await store.refreshCustomAttributes()
expect(api.getCustomAttributes).toHaveBeenCalledTimes(2)
expect(store.contactAttributeOptions).toEqual([
{ id: 2, name: 'Plan', applies_to: 'contact', label: 'Plan', value: '2' }
])
})
})
@@ -129,6 +129,10 @@ describe('Live chat inbox form: proactive messages and help center tabs', () =>
cy.get('#campaign-exclude_urls').clear().type('/checkout/*')
cy.get('#campaign-delay_seconds').type('{selectall}25')
cy.get('#campaign-event').clear().type('pricing_viewed')
cy.contains(
'Show this message only after your website tracks this event. Leave this field blank to show it without an event.'
).should('be.visible')
cy.contains('code', 'window.Libredesk.trackEvent("pricing_viewed")').should('be.visible')
selectOption('#campaign-repeat', 'After an interval')
cy.get('#campaign-repeat_hours').type('{selectall}12')
+4 -1
View File
@@ -1676,6 +1676,7 @@
"webhook.sendTest": "Send test",
"webhook.sentSuccessfully": "Webhook sent successfully",
"widget.addCampaign": "Add proactive message",
"widget.addContactCustomAttribute": "Add contact custom attribute",
"widget.addHelpHomeApp": "Add help articles",
"widget.articleUnavailable": "Article unavailable",
"widget.autoHideSeconds": "Hide previews after (seconds, 0 keeps them visible)",
@@ -1702,8 +1703,10 @@
"widget.campaign.section.placement": "Where and when",
"widget.campaign.session": "Once per session",
"widget.campaign.users": "Signed-in users",
"widget.campaignCooldown": "Minimum hours between proactive messages",
"widget.campaignContactAttributesEmpty": "Visibility conditions use contact custom attributes. None are available.",
"widget.campaignCooldown": "Minimum time between proactive messages",
"widget.campaignDeviceRequired": "Select at least one device. This proactive message cannot appear until desktop or mobile is enabled.",
"widget.campaignEventHint": "Show this message only after your website tracks this event. Leave this field blank to show it without an event.",
"widget.campaignLimitReached": "You have reached the limit of 50 proactive messages. Delete one to add another.",
"widget.campaignPriorityHint": "Messages are checked from top to bottom. The first matching message is shown.",
"widget.campaignsHint": "Show a message on matching pages before the chat is opened. A conversation is created only when someone replies to it.",
+13 -13
View File
@@ -157,19 +157,19 @@ type PreChatFormConfig struct {
// Config holds the live chat inbox configuration.
type Config struct {
Campaigns []proactive.Campaign `json:"campaigns"`
CampaignCooldownHours int `json:"campaign_cooldown_hours"`
Help HelpConfig `json:"help"`
Previews PreviewConfig `json:"previews"`
BrandName string `json:"brand_name"`
WebsiteURL string `json:"website_url"`
Theme string `json:"theme"`
ShowPoweredBy bool `json:"show_powered_by"`
Language string `json:"language"`
FallbackLanguage string `json:"fallback_language"`
Users AudienceConfig `json:"users"`
Branding BrandingSet `json:"branding"`
Features struct {
Campaigns []proactive.Campaign `json:"campaigns"`
CampaignCooldown string `json:"campaign_cooldown"`
Help HelpConfig `json:"help"`
Previews PreviewConfig `json:"previews"`
BrandName string `json:"brand_name"`
WebsiteURL string `json:"website_url"`
Theme string `json:"theme"`
ShowPoweredBy bool `json:"show_powered_by"`
Language string `json:"language"`
FallbackLanguage string `json:"fallback_language"`
Users AudienceConfig `json:"users"`
Branding BrandingSet `json:"branding"`
Features struct {
Emoji bool `json:"emoji"`
FileUpload bool `json:"file_upload"`
Transcript bool `json:"transcript"`
@@ -36,33 +36,33 @@ func TestEligibilityAndSuppression(t *testing.T) {
}
}
history := []Delivery{{CampaignID: "a", Displayed: true, CreatedAt: now.Add(-48 * time.Hour), SessionKey: "session"}}
if got := Suppression(campaign, ctx, history, 24); got != "repeat" {
if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" {
t.Fatalf("once: %q", got)
}
campaign.Repeat = "session"
if got := Suppression(campaign, ctx, history, 24); got != "repeat" {
if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" {
t.Fatalf("session: %q", got)
}
ctx.SessionKey = "new"
if got := Suppression(campaign, ctx, history, 24); got != "" {
if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "" {
t.Fatalf("new session: %q", got)
}
campaign.Repeat = "interval"
campaign.RepeatHours = 72
if got := Suppression(campaign, ctx, history, 24); got != "repeat" {
if got := Suppression(campaign, ctx, history, 24*time.Hour); got != "repeat" {
t.Fatalf("interval: %q", got)
}
history[0].CampaignID = "other"
history[0].CreatedAt = now.Add(-time.Hour)
if got := Suppression(campaign, ctx, history, 24); got != "cooldown" {
if got := Suppression(campaign, ctx, history, 90*time.Minute); got != "cooldown" {
t.Fatalf("shared cooldown: %q", got)
}
history[0].Displayed = false
if got := Suppression(campaign, ctx, history, 24); got != "" {
if got := Suppression(campaign, ctx, history, 90*time.Minute); got != "" {
t.Fatalf("expired reservation suppressed: %q", got)
}
history[0].CreatedAt = now.Add(-time.Second)
if got := Suppression(campaign, ctx, history, 24); got != "cooldown" {
if got := Suppression(campaign, ctx, history, 10*time.Minute); got != "cooldown" {
t.Fatalf("concurrent reservation: %q", got)
}
}
@@ -112,7 +112,7 @@ func (m *Manager) error(err error) error {
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
func Suppression(c Campaign, ctx Context, history []Delivery, cooldownHours int) string {
func Suppression(c Campaign, ctx Context, history []Delivery, cooldown time.Duration) string {
for _, d := range history {
if !d.Displayed && !d.Dismissed && !d.Replied && ctx.Now.Sub(d.CreatedAt) > time.Minute {
continue
@@ -123,7 +123,7 @@ func Suppression(c Campaign, ctx Context, history []Delivery, cooldownHours int)
}
continue
}
if cooldownHours > 0 && ctx.Now.Sub(d.CreatedAt) < time.Duration(cooldownHours)*time.Hour {
if cooldown > 0 && ctx.Now.Sub(d.CreatedAt) < cooldown {
return "cooldown"
}
}
@@ -34,7 +34,7 @@ func TestConcurrentDeliveryAndOwnership(t *testing.T) {
t.Error(err)
return
}
if Suppression(c, ctx, history, 24) != "" {
if Suppression(c, ctx, history, 24*time.Hour) != "" {
return
}
delivery, err := m.Reserve(inboxID, c, ctx, Snapshot{Message: "Hello"})
@@ -83,7 +83,7 @@ func TestConcurrentDeliveryAndOwnership(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if Suppression(c, ctx, history, 24) != "repeat" {
if Suppression(c, ctx, history, 24*time.Hour) != "repeat" {
t.Fatal("identified contact lost delivery history")
}
}