Merge branch 'feat/scenario-tools-association' into feat/scenario-agents

This commit is contained in:
Shivam Mishra
2025-07-16 17:10:37 +05:30
committed by GitHub
61 changed files with 1839 additions and 174 deletions
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
def campaign_params
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
:scheduled_at, audience: [:type, :id], trigger_rules: {})
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
end
end
@@ -41,7 +41,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@@ -7,8 +7,9 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper_method :render_vue_component
helper_method :render_vue_component, :settings_open?, :settings_pages
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!
@@ -1,5 +1,7 @@
# TODO: Move this values to features.yml itself
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
@@ -32,6 +34,15 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
# ------- Product Features ------- #
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
# ------- Communication Channels ------- #
live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
@@ -42,6 +53,12 @@ email:
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'icon-mail-send-fill'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'icon-message-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
@@ -69,22 +86,22 @@ line:
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'icon-line-line'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'icon-message-line'
help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
icon: 'icon-google'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'icon-microsoft'
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'
@@ -1,6 +1,6 @@
module SuperAdmin::FeaturesHelper
def self.available_features
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
end
def self.plan_details
@@ -0,0 +1,16 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def settings_pages
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
general_feature + features.to_a
end
end
@@ -0,0 +1,37 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>
@@ -0,0 +1,48 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>
@@ -0,0 +1,357 @@
<script setup>
import { reactive, computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
};
const initialState = {
title: '',
inboxId: null,
templateId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const rules = {
title: { required, minLength: minLength(1) },
inboxId: { required },
templateId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
template: getErrorMessage('templateId', 'TEMPLATE'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
});
const isSubmitDisabled = computed(
() => v$.value.$invalid || !hasRequiredTemplateParams.value
);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
};
return {
title: state.title,
message: templateContent,
template_params: templateParams,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
// Reset template selection when inbox changes
watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
</p>
</div>
<!-- Template Preview -->
<div
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>
@@ -331,6 +331,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
{
name: 'WhatsApp',
label: t('SIDEBAR.WHATSAPP'),
to: accountScopedRoute('campaigns_whatsapp_index'),
},
],
},
{
+1
View File
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
@@ -146,6 +146,7 @@
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
"SEND_ATTACHMENT": "Send Attachment",
"SEND_MESSAGE": "Send a Message",
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
@@ -137,6 +137,70 @@
}
}
},
"WHATSAPP": {
"HEADER_TITLE": "WhatsApp campaigns",
"NEW_CAMPAIGN": "Create campaign",
"EMPTY_STATE": {
"TITLE": "No WhatsApp campaigns are available",
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
"CAMPAIGN_DETAILS": {
"SENT_FROM": "Sent from",
"ON": "on"
}
},
"CREATE": {
"TITLE": "Create WhatsApp campaign",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"TEMPLATE": {
"LABEL": "WhatsApp Template",
"PLACEHOLDER": "Select a template",
"INFO": "Select a template to use for this campaign.",
"ERROR": "Template is required",
"PREVIEW_TITLE": "Process {templateName}",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"ERROR": "Scheduled time is required"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
}
},
"CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -319,6 +319,7 @@
"CSAT": "CSAT",
"LIVE_CHAT": "Live Chat",
"SMS": "SMS",
"WHATSAPP": "WhatsApp",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -50,6 +51,15 @@ const campaignsRoutes = {
meta,
component: SMSCampaignsPage,
},
{
path: 'whatsapp',
name: 'campaigns_whatsapp_index',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: WhatsAppCampaignsPage,
},
],
},
],
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
@@ -25,8 +24,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
const liveChatCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
const liveChatCampaigns = computed(
() => getters['campaigns/getLiveChatCampaigns'].value
);
const hasNoLiveChatCampaigns = computed(
@@ -59,7 +58,7 @@ const handleDelete = campaign => {
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
class="flex justify-center items-center py-10 text-n-slate-11"
>
<Spinner />
</div>
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
@@ -23,9 +22,7 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const SMSCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
);
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
const hasNoSMSCampaigns = computed(
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const WhatsAppCampaigns = computed(
() => getters['campaigns/getWhatsAppCampaigns'].value
);
const hasNoWhatsAppCampaigns = computed(
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
@click="toggleWhatsAppCampaignDialog()"
@close="toggleWhatsAppCampaignDialog(false)"
>
<template #action>
<WhatsAppCampaignDialog
v-if="showWhatsAppCampaignDialog"
@close="toggleWhatsAppCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
/>
<WhatsAppCampaignEmptyState
v-else
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>
@@ -555,6 +555,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'SEND_MESSAGE',
inputType: 'textarea',
},
{
key: 'add_private_note',
label: 'ADD_PRIVATE_NOTE',
inputType: 'textarea',
},
{
key: 'change_priority',
label: 'CHANGE_PRIORITY',
@@ -3,6 +3,8 @@ import types from '../mutation-types';
import CampaignsAPI from '../../api/campaigns';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const state = {
records: [],
@@ -16,10 +18,35 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCampaigns: _state => campaignType => {
return _state.records
.filter(record => record.campaign_type === campaignType)
.sort((a1, a2) => a1.id - a2.id);
getCampaigns:
_state =>
(campaignType, inboxChannelTypes = null) => {
let filteredRecords = _state.records.filter(
record => record.campaign_type === campaignType
);
if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) {
filteredRecords = filteredRecords.filter(record => {
return (
record.inbox &&
inboxChannelTypes.includes(record.inbox.channel_type)
);
});
}
return filteredRecords.sort((a1, a2) => a1.id - a2.id);
},
getSMSCampaigns: (_state, _getters) => {
const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes);
},
getWhatsAppCampaigns: (_state, _getters) => {
const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes);
},
getLiveChatCampaigns: (_state, _getters) => {
const liveChatChannelTypes = [INBOX_TYPES.WEB];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes);
},
getAllCampaigns: _state => {
return _state.records;
@@ -96,6 +96,11 @@ export const getters = {
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
);
},
getWhatsAppInboxes($state) {
return $state.records.filter(
item => item.channel_type === INBOX_TYPES.WHATSAPP
);
},
dialogFlowEnabledInboxes($state) {
return $state.records.filter(
item => item.channel_type !== INBOX_TYPES.EMAIL
@@ -11,6 +11,11 @@ export default [
url: 'https://github.com',
time_on_page: 10,
},
inbox: {
id: 1,
channel_type: 'Channel::WebWidget',
name: 'Web Widget',
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
@@ -24,6 +29,11 @@ export default [
url: 'https://chatwoot.com',
time_on_page: '20',
},
inbox: {
id: 2,
channel_type: 'Channel::TwilioSms',
name: 'Twilio SMS',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
@@ -39,7 +49,52 @@ export default [
url: 'https://noshow.com',
time_on_page: 10,
},
inbox: {
id: 3,
channel_type: 'Channel::WebWidget',
name: 'Web Widget 2',
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
{
id: 4,
title: 'WhatsApp Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Hello {{name}}, your order is ready!',
enabled: true,
trigger_rules: {},
inbox: {
id: 4,
channel_type: 'Channel::Whatsapp',
name: 'WhatsApp Business',
},
template_params: {
name: 'order_ready',
namespace: 'business_namespace',
language: 'en_US',
processed_params: { name: 'John' },
},
created_at: '2021-05-03T12:15:35.828Z',
updated_at: '2021-05-03T12:15:35.828Z',
},
{
id: 5,
title: 'SMS Promotion',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Get 20% off your next order!',
enabled: true,
trigger_rules: {},
inbox: {
id: 5,
channel_type: 'Channel::Sms',
name: 'SMS Channel',
},
created_at: '2021-05-03T14:15:35.828Z',
updated_at: '2021-05-03T14:15:35.828Z',
},
];
@@ -13,20 +13,58 @@ describe('#getters', () => {
it('get one_off campaigns', () => {
const state = { records: campaigns };
expect(getters.getCampaigns(state)('one_off')).toEqual([
{
id: 2,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
campaigns[1],
campaigns[3],
campaigns[4],
]);
});
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
it('get campaigns by channel type', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
).toEqual([campaigns[3]]);
});
it('get campaigns by multiple channel types', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', [
'Channel::TwilioSms',
'Channel::Sms',
])
).toEqual([campaigns[1], campaigns[4]]);
});
it('get SMS campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([
campaigns[1],
campaigns[4],
]);
});
it('get WhatsApp campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([
campaigns[3],
]);
});
it('get Live Chat campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([
campaigns[0],
campaigns[2],
]);
});
@@ -1,5 +1,5 @@
class Conversations::UpdateMessageStatusJob < ApplicationJob
queue_as :low
queue_as :deferred
# This job only support marking messages as read or delivered, update this array if we want to support more statuses
VALID_STATUSES = %w[read delivered].freeze
+2 -1
View File
@@ -41,7 +41,8 @@ class AutomationRule < ApplicationRecord
def actions_attributes
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript].freeze
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
add_private_note].freeze
end
def file_base_data
+15 -4
View File
@@ -10,6 +10,7 @@
# enabled :boolean default(TRUE)
# message :text not null
# scheduled_at :datetime
# template_params :jsonb
# title :string not null
# trigger_only_during_business_hours :boolean default(FALSE)
# trigger_rules :jsonb
@@ -57,12 +58,22 @@ class Campaign < ApplicationRecord
return unless one_off?
return if completed?
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
execute_campaign
end
private
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
Twilio::OneoffSmsCampaignService.new(campaign: self).perform
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
end
end
def set_display_id
reload
end
@@ -70,14 +81,14 @@ class Campaign < ApplicationRecord
def validate_campaign_inbox
return unless inbox
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type
end
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
def ensure_correct_campaign_attributes
return if inbox.blank?
if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type)
if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type)
self.campaign_type = 'one_off'
self.scheduled_at ||= Time.now.utc
else
@@ -47,6 +47,13 @@ class AutomationRules::ActionService < ActionService
Messages::MessageBuilder.new(nil, @conversation, params).perform
end
def add_private_note(message)
return if conversation_a_tweet?
params = { content: message[0], private: true, content_attributes: { automation_rule_id: @rule.id } }
Messages::MessageBuilder.new(nil, @conversation.reload, params).perform
end
def send_email_to_team(params)
teams = Team.where(id: params[0][:team_ids])
@@ -0,0 +1,94 @@
class Whatsapp::OneoffCampaignService
pattr_initialize [:campaign!]
def perform
validate_campaign!
process_audience(extract_audience_labels)
campaign.completed!
end
private
delegate :inbox, to: :campaign
delegate :channel, to: :inbox
def validate_campaign_type!
raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off?
end
def whatsapp_campaign?
campaign.inbox.inbox_type == 'Whatsapp'
end
def validate_campaign_status!
raise 'Completed Campaign' if campaign.completed?
end
def validate_provider!
raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud'
end
def validate_feature_flag!
raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign)
end
def validate_campaign!
validate_campaign_type!
validate_campaign_status!
validate_provider!
validate_feature_flag!
end
def extract_audience_labels
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
campaign.account.labels.where(id: audience_label_ids).pluck(:title)
end
def process_contact(contact)
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
if contact.phone_number.blank?
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
return
end
if campaign.template_params.blank?
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
return
end
send_whatsapp_template_message(to: contact.phone_number)
end
def process_audience(audience_labels)
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
contacts.each { |contact| process_contact(contact) }
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
)
name, namespace, lang_code, processed_parameters = processor.call
return if name.blank?
channel.send_template(to, {
name: name,
namespace: namespace,
lang_code: lang_code,
parameters: processed_parameters
})
rescue StandardError => e
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
raise e
end
end
@@ -15,7 +15,13 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def send_template_message
name, namespace, lang_code, processed_parameters = processable_channel_message_template
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: template_params,
message: message
)
name, namespace, lang_code, processed_parameters = processor.call
return if name.blank?
@@ -28,86 +34,6 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
message.update!(source_id: message_id) if message_id.present?
end
def processable_channel_message_template
if template_params.present?
return [
template_params['name'],
template_params['namespace'],
template_params['language'],
processed_templates_params(template_params)
]
end
# Delete the following logic once the update for template_params is stable
# see if we can match the message content to a template
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
# no need to look up further end the search
return [template['name'], template['namespace'], template['language'], processed_parameters]
end
[nil, nil, nil, nil]
end
def template_match_object(template)
body_object = validated_body_object(template)
return if body_object.blank?
template_match_regex = build_template_match_regex(body_object['text'])
message.outgoing_content.match(template_match_regex)
end
def build_template_match_regex(template_text)
# Converts the whatsapp template to a comparable regex string to check against the message content
# the variables are of the format {{num}} ex:{{1}}
# transform the template text into a regex string
# we need to replace the {{num}} with matchers that can be used to capture the variables
template_text = template_text.gsub(/{{\d}}/, '(.*)')
# escape if there are regex characters in the template text
template_text = Regexp.escape(template_text)
# ensuring only the variables remain as capture groups
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
template_match_string = "^#{template_text}$"
Regexp.new template_match_string
end
def template(template_params)
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language']
end
end
def processed_templates_params(template_params)
template = template(template_params)
return if template.blank?
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
else
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
end
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
# we only care about text body object in template. if not present we discard the template
# we don't support other forms of templates
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
end
def send_session_message
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
message.update!(source_id: message_id) if message_id.present?
@@ -0,0 +1,95 @@
class Whatsapp::TemplateProcessorService
pattr_initialize [:channel!, :template_params, :message]
def call
if template_params.present?
process_template_with_params
else
process_template_from_message
end
end
private
def process_template_with_params
[
template_params['name'],
template_params['namespace'],
template_params['language'],
processed_templates_params
]
end
def process_template_from_message
return [nil, nil, nil, nil] if message.blank?
# Delete the following logic once the update for template_params is stable
# see if we can match the message content to a template
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
# Then we use regex to parse the template varibles and convert them into the proper payload
channel.message_templates&.each do |template|
match_obj = template_match_object(template)
next if match_obj.blank?
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
# no need to look up further end the search
return [template['name'], template['namespace'], template['language'], processed_parameters]
end
[nil, nil, nil, nil]
end
def template_match_object(template)
body_object = validated_body_object(template)
return if body_object.blank?
template_match_regex = build_template_match_regex(body_object['text'])
message.outgoing_content.match(template_match_regex)
end
def build_template_match_regex(template_text)
# Converts the whatsapp template to a comparable regex string to check against the message content
# the variables are of the format {{num}} ex:{{1}}
# transform the template text into a regex string
# we need to replace the {{num}} with matchers that can be used to capture the variables
template_text = template_text.gsub(/{{\d}}/, '(.*)')
# escape if there are regex characters in the template text
template_text = Regexp.escape(template_text)
# ensuring only the variables remain as capture groups
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
template_match_string = "^#{template_text}$"
Regexp.new template_match_string
end
def find_template
channel.message_templates.find do |t|
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
end
end
def processed_templates_params
template = find_template
return if template.blank?
parameter_format = template['parameter_format']
if parameter_format == 'NAMED'
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
else
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
end
end
def validated_body_object(template)
# we don't care if its not approved template
return if template['status'] != 'approved'
# we only care about text body object in template. if not present we discard the template
# we don't support other forms of templates
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
end
end
@@ -9,6 +9,7 @@ json.sender do
json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present?
end
json.message resource.message
json.template_params resource.template_params
json.campaign_status resource.campaign_status
json.enabled resource.enabled
json.campaign_type resource.campaign_type
@@ -2,6 +2,12 @@
<symbol id="icon-microsoft" viewBox="0 0 24 24">
<path fill="currentColor" d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"/>
</symbol>
<symbol id="icon-google" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</symbol>
<symbol id="icon-cancel" viewBox="0 0 48 48">
<path fill-rule="evenodd" d="M24 19.757l-8.485-8.485c-.784-.783-2.047-.782-2.827 0l-1.417 1.416c-.777.777-.78 2.046.002 2.827L19.757 24l-8.485 8.485c-.783.784-.782 2.047 0 2.827l1.416 1.417c.777.777 2.046.78 2.827-.002L24 28.243l8.485 8.485c.784.783 2.047.782 2.827 0l1.417-1.416c.777-.777.78-2.046-.002-2.827L28.243 24l8.485-8.485c.783-.784.782-2.047 0-2.827l-1.416-1.417c-.777-.777-2.046-.78-2.827.002L24 19.757zM24 47c12.703 0 23-10.297 23-23S36.703 1 24 1 1 11.297 1 24s10.297 23 23 23z" />
</symbol>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

