diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index b1a132246..2a1650e53 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -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 diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 3972d5a28..5cf158b98 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -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]) diff --git a/app/controllers/super_admin/application_controller.rb b/app/controllers/super_admin/application_controller.rb index 775fb34fc..5b04fbb44 100644 --- a/app/controllers/super_admin/application_controller.rb +++ b/app/controllers/super_admin/application_controller.rb @@ -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! diff --git a/enterprise/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml similarity index 90% rename from enterprise/app/helpers/super_admin/features.yml rename to app/helpers/super_admin/features.yml index f2b2b263c..f49004e79 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -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' diff --git a/enterprise/app/helpers/super_admin/features_helper.rb b/app/helpers/super_admin/features_helper.rb similarity index 78% rename from enterprise/app/helpers/super_admin/features_helper.rb rename to app/helpers/super_admin/features_helper.rb index 2fbcd1715..475ad6d25 100644 --- a/enterprise/app/helpers/super_admin/features_helper.rb +++ b/app/helpers/super_admin/features_helper.rb @@ -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 diff --git a/app/helpers/super_admin/navigation_helper.rb b/app/helpers/super_admin/navigation_helper.rb new file mode 100644 index 000000000..5fca3fa76 --- /dev/null +++ b/app/helpers/super_admin/navigation_helper.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue new file mode 100644 index 000000000..ab01acb4a --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue new file mode 100644 index 000000000..12a789fee --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue new file mode 100644 index 000000000..df76ae901 --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 171f4a4d8..12f15ce4b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -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'), + }, ], }, { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 78109e82c..28b6b09b7 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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', diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index cb030332f..cf63de81c 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -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" diff --git a/app/javascript/dashboard/i18n/locale/en/campaign.json b/app/javascript/dashboard/i18n/locale/en/campaign.json index e2418d52e..10366e79e 100644 --- a/app/javascript/dashboard/i18n/locale/en/campaign.json +++ b/app/javascript/dashboard/i18n/locale/en/campaign.json @@ -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.", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 219041d75..fde198c92 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -319,6 +319,7 @@ "CSAT": "CSAT", "LIVE_CHAT": "Live Chat", "SMS": "SMS", + "WHATSAPP": "WhatsApp", "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js index e0c1f3a17..c5f2dbdb1 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js +++ b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js @@ -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, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue index 93d5d1ec6..6cc2bf181 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue @@ -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 => {
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue index a38a818f0..c04726ebe 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue @@ -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 diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue new file mode 100644 index 000000000..96aa21b5a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index 1b0f0599f..dfa6163d8 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -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', diff --git a/app/javascript/dashboard/store/modules/campaigns.js b/app/javascript/dashboard/store/modules/campaigns.js index c5a73d1d3..c558716f0 100644 --- a/app/javascript/dashboard/store/modules/campaigns.js +++ b/app/javascript/dashboard/store/modules/campaigns.js @@ -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; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 68f4780e9..14bd4c2a9 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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 diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js index 08fd398e2..400b43638 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js @@ -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', + }, ]; diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js index 52f14c296..231b83adc 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js @@ -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], ]); }); diff --git a/app/jobs/conversations/update_message_status_job.rb b/app/jobs/conversations/update_message_status_job.rb index 6fcef4361..efe2937b4 100644 --- a/app/jobs/conversations/update_message_status_job.rb +++ b/app/jobs/conversations/update_message_status_job.rb @@ -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 diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index c8f5a180d..6f3f47d9c 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -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 diff --git a/app/models/campaign.rb b/app/models/campaign.rb index f937b1d71..2927181c6 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -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 diff --git a/app/services/automation_rules/action_service.rb b/app/services/automation_rules/action_service.rb index 290e14a9e..e409faba2 100644 --- a/app/services/automation_rules/action_service.rb +++ b/app/services/automation_rules/action_service.rb @@ -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]) diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb new file mode 100644 index 000000000..c2f0080f3 --- /dev/null +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -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 diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 61b78f892..8cecfd41f 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -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? diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb new file mode 100644 index 000000000..2ce9fcf8f --- /dev/null +++ b/app/services/whatsapp/template_processor_service.rb @@ -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 diff --git a/app/views/api/v1/models/_campaign.json.jbuilder b/app/views/api/v1/models/_campaign.json.jbuilder index 8706175df..8757fd0f8 100644 --- a/app/views/api/v1/models/_campaign.json.jbuilder +++ b/app/views/api/v1/models/_campaign.json.jbuilder @@ -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 diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index 6669fe87d..a45c4c1b3 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -2,6 +2,12 @@ + + + + + + diff --git a/app/views/super_admin/application/_nav_item.html.erb b/app/views/super_admin/application/_nav_item.html.erb index a6366d8b5..a32fddd11 100644 --- a/app/views/super_admin/application/_nav_item.html.erb +++ b/app/views/super_admin/application/_nav_item.html.erb @@ -1,6 +1,4 @@ -
  • +
  • <% 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 %> diff --git a/app/views/super_admin/application/_navigation.html.erb b/app/views/super_admin/application/_navigation.html.erb index f673c6bd1..787576d33 100644 --- a/app/views/super_admin/application/_navigation.html.erb +++ b/app/views/super_admin/application/_navigation.html.erb @@ -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? %>
    -
    -
    -
    -

    Current plan

    - - - Refresh - + <% if ChatwootApp.enterprise? %> +
    +
    +
    +

    Current plan

    + + + Refresh + +
    +

    <%= SuperAdmin::FeaturesHelper.plan_details.html_safe %>

    -

    <%= SuperAdmin::FeaturesHelper.plan_details.html_safe %>

    + + +
    - - - -
    + <% end %> <% if ChatwootHub.pricing_plan != 'community' && User.count > ChatwootHub.pricing_plan_quantity %>
    @@ -99,10 +101,11 @@
    <% if !attrs[:enabled] %> <% end %>
    diff --git a/config/app.yml b/config/app.yml index 6ba134f19..e6fc39be3 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.3.0' + version: '4.4.0' development: <<: *shared diff --git a/config/application.rb b/config/application.rb index 96025e9f9..5316e65bf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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') diff --git a/config/features.yml b/config/features.yml index 99d7bea6d..4706c46f0 100644 --- a/config/features.yml +++ b/config/features.yml @@ -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 diff --git a/config/installation_config.yml b/config/installation_config.yml index b17d3cec0..2e8d94a96 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -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 ------ ## diff --git a/config/sidekiq.yml b/config/sidekiq.yml index af813d92a..d2d764382 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -23,6 +23,7 @@ - action_mailbox_routing - low - scheduled_jobs + - deferred - housekeeping - async_database_migration - active_storage_analysis diff --git a/db/migrate/20250709102213_add_template_params_to_campaigns.rb b/db/migrate/20250709102213_add_template_params_to_campaigns.rb new file mode 100644 index 000000000..d70359b30 --- /dev/null +++ b/db/migrate/20250709102213_add_template_params_to_campaigns.rb @@ -0,0 +1,5 @@ +class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1] + def change + add_column :campaigns, :template_params, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 1806ab23f..34637315b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index d008a8852..23d50e400 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -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"] # diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb index 7aa924d3d..5a660310c 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -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. diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb index 4a2328d43..36e1ef977 100644 --- a/enterprise/lib/captain/tools/add_private_note_tool.rb +++ b/enterprise/lib/captain/tools/add_private_note_tool.rb @@ -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) diff --git a/enterprise/lib/captain/tools/base_public_tool.rb b/enterprise/lib/captain/tools/base_public_tool.rb index e53f7a0f1..e1f779b36 100644 --- a/enterprise/lib/captain/tools/base_public_tool.rb +++ b/enterprise/lib/captain/tools/base_public_tool.rb @@ -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) diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb index 0d99becd3..c18fb299b 100644 --- a/lib/chatwoot_hub.rb +++ b/lib/chatwoot_hub.rb @@ -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 diff --git a/package.json b/package.json index 1ca8d1025..e0fd2cf7b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.3.0", + "version": "4.4.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", diff --git a/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb new file mode 100644 index 000000000..c087242dc --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb new file mode 100644 index 000000000..38e4dc7c6 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb new file mode 100644 index 000000000..cfce1a7d1 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb new file mode 100644 index 000000000..9aa858593 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb @@ -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 diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb index 1c441bc61..afe482385 100644 --- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -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) diff --git a/spec/factories/campaigns.rb b/spec/factories/campaigns.rb index 4d4c6de18..c3c32d149 100644 --- a/spec/factories/campaigns.rb +++ b/spec/factories/campaigns.rb @@ -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 diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index d437ed346..ad2bab241 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -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', diff --git a/spec/jobs/conversations/update_message_status_job_spec.rb b/spec/jobs/conversations/update_message_status_job_spec.rb index ff5c90292..508d2dc1c 100644 --- a/spec/jobs/conversations/update_message_status_job_spec.rb +++ b/spec/jobs/conversations/update_message_status_job_spec.rb @@ -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 diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb index bdfa7af0c..e63fd7545 100644 --- a/spec/services/automation_rules/action_service_spec.rb +++ b/spec/services/automation_rules/action_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb new file mode 100644 index 000000000..33107e8de --- /dev/null +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -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