@@ -1,6 +1,4 @@
<li
class="px-4 border-l-4 mb-1 <%= current_page?(url) ? 'border-woot-500' : 'border-transparent' %>"
>
<li class="px-4 mb-1">
<% text_class_name = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to(url, class: text_class_name + " -ml-1 focus:outline-none cursor-pointer flex items-center px-2 py-1.5 text-slate-800 cursor-pointer hover:text-woot-500 hover:bg-slate-25 rounded-lg") do %>
<svg width="16" height="16"><use xlink:href="#<%= icon %>" /></svg>
@@ -39,14 +39,13 @@ as defined by the routes in the `admin/` namespace
label: display_resource_name(resource),
}
%>
<% end %>
<%= render 'settings_menu', open: settings_open? %>
</ul>
</div>
<div>
<ul class="my-4">
<% if ChatwootApp.enterprise? %>
<%= render partial: "nav_item", locals: { icon: 'icon-settings-2-line', url: super_admin_settings_url, label: 'Settings' } %>
<% end %>
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
@@ -0,0 +1,24 @@
<li class="px-4 mb-1">
<details class="group" <%= 'open' if open %>>
<summary class="-ml-1 flex items-center px-2 py-1.5 cursor-pointer rounded-lg <%= open ? 'text-woot-500 bg-slate-25' : 'text-slate-800 hover:text-woot-500 hover:bg-slate-25' %> list-none">
<%= link_to super_admin_settings_url, class: 'flex items-center flex-1' do %>
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="ml-2 text-sm">Settings</span>
<% end %>
<svg class="ml-auto w-4 h-4 transition-transform duration-200 group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</summary>
<ul class="ml-4 mt-1">
<% settings_pages.each do |_feature_key, attrs| %>
<% url = super_admin_app_config_url(config: attrs['config_key']) %>
<li class="px-4 mb-1">
<% text_class = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to url, class: text_class + ' -ml-1 flex items-center px-2 py-1.5 hover:text-woot-500 hover:bg-slate-25 rounded-lg' do %>
<span class="ml-2 text-sm"><%= attrs['name'] %></span>
<% end %>
</li>
<% end %>
</ul>
</details>
</li>
@@ -0,0 +1,4 @@
<span class="flex gap-1 items-center bg-slate-100 h-9 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Switch to Enterprise edition</span>
</span>
@@ -0,0 +1,4 @@
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
+23 -20
View File
@@ -39,24 +39,26 @@
</button>
</div>
</div>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
<% if ChatwootApp.enterprise? %>
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
<div class="flex flex-col flex-grow gap-1">
<div class="flex items-center gap-2">
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
<span>Refresh</span>
</a>
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
</div>
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
</a>
</div>
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
<span class="px-1">Manage</span>
</button>
</a>
</div>
<% end %>
<% if ChatwootHub.pricing_plan != 'community' && User.count > ChatwootHub.pricing_plan_quantity %>
<div role="alert">
@@ -99,10 +101,11 @@
</div>
<% if !attrs[:enabled] %>
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
<span class="px-1">Upgrade now</span>
</a>
<% if ChatwootApp.enterprise? %>
<%= render 'upgrade_button_enterprise' %>
<% else %>
<%= render 'upgrade_button_community' %>
<% end %>
</div>
<% end %>
<div class="flex items-center justify-between mb-1.5 mt-4">
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.3.0'
version: '4.4.0'
development:
<<: *shared
-1
View File
@@ -43,7 +43,6 @@ module Chatwoot
config.eager_load_paths << Rails.root.join('enterprise/listeners')
# rubocop:disable Rails/FilePath
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
config.eager_load_paths << Rails.root.join('enterprise/app/models/concerns')
# rubocop:enable Rails/FilePath
# Add enterprise views to the view paths
config.paths['app/views'].unshift('enterprise/app/views')
+4
View File
@@ -180,6 +180,10 @@
display_name: Captain V2
enabled: false
premium: true
chatwoot_internal: true
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
- name: whatsapp_campaign
display_name: WhatsApp Campaign
enabled: false
+22
View File
@@ -87,11 +87,14 @@
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
display_title: 'Inbound Email Domain'
value:
description: 'The domain name to be used for generating conversation continuity emails (reply+id@domain.com)'
locked: false
- name: MAILER_SUPPORT_EMAIL
display_title: 'Support Email'
value:
description: 'The support email address for your installation'
locked: false
# ------- End of Email Related Config ------- #
@@ -394,3 +397,22 @@
locked: false
type: secret
# ------- End of OG Image Related Config ------- #
## ------ Configs added for Google OAuth ------ ##
- name: GOOGLE_OAUTH_CLIENT_ID
display_title: 'Google OAuth Client ID'
value:
locked: false
description: 'Google OAuth Client ID for email authentication'
- name: GOOGLE_OAUTH_CLIENT_SECRET
display_title: 'Google OAuth Client Secret'
value:
locked: false
description: 'Google OAuth Client Secret for email authentication'
type: secret
- name: GOOGLE_OAUTH_REDIRECT_URI
display_title: 'Google OAuth Redirect URI'
value:
locked: false
description: 'The redirect URI configured in your Google OAuth app'
## ------ End of Configs added for Google OAuth ------ ##
+1
View File
@@ -23,6 +23,7 @@
- action_mailbox_routing
- low
- scheduled_jobs
- deferred
- housekeeping
- async_database_migration
- active_storage_analysis
@@ -0,0 +1,5 @@
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
def change
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
end
end
+1
View File
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.jsonb "audience", default: []
t.datetime "scheduled_at", precision: nil
t.boolean "trigger_only_during_business_hours", default: false
t.jsonb "template_params"
t.index ["account_id"], name: "index_campaigns_on_account_id"
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
+3 -3
View File
@@ -80,11 +80,11 @@ class Captain::Scenario < ApplicationRecord
# @return [void]
# @api private
# @example Valid instruction
# scenario.instruction = "Use (tool://add_contact_note) to document"
# scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
# scenario.valid? # => true
#
# @example Invalid instruction
# scenario.instruction = "Use (tool://invalid_tool) to process"
# scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
# scenario.valid? # => false
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
def validate_instruction_tools
@@ -108,7 +108,7 @@ class Captain::Scenario < ApplicationRecord
# @return [void]
# @api private
# @example
# scenario.instruction = "First (tool://add_private_note) then (tool://update_priority)"
# scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
# scenario.save!
# scenario.tools # => ["add_private_note", "update_priority"]
#
@@ -1,11 +1,11 @@
# Provides helper methods for working with Captain agent tools including
# tool resolution, text parsing, and metadata retrieval.
module CaptainToolsHelpers
module Concerns::CaptainToolsHelpers
extend ActiveSupport::Concern
# Regular expression pattern for matching tool references in text.
# Matches patterns like (tool://tool_id) following Chatwoot's mention syntax.
TOOL_REFERENCE_REGEX = %r{\(tool://([^/)]+)\)}
# Matches patterns like [Tool name](tool://tool_id) following markdown link syntax.
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
class_methods do
# Returns all available agent tools with their metadata.
@@ -6,6 +6,8 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return 'Note content is required' if note.blank?
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
create_private_note(conversation, note)
@@ -24,14 +24,14 @@ class Captain::Tools::BasePublicTool < Agents::Tool
end
def find_conversation(state)
conversation_id = state[:conversation][:id]
conversation_id = state&.dig(:conversation, :id)
return nil unless conversation_id
account_scoped(::Conversation).find_by(id: conversation_id)
end
def find_contact(state)
contact_id = state[:contact][:id]
contact_id = state&.dig(:contact, :id)
return nil unless contact_id
account_scoped(::Contact).find_by(id: contact_id)
+4
View File
@@ -19,10 +19,14 @@ class ChatwootHub
end
def self.pricing_plan
return 'community' unless ChatwootApp.enterprise?
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
end
def self.pricing_plan_quantity
return 0 unless ChatwootApp.enterprise?
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
end
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.3.0",
"version": "4.4.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddContactNoteTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:tool_context) { Struct.new(:state).new({ contact: { id: contact.id } }) }
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Add a note to a contact profile')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:note)
expect(tool.parameters[:note].name).to eq(:note)
expect(tool.parameters[:note].type).to eq('string')
expect(tool.parameters[:note].description).to eq('The note content to add to the contact')
end
end
describe '#perform' do
context 'when contact exists' do
context 'with valid note content' do
it 'creates a contact note and returns success message' do
note_content = 'This is a contact note'
expect do
result = tool.perform(tool_context, note: note_content)
expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})")
end.to change(Note, :count).by(1)
created_note = Note.last
expect(created_note.content).to eq(note_content)
expect(created_note.account).to eq(account)
expect(created_note.contact).to eq(contact)
expect(created_note.user).to eq(assistant.account.users.first)
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'add_contact_note',
{ contact_id: contact.id, note_length: 19 }
)
tool.perform(tool_context, note: 'This is a test note')
end
end
context 'with blank note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: '')
expect(result).to eq('Note content is required')
end
it 'does not create a note' do
expect do
tool.perform(tool_context, note: '')
end.not_to change(Note, :count)
end
end
context 'with nil note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: nil)
expect(result).to eq('Note content is required')
end
end
end
context 'when contact does not exist' do
let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
it 'does not create a note' do
expect do
tool.perform(tool_context, note: 'Some note')
end.not_to change(Note, :count)
end
end
context 'when contact state is missing' do
let(:tool_context) { Struct.new(:state).new({}) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
end
context 'when contact id is nil' do
let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
end
end
describe '#active?' do
it 'returns true for public tools' do
expect(tool.active?).to be true
end
end
end
@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddLabelToConversationTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:label) { create(:label, account: account, title: 'urgent') }
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Add a label to a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:label_name)
expect(tool.parameters[:label_name].name).to eq(:label_name)
expect(tool.parameters[:label_name].type).to eq('string')
expect(tool.parameters[:label_name].description).to eq('The name of the label to add')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid label that exists' do
before { label }
it 'adds label to conversation and returns success message' do
result = tool.perform(tool_context, label_name: 'urgent')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
expect(conversation.reload.label_list).to include('urgent')
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'added_label',
{ conversation_id: conversation.id, label: 'urgent' }
)
tool.perform(tool_context, label_name: 'urgent')
end
it 'handles case insensitive label names' do
result = tool.perform(tool_context, label_name: 'URGENT')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
end
it 'strips whitespace from label names' do
result = tool.perform(tool_context, label_name: ' urgent ')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
end
end
context 'with label that does not exist' do
it 'returns error message' do
result = tool.perform(tool_context, label_name: 'nonexistent')
expect(result).to eq('Label not found')
end
it 'does not add any labels to conversation' do
expect do
tool.perform(tool_context, label_name: 'nonexistent')
end.not_to(change { conversation.reload.labels.count })
end
end
context 'with blank label name' do
it 'returns error message for empty string' do
result = tool.perform(tool_context, label_name: '')
expect(result).to eq('Label name is required')
end
it 'returns error message for nil' do
result = tool.perform(tool_context, label_name: nil)
expect(result).to eq('Label name is required')
end
it 'returns error message for whitespace only' do
result = tool.perform(tool_context, label_name: ' ')
expect(result).to eq('Label name is required')
end
end
end
context 'when conversation does not exist' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
it 'returns error message' do
result = tool.perform(tool_context, label_name: 'urgent')
expect(result).to eq('Conversation not found')
end
end
context 'when conversation state is missing' do
let(:tool_context) { Struct.new(:state).new({}) }
it 'returns error message' do
result = tool.perform(tool_context, label_name: 'urgent')
expect(result).to eq('Conversation not found')
end
end
context 'when conversation id is nil' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
it 'returns error message' do
result = tool.perform(tool_context, label_name: 'urgent')
expect(result).to eq('Conversation not found')
end
end
end
describe '#active?' do
it 'returns true for public tools' do
expect(tool.active?).to be true
end
end
end
@@ -0,0 +1,124 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Add a private note to a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:note)
expect(tool.parameters[:note].name).to eq(:note)
expect(tool.parameters[:note].type).to eq('string')
expect(tool.parameters[:note].description).to eq('The private note content')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid note content' do
it 'creates a private note and returns success message' do
note_content = 'This is a private note'
expect do
result = tool.perform(tool_context, note: note_content)
expect(result).to eq('Private note added successfully')
end.to change(Message, :count).by(1)
end
it 'creates a private note with correct attributes' do
note_content = 'This is a private note'
tool.perform(tool_context, note: note_content)
created_message = Message.last
expect(created_message.content).to eq(note_content)
expect(created_message.message_type).to eq('outgoing')
expect(created_message.private).to be true
expect(created_message.account).to eq(account)
expect(created_message.inbox).to eq(inbox)
expect(created_message.conversation).to eq(conversation)
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'add_private_note',
{ conversation_id: conversation.id, note_length: 19 }
)
tool.perform(tool_context, note: 'This is a test note')
end
end
context 'with blank note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: '')
expect(result).to eq('Note content is required')
end
it 'does not create a message' do
expect do
tool.perform(tool_context, note: '')
end.not_to change(Message, :count)
end
end
context 'with nil note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: nil)
expect(result).to eq('Note content is required')
end
end
end
context 'when conversation does not exist' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Conversation not found')
end
it 'does not create a message' do
expect do
tool.perform(tool_context, note: 'Some note')
end.not_to change(Message, :count)
end
end
context 'when conversation state is missing' do
let(:tool_context) { Struct.new(:state).new({}) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Conversation not found')
end
end
context 'when conversation id is nil' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Conversation not found')
end
end
end
describe '#active?' do
it 'returns true for public tools' do
expect(tool.active?).to be true
end
end
end
@@ -0,0 +1,117 @@
require 'rails_helper'
RSpec.describe Captain::Tools::UpdatePriorityTool, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:tool) { described_class.new(assistant) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Update the priority of a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:priority)
expect(tool.parameters[:priority].name).to eq(:priority)
expect(tool.parameters[:priority].type).to eq('string')
expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid priority levels' do
%w[low medium high urgent].each do |priority|
it "updates conversation priority to #{priority}" do
result = tool.perform(tool_context, priority: priority)
expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to eq(priority)
end
end
it 'removes priority when set to nil' do
conversation.update!(priority: 'high')
result = tool.perform(tool_context, priority: 'nil')
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to be_nil
end
it 'removes priority when set to empty string' do
conversation.update!(priority: 'high')
result = tool.perform(tool_context, priority: '')
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to be_nil
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'update_priority',
{ conversation_id: conversation.id, priority: 'high' }
)
tool.perform(tool_context, priority: 'high')
end
end
context 'with invalid priority levels' do
it 'returns error message for invalid priority' do
result = tool.perform(tool_context, priority: 'invalid')
expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil')
end
it 'does not update conversation priority' do
original_priority = conversation.priority
tool.perform(tool_context, priority: 'invalid')
expect(conversation.reload.priority).to eq(original_priority)
end
end
end
context 'when conversation does not exist' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
it 'returns error message' do
result = tool.perform(tool_context, priority: 'high')
expect(result).to eq('Conversation not found')
end
end
context 'when conversation state is missing' do
let(:tool_context) { Struct.new(:state).new({}) }
it 'returns error message' do
result = tool.perform(tool_context, priority: 'high')
expect(result).to eq('Conversation not found')
end
end
context 'when conversation id is nil' do
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
it 'returns error message' do
result = tool.perform(tool_context, priority: 'high')
expect(result).to eq('Conversation not found')
end
end
end
describe '#active?' do
it 'returns true for public tools' do
expect(tool.active?).to be true
end
end
end
@@ -1,10 +1,10 @@
require 'rails_helper'
RSpec.describe CaptainToolsHelpers, type: :concern do
RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
# Create a test class that includes the concern
let(:test_class) do
Class.new do
include CaptainToolsHelpers
include Concerns::CaptainToolsHelpers
def self.name
'TestClass'
@@ -16,8 +16,8 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
describe 'TOOL_REFERENCE_REGEX' do
it 'matches tool references in text' do
text = 'Use (tool://add_contact_note) and (tool://update_priority)'
matches = text.scan(CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)'
matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches.flatten).to eq(%w[add_contact_note update_priority])
end
@@ -28,11 +28,15 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
'tool://invalid',
'(tool:invalid)',
'(tool://)',
'(tool://with/slash)'
'(tool://with/slash)',
'(tool://add_contact_note)',
'[@Tool](tool://)',
'[Tool](tool://with/slash)',
'[](tool://valid)'
]
invalid_formats.each do |format|
matches = format.scan(CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches).to be_empty, "Should not match: #{format}"
end
end
@@ -136,14 +140,14 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First (tool://add_contact_note) then (tool://update_priority)'
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(%w[add_contact_note update_priority])
end
it 'returns unique tool IDs' do
text = 'Use (tool://add_contact_note) and (tool://add_contact_note) again'
text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(['add_contact_note'])
@@ -164,9 +168,9 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
it 'handles complex text with multiple tools' do
text = <<~TEXT
Start with (tool://add_contact_note) to document.
Then use (tool://update_priority) if needed.
Finally (tool://add_private_note) for internal notes.
Start with [@Add Contact Note](tool://add_contact_note) to document.
Then use [@Update Priority](tool://update_priority) if needed.
Finally [@Add Private Note](tool://add_private_note) for internal notes.
TEXT
result = test_instance.extract_tool_ids_from_text(text)
+17
View File
@@ -12,5 +12,22 @@ FactoryBot.define do
channel: create(:channel_widget, account: campaign.account)
)
end
trait :whatsapp do
after(:build) do |campaign|
campaign.inbox = create(
:inbox,
account: campaign.account,
channel: create(:channel_whatsapp, account: campaign.account)
)
campaign.template_params = {
'name' => 'ticket_status_updated',
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
}
end
end
end
end
@@ -36,6 +36,7 @@ FactoryBot.define do
'status' => 'APPROVED',
'category' => 'UTILITY',
'language' => 'en',
'namespace' => '23423423_2342423_324234234_2343224',
'components' => [
{ 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.",
'type' => 'BODY',
@@ -10,7 +10,7 @@ RSpec.describe Conversations::UpdateMessageStatusJob do
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(conversation.id, conversation.contact_last_seen_at, :read)
.on_queue('low')
.on_queue('deferred')
end
context 'when called' do
@@ -117,5 +117,27 @@ RSpec.describe AutomationRules::ActionService do
expect(mailer).to have_received(:conversation_transcript).exactly(1).times
end
end
describe '#perform with add_private_note action' do
let(:message_builder) { double }
before do
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
rule.actions.delete_if { |a| a['action_name'] == 'send_message' }
rule.actions << { action_name: 'add_private_note', action_params: ['Note'] }
end
it 'will add private note' do
expect(message_builder).to receive(:perform)
described_class.new(rule, account, conversation).perform
end
it 'will not add note if conversation is a tweet' do
twitter_inbox = create(:inbox, channel: create(:channel_twitter_profile, account: account))
conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
expect(message_builder).not_to receive(:perform)
described_class.new(rule, account, conversation).perform
end
end
end
end
@@ -0,0 +1,169 @@
require 'rails_helper'
describe Whatsapp::OneoffCampaignService do
let(:account) { create(:account) }
let!(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
end
let!(:whatsapp_inbox) { whatsapp_channel.inbox }
let(:label1) { create(:label, account: account) }
let(:label2) { create(:label, account: account) }
let!(:campaign) do
create(:campaign, inbox: whatsapp_inbox, account: account,
audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }],
template_params: template_params)
end
let(:template_params) do
{
'name' => 'ticket_status_updated',
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
}
end
before do
# Stub HTTP requests to WhatsApp API
stub_request(:post, /graph\.facebook\.com.*messages/)
.to_return(status: 200, body: { messages: [{ id: 'message_id_123' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
# Ensure the service uses our mocked channel object by stubbing the whole delegation chain
# Using allow_any_instance_of here because the service is instantiated within individual tests
# and we need to mock the delegated channel method for proper test isolation
allow_any_instance_of(described_class).to receive(:channel).and_return(whatsapp_channel) # rubocop:disable RSpec/AnyInstance
end
describe '#perform' do
before do
# Enable WhatsApp campaigns feature flag for all tests
account.enable_features!(:whatsapp_campaign)
end
context 'when campaign validation fails' do
it 'raises error if campaign is completed' do
campaign.completed!
expect { described_class.new(campaign: campaign).perform }.to raise_error 'Completed Campaign'
end
it 'raises error when campaign is not a WhatsApp campaign' do
sms_channel = create(:channel_sms, account: account)
sms_inbox = create(:inbox, channel: sms_channel, account: account)
invalid_campaign = create(:campaign, inbox: sms_inbox, account: account)
expect { described_class.new(campaign: invalid_campaign).perform }
.to raise_error "Invalid campaign #{invalid_campaign.id}"
end
it 'raises error when campaign is not oneoff' do
allow(campaign).to receive(:one_off?).and_return(false)
expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
end
it 'raises error when channel provider is not whatsapp_cloud' do
whatsapp_channel.update!(provider: 'default')
expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp Cloud provider required'
end
it 'raises error when WhatsApp campaigns feature is not enabled' do
account.disable_features!(:whatsapp_campaign)
expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp campaigns feature not enabled'
end
end
context 'when campaign is valid' do
it 'marks campaign as completed' do
described_class.new(campaign: campaign).perform
expect(campaign.reload.completed?).to be true
end
it 'processes contacts with matching labels' do
contact_with_label1, contact_with_label2, contact_with_both_labels =
create_list(:contact, 3, :with_phone_number, account: account)
contact_with_label1.update_labels([label1.title])
contact_with_label2.update_labels([label2.title])
contact_with_both_labels.update_labels([label1.title, label2.title])
expect(whatsapp_channel).to receive(:send_template).exactly(3).times
described_class.new(campaign: campaign).perform
end
it 'skips contacts without phone numbers' do
contact_without_phone = create(:contact, account: account, phone_number: nil)
contact_without_phone.update_labels([label1.title])
expect(whatsapp_channel).not_to receive(:send_template)
described_class.new(campaign: campaign).perform
end
it 'uses template processor service to process templates' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(Whatsapp::TemplateProcessorService).to receive(:new)
.with(channel: whatsapp_channel, template_params: template_params)
.and_call_original
described_class.new(campaign: campaign).perform
end
it 'sends template message with correct parameters' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(whatsapp_channel).to receive(:send_template).with(
contact.phone_number,
hash_including(
name: 'ticket_status_updated',
namespace: '23423423_2342423_324234234_2343224',
lang_code: 'en',
parameters: array_including(
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
)
)
)
described_class.new(campaign: campaign).perform
end
end
context 'when template_params is missing' do
let(:template_params) { nil }
it 'skips contacts and logs error' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(Rails.logger).to receive(:error)
.with("Skipping contact #{contact.name} - no template_params found for WhatsApp campaign")
expect(whatsapp_channel).not_to receive(:send_template)
described_class.new(campaign: campaign).perform
end
end
context 'when send_template raises an error' do
it 'logs error and re-raises' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
error_message = 'WhatsApp API error'
allow(whatsapp_channel).to receive(:send_template).and_raise(StandardError, error_message)
expect(Rails.logger).to receive(:error)
.with("Failed to send WhatsApp template message to #{contact.phone_number}: #{error_message}")
expect(Rails.logger).to receive(:error).with(/Backtrace:/)
expect { described_class.new(campaign: campaign).perform }.to raise_error(StandardError, error_message)
end
end
end
end