From 9dd13b9a2be045e2f1ec549745800bc8af5e009b Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:54:52 +0530 Subject: [PATCH 1/7] fix: topup checkout flaky test (#13616) --- .../billing/topup_checkout_service_spec.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb index 8a64e6fd2..24c7889de 100644 --- a/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/topup_checkout_service_spec.rb @@ -44,17 +44,19 @@ describe Enterprise::Billing::TopupCheckoutService do end it 'raises error for invalid credits' do - expect do - service.create_checkout_session(credits: 500) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + expect { service.create_checkout_session(credits: 500) }.to raise_error do |error| + expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') + expect(error.message).to eq(I18n.t('errors.topup.invalid_option')) + end end it 'raises error when account is on free plan' do account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id }) - expect do - service.create_checkout_session(credits: 1000) - end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error) + expect { service.create_checkout_session(credits: 1000) }.to raise_error do |error| + expect(error.class.name).to eq('Enterprise::Billing::TopupCheckoutService::Error') + expect(error.message).to eq(I18n.t('errors.topup.plan_not_eligible')) + end end end end From 957a1b17c9178ac8b838dec0e8384a405572c07b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:08:11 +0530 Subject: [PATCH 2/7] perf: add default configs for assignment V2 (#13577) ## Description AutoAssignment::RateLimiter#within_limit? returned true early for inboxes without an AssignmentPolicy, bypassing fair distribution entirely and allowing unlimited conversation assignment. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## Script - Script to enable users with V2 assignment: https://www.notion.so/chatwoot/Script-to-migrate-account-to-assignment-V2-30ca5f274c9280f5b8ecfd15e28eeb9c?source=copy_link ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Shivam Mishra --- app/services/auto_assignment/rate_limiter.rb | 12 ++---------- spec/services/auto_assignment/rate_limiter_spec.rb | 6 +++--- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/app/services/auto_assignment/rate_limiter.rb b/app/services/auto_assignment/rate_limiter.rb index da8daac9c..b46148526 100644 --- a/app/services/auto_assignment/rate_limiter.rb +++ b/app/services/auto_assignment/rate_limiter.rb @@ -2,8 +2,6 @@ class AutoAssignment::RateLimiter pattr_initialize [:inbox!, :agent!] def within_limit? - return true unless enabled? - current_count < limit end @@ -13,24 +11,18 @@ class AutoAssignment::RateLimiter end def current_count - return 0 unless enabled? - pattern = assignment_key_pattern Redis::Alfred.keys_count(pattern) end private - def enabled? - config.present? && limit.positive? - end - def limit - config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : Float::INFINITY + config&.fair_distribution_limit.present? ? config.fair_distribution_limit.to_i : 5 end def window - config&.fair_distribution_window&.to_i || 24.hours.to_i + config&.fair_distribution_window&.to_i || 5.minutes.to_i end def config diff --git a/spec/services/auto_assignment/rate_limiter_spec.rb b/spec/services/auto_assignment/rate_limiter_spec.rb index d5c3de047..59b145c87 100644 --- a/spec/services/auto_assignment/rate_limiter_spec.rb +++ b/spec/services/auto_assignment/rate_limiter_spec.rb @@ -61,7 +61,7 @@ RSpec.describe AutoAssignment::RateLimiter do it 'still tracks the assignment with default window' do expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id) - expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 24.hours.to_i) + expect(Redis::Alfred).to receive(:set).with(expected_key, conversation.id.to_s, ex: 5.minutes.to_i) rate_limiter.track_assignment(conversation) end end @@ -154,12 +154,12 @@ RSpec.describe AutoAssignment::RateLimiter do allow(inbox).to receive(:assignment_policy).and_return(assignment_policy) end - it 'uses the default window value of 24 hours' do + it 'uses the default window value of 5 minutes' do expected_key = format(Redis::RedisKeys::ASSIGNMENT_KEY, inbox_id: inbox.id, agent_id: agent.id, conversation_id: conversation.id) expect(Redis::Alfred).to receive(:set).with( expected_key, conversation.id.to_s, - ex: 86_400 + ex: 5.minutes.to_i ) rate_limiter.track_assignment(conversation) end From 40da358dc2079687d8eff02ca70dcf85dc6da54e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 23 Feb 2026 16:00:17 +0530 Subject: [PATCH 3/7] feat: better errors for SMTP (#13401) Co-authored-by: Muhsin Keloth --- app/helpers/api/v1/inboxes_helper.rb | 38 +++++++++---------- .../dashboard/settings/inbox/SmtpSettings.vue | 4 +- config/locales/en.yml | 5 +++ .../v1/accounts/inboxes_controller_spec.rb | 3 ++ 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index d734a346e..3d6b559c8 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -57,39 +57,35 @@ module Api::V1::InboxesHelper end def check_smtp_connection(channel_data, smtp) + smtp.open_timeout = 10 smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], channel_data[:smtp_authentication]&.to_sym || :login) smtp.finish + rescue Net::SMTPAuthenticationError + raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error') + rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout + raise StandardError, I18n.t('errors.inboxes.smtp.connection_error') + rescue OpenSSL::SSL::SSLError + raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error') + rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError + raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error') + rescue StandardError => e + raise StandardError, e.message end def set_smtp_encryption(channel_data, smtp) if channel_data[:smtp_enable_ssl_tls] - set_enable_tls(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode]) elsif channel_data[:smtp_enable_starttls_auto] - set_enable_starttls_auto(channel_data, smtp) + set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode]) end end - def set_enable_starttls_auto(channel_data, smtp) - return unless smtp.respond_to?(:enable_starttls_auto) + def set_smtp_ssl_method(smtp, method, openssl_verify_mode) + return unless smtp.respond_to?(method) - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_starttls_auto(context) - else - smtp.enable_starttls_auto - end - end - - def set_enable_tls(channel_data, smtp) - return unless smtp.respond_to?(:enable_tls) - - if channel_data[:smtp_openssl_verify_mode] - context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode]) - smtp.enable_tls(context) - else - smtp.enable_tls - end + context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode + context ? smtp.send(method, context) : smtp.send(method) end def enable_openssl_mode(smtp_openssl_verify_mode) diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue index 575e0e5d2..e8bc723c2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/SmtpSettings.vue @@ -147,7 +147,9 @@ export default { await this.$store.dispatch('inboxes/updateInboxSMTP', payload); useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE')); } catch (error) { - useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')); + useAlert( + error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE') + ); } }, }, diff --git a/config/locales/en.yml b/config/locales/en.yml index a058d28c7..05276009f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -108,6 +108,11 @@ en: host_unreachable_error: Host unreachable, Please check the IMAP address, IMAP port and try again. connection_timed_out_error: Connection timed out for %{address}:%{port} connection_closed_error: Connection closed. + smtp: + authentication_error: SMTP authentication failed. Please verify your login credentials. + connection_error: Could not connect to SMTP server. Please check the server address and port. + ssl_error: SSL/TLS error. Please verify your encryption settings. + smtp_error: SMTP server error. Please check your configuration and try again. validations: name: should not start or end with symbols, and it should not have < > / \ @ characters. custom_filters: diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index fafd6788e..b93ca8ecf 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -631,6 +631,7 @@ RSpec.describe 'Inboxes API', type: :request do it 'updates smtp configuration with starttls encryption' do smtp_connection = double + allow(smtp_connection).to receive(:open_timeout=).and_return(10) allow(smtp_connection).to receive(:start).and_return(true) allow(smtp_connection).to receive(:finish).and_return(true) allow(smtp_connection).to receive(:respond_to?).and_return(true) @@ -661,6 +662,7 @@ RSpec.describe 'Inboxes API', type: :request do it 'updates smtp configuration with ssl/tls encryption' do smtp_connection = double + allow(smtp_connection).to receive(:open_timeout=).and_return(10) allow(smtp_connection).to receive(:start).and_return(true) allow(smtp_connection).to receive(:finish).and_return(true) allow(smtp_connection).to receive(:respond_to?).and_return(true) @@ -691,6 +693,7 @@ RSpec.describe 'Inboxes API', type: :request do it 'updates smtp configuration with authentication mechanism' do smtp_connection = double + allow(smtp_connection).to receive(:open_timeout=).and_return(10) allow(smtp_connection).to receive(:start).and_return(true) allow(smtp_connection).to receive(:finish).and_return(true) allow(smtp_connection).to receive(:respond_to?).and_return(true) From b220663785e7f8a2e93b0670ba9885285e14c567 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 23 Feb 2026 15:40:54 +0400 Subject: [PATCH 4/7] fix: Skip notifications for private notes (#13617) When agents or integrations create private notes on a conversation, every note was sending a notification to the assigned agent and all conversation participants. The fix ensures that private notes no longer trigger new message notifications. If someone explicitly mentions a teammate in a private note, that person will still get notified as expected. --- app/models/concerns/message_filter_helpers.rb | 2 +- spec/listeners/notification_listener_spec.rb | 22 ++++++++++++++++--- .../new_message_notification_service_spec.rb | 8 ++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/models/concerns/message_filter_helpers.rb b/app/models/concerns/message_filter_helpers.rb index 38124f347..39ee71089 100644 --- a/app/models/concerns/message_filter_helpers.rb +++ b/app/models/concerns/message_filter_helpers.rb @@ -14,7 +14,7 @@ module MessageFilterHelpers end def notifiable? - incoming? || outgoing? + (incoming? || outgoing?) && !private? end def conversation_transcriptable? diff --git a/spec/listeners/notification_listener_spec.rb b/spec/listeners/notification_listener_spec.rb index 76e458b8e..048339ca5 100644 --- a/spec/listeners/notification_listener_spec.rb +++ b/spec/listeners/notification_listener_spec.rb @@ -142,7 +142,24 @@ describe NotificationListener do expect(first_agent.notifications.first.notification_type).to eq('conversation_mention') end - it 'will not create duplicate new message notifications for assignment & participation' do + it 'will create a mention notification when a user is mentioned in a private note' do + create(:inbox_member, user: first_agent, inbox: inbox) + + message = build( + :message, + conversation: conversation, + account: account, + content: "hey [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})", + private: true + ) + event = Events::Base.new(event_name, Time.zone.now, message: message) + listener.message_created(event) + + expect(first_agent.notifications.count).to eq(1) + expect(first_agent.notifications.first.notification_type).to eq('conversation_mention') + end + + it 'will not create new message notifications for private messages without mentions' do create(:inbox_member, user: first_agent, inbox: inbox) conversation.update(assignee: first_agent) # participants is created by async job. so creating it directly for testcase @@ -160,8 +177,7 @@ describe NotificationListener do listener.message_created(event) expect(conversation.conversation_participants.map(&:user)).to include(first_agent) - expect(first_agent.notifications.count).to eq(1) - expect(first_agent.notifications.first.notification_type).to eq('assigned_conversation_new_message') + expect(first_agent.notifications.count).to eq(0) end end diff --git a/spec/services/messages/new_message_notification_service_spec.rb b/spec/services/messages/new_message_notification_service_spec.rb index 51da1ad5a..c8e2b7b98 100644 --- a/spec/services/messages/new_message_notification_service_spec.rb +++ b/spec/services/messages/new_message_notification_service_spec.rb @@ -2,11 +2,17 @@ require 'rails_helper' describe Messages::NewMessageNotificationService do context 'when message is not notifiable' do - it 'will not create any notifications' do + it 'will not create any notifications for activity messages' do message = build(:message, message_type: :activity) expect(NotificationBuilder).not_to receive(:new) described_class.new(message: message).perform end + + it 'will not create any notifications for private messages' do + message = build(:message, message_type: :outgoing, private: true) + expect(NotificationBuilder).not_to receive(:new) + described_class.new(message: message).perform + end end context 'when message is notifiable' do From 5b167b5b5b9de36ee1afaec3748a43e5ba08537d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 23 Feb 2026 19:26:45 +0400 Subject: [PATCH 5/7] fix(contacts): Show telegram id in contact details form (#13611) ## Summary This change fixes a mismatch in contact details where Telegram data could be shown in the contact profile/social icon area but was not available in the editable contact form. ### What changed - Added Telegram to the social links section of the next-gen contact form so agents can view and edit it alongside Facebook, Instagram, TikTok, Twitter, GitHub, and LinkedIn. - Added Telegram support to the legacy conversation contact edit form for parity between both contact editing experiences. - Mapped social_telegram_user_name into the editable socialProfiles payload when preparing contact form state, so Telegram usernames sourced from channel attributes are visible in the form. - Updated the conversation contact social profile merge logic so Telegram display prefers an explicitly saved social profile value and falls back to social_telegram_user_name when needed. - Added the missing English i18n placeholder: Add Telegram. ### Why Without this, users could see Telegram info in some contact views but could not reliably edit it in contact details, creating inconsistent behavior between display and edit states. --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../Contacts/ContactsForm/ContactsForm.vue | 11 ++++++++++- app/javascript/dashboard/i18n/locale/en/contact.json | 3 +++ .../dashboard/conversation/contact/ContactForm.vue | 4 ++++ .../dashboard/conversation/contact/ContactInfo.vue | 8 ++++++-- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index 3dc29738e..66296257f 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -44,6 +44,7 @@ const SOCIAL_CONFIG = { LINKEDIN: 'i-ri-linkedin-box-fill', FACEBOOK: 'i-ri-facebook-circle-fill', INSTAGRAM: 'i-ri-instagram-line', + TELEGRAM: 'i-ri-telegram-fill', TIKTOK: 'i-ri-tiktok-fill', TWITTER: 'i-ri-twitter-x-fill', GITHUB: 'i-ri-github-fill', @@ -66,6 +67,7 @@ const defaultState = { facebook: '', github: '', instagram: '', + telegram: '', tiktok: '', linkedin: '', twitter: '', @@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => { countryCode = '', country = '', city = '', + socialTelegramUserName = '', socialProfiles = {}, } = additionalAttributes || {}; + const telegramUsername = + socialProfiles?.telegram || socialTelegramUserName || ''; + Object.assign(state, { id, name, @@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => { countryCode, country, city, - socialProfiles, + socialProfiles: { + ...socialProfiles, + telegram: telegramUsername, + }, }, }); }; diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index fe04e5848..0d9f79984 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -458,6 +458,9 @@ "INSTAGRAM": { "PLACEHOLDER": "Add Instagram" }, + "TELEGRAM": { + "PLACEHOLDER": "Add Telegram" + }, "TIKTOK": { "PLACEHOLDER": "Add TikTok" }, diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue index 77d4b9773..d8479c4f2 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactForm.vue @@ -58,12 +58,14 @@ export default { twitter: '', linkedin: '', github: '', + telegram: '', }, socialProfileKeys: [ { key: 'facebook', prefixURL: 'https://facebook.com/' }, { key: 'twitter', prefixURL: 'https://twitter.com/' }, { key: 'linkedin', prefixURL: 'https://linkedin.com/' }, { key: 'github', prefixURL: 'https://github.com/' }, + { key: 'telegram', prefixURL: 'https://t.me/' }, { key: 'tiktok', prefixURL: 'https://tiktok.com/@' }, ], }; @@ -175,12 +177,14 @@ export default { const { social_profiles: socialProfiles = {}, screen_name: twitterScreenName, + social_telegram_user_name: telegramUserName, } = additionalAttributes; this.socialProfileUserNames = { twitter: socialProfiles.twitter || twitterScreenName || '', facebook: socialProfiles.facebook || '', linkedin: socialProfiles.linkedin || '', github: socialProfiles.github || '', + telegram: socialProfiles.telegram || telegramUserName || '', instagram: socialProfiles.instagram || '', tiktok: socialProfiles.tiktok || '', }; diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index 7392bea78..3899d1ddf 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -81,10 +81,14 @@ export default { screen_name: twitterScreenName, social_telegram_user_name: telegramUsername, } = this.additionalAttributes; + + const telegram = socialProfiles?.telegram || telegramUsername || ''; + const twitter = socialProfiles?.twitter || twitterScreenName || ''; + return { - twitter: twitterScreenName, - telegram: telegramUsername, ...(socialProfiles || {}), + twitter, + telegram, }; }, // Delete Modal From 2b85275e2693f7ec3b5f03cc7e2e3e5c1b75ba1b Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:32:54 +0530 Subject: [PATCH 6/7] feat: show assignment policy name in auto-assignment activity messages (#13598) --- .../concerns/activity_message_handler.rb | 22 +----------- .../assignee_activity_message_handler.rb | 35 +++++++++++++++++++ .../auto_assignment/assignment_service.rb | 4 +++ config/locales/en.yml | 3 ++ 4 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 app/models/concerns/assignee_activity_message_handler.rb diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index c25aba47f..0300bd2d1 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -1,6 +1,7 @@ module ActivityMessageHandler extend ActiveSupport::Concern + include AssigneeActivityMessageHandler include PriorityActivityMessageHandler include LabelActivityMessageHandler include SlaActivityMessageHandler @@ -104,27 +105,6 @@ module ActivityMessageHandler content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name) ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content end - - def generate_assignee_change_activity_content(user_name) - params = { assignee_name: assignee&.name || '', user_name: user_name } - key = assignee_id ? 'assigned' : 'removed' - key = 'self_assigned' if self_assign? assignee_id - I18n.t("conversations.activity.assignee.#{key}", **params) - end - - def create_assignee_change_activity(user_name) - user_name = activity_message_owner(user_name) - - return unless user_name - - content = generate_assignee_change_activity_content(user_name) - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content - end - - def activity_message_owner(user_name) - user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present? - user_name - end end ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler') diff --git a/app/models/concerns/assignee_activity_message_handler.rb b/app/models/concerns/assignee_activity_message_handler.rb new file mode 100644 index 000000000..ff5516ec9 --- /dev/null +++ b/app/models/concerns/assignee_activity_message_handler.rb @@ -0,0 +1,35 @@ +module AssigneeActivityMessageHandler + extend ActiveSupport::Concern + + private + + def create_assignee_change_activity(user_name) + user_name = activity_message_owner(user_name) + + return unless user_name + + content = generate_assignee_change_activity_content(user_name) + ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + end + + def generate_assignee_change_activity_content(user_name) + params = { assignee_name: assignee&.name || '', user_name: user_name } + key = assignee_id ? 'assigned' : 'removed' + key = 'self_assigned' if self_assign? assignee_id + I18n.t("conversations.activity.assignee.#{key}", **params) + end + + def activity_message_owner(user_name) + if !user_name && Current.executed_by.present? + user_name = case Current.executed_by + when AssignmentPolicy + I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name) + when Inbox + I18n.t('auto_assignment.default_policy_name') + else + I18n.t('automation.system_name') + end + end + user_name + end +end diff --git a/app/services/auto_assignment/assignment_service.rb b/app/services/auto_assignment/assignment_service.rb index 89eff9d1c..e27f1e829 100644 --- a/app/services/auto_assignment/assignment_service.rb +++ b/app/services/auto_assignment/assignment_service.rb @@ -72,13 +72,17 @@ class AutoAssignment::AssignmentService end def assign_conversation(conversation, agent) + Current.executed_by = inbox.assignment_policy || inbox conversation.update!(assignee: agent) + Current.executed_by = nil rate_limiter = build_rate_limiter(agent) rate_limiter.track_assignment(conversation) dispatch_assignment_event(conversation, agent) true + ensure + Current.executed_by = nil end def dispatch_assignment_event(conversation, agent) diff --git a/config/locales/en.yml b/config/locales/en.yml index 05276009f..9cab941c2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -438,6 +438,9 @@ en: seconds: one: '%{count} second' other: '%{count} seconds' + auto_assignment: + default_policy_name: 'Default Policy' + policy_actor: 'Automation System via %{policy_name}' automation: system_name: 'Automation System' crm: From 6be95e79f8964d922d7de3d7ceb76874e6405ac4 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 24 Feb 2026 15:11:04 +0400 Subject: [PATCH 7/7] feat(csat): Add WhatsApp utility template analyzer with rewrite guidance (#13575) CSAT templates for WhatsApp are submitted as Utility, but Meta may reclassify them as Marketing based on content, which can significantly increase messaging costs. This PR introduces a Captain-powered CSAT template analyzer for WhatsApp/Twilio WhatsApp that predicts utility fit, explains likely risks, and suggests safer rewrites before submission. The flow is manual (button-triggered), Captain-gated, and applies rewrites only on explicit user action. It also updates UX copy to clearly set expectations: the system submits as Utility, Meta makes the final categorization decision. Fixes https://linear.app/chatwoot/issue/CW-6424/ai-powered-whatsapp-template-classifier-for-csat-submissions https://github.com/user-attachments/assets/8fd1d6db-2f91-447c-9771-3de271b16fd9 --- .../inbox_csat_templates_controller.rb | 24 +++ app/javascript/dashboard/api/inboxes.js | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 16 +- .../settingsPage/CustomerSatisfactionPage.vue | 152 +++++++++++++++++- .../dashboard/store/modules/inboxes.js | 7 + .../csat_template_management_service.rb | 2 + .../csat_template_utility_analysis_service.rb | 86 ++++++++++ app/services/csat_template_utility_rubric.rb | 125 ++++++++++++++ .../whatsapp/csat_template_service.rb | 2 +- config/routes.rb | 4 +- lib/captain/csat_utility_analysis_service.rb | 66 ++++++++ .../csat_utility_analysis.liquid | 27 ++++ .../inbox_csat_templates_controller_spec.rb | 91 +++++++++++ .../csat_utility_analysis_service_spec.rb | 24 +++ ..._template_utility_analysis_service_spec.rb | 81 ++++++++++ .../whatsapp/csat_template_service_spec.rb | 4 +- 16 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 app/services/csat_template_utility_analysis_service.rb create mode 100644 app/services/csat_template_utility_rubric.rb create mode 100644 lib/captain/csat_utility_analysis_service.rb create mode 100644 lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid create mode 100644 spec/lib/captain/csat_utility_analysis_service_spec.rb create mode 100644 spec/services/csat_template_utility_analysis_service_spec.rb diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index bb5dab680..d7244616d 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -1,6 +1,7 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController before_action :fetch_inbox before_action :validate_whatsapp_channel + before_action :validate_captain_enabled, only: [:analyze] def show service = CsatTemplateManagementService.new(@inbox) @@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Template parameters are required' }, status: :unprocessable_entity end + def analyze + template_params = extract_template_params + return render_missing_message_error if template_params[:message].blank? + + result = CsatTemplateUtilityAnalysisService.new( + account: Current.account, + inbox: @inbox, + message: template_params[:message], + button_text: template_params[:button_text], + language: template_params[:language] + ).perform + + render json: result + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + end + private def fetch_inbox @@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Message is required' }, status: :unprocessable_entity end + def validate_captain_enabled + return if Current.account.feature_enabled?('captain_integration') + + render json: { error: 'Captain is required for template analysis' }, status: :forbidden + end + def render_template_creation_result(result) if result[:success] render_successful_template_creation(result) diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 83ba3e9ba..079f21815 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient { getCSATTemplateStatus(inboxId) { return axios.get(`${this.url}/${inboxId}/csat_template`); } + + analyzeCSATTemplateUtility(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, { + template, + }); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index f4f62764b..aaa8fd5bf 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -890,6 +890,20 @@ "CONFIRM": "Create new template", "CANCEL": "Go back" }, + "UTILITY_ANALYZER": { + "ACTION": "Check utility fit", + "HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.", + "RESULT_LABEL": "Meta category prediction", + "GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.", + "SUGGESTION_LABEL": "Suggested utility-safe rewrite", + "APPLY": "Use this rewrite", + "ERROR_MESSAGE": "Couldn't analyze the message. Please try again.", + "CLASSIFICATION": { + "LIKELY_UTILITY": "Likely Utility", + "LIKELY_MARKETING": "Likely Marketing", + "UNCLEAR": "Needs clarification" + } + }, "SURVEY_RULE": { "LABEL": "Survey rule", "DESCRIPTION_PREFIX": "Send the survey if the conversation", @@ -901,7 +915,7 @@ "SELECT_PLACEHOLDER": "select labels" }, "NOTE": "Note: CSAT surveys are sent only once per conversation", - "WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.", + "WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.", "API": { "SUCCESS_MESSAGE": "CSAT settings updated successfully", "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue index 03cb6dada..dc247da6f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useInbox } from 'dashboard/composables/useInbox'; +import { useCaptain } from 'dashboard/composables/useCaptain'; import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages'; import Icon from 'dashboard/components-next/icon/Icon.vue'; @@ -26,6 +27,7 @@ const props = defineProps({ const { t } = useI18n(); const store = useStore(); const labels = useMapGetter('labels/getLabels'); +const { captainEnabled } = useCaptain(); const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox( props.inbox?.id @@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed( ); const isUpdating = ref(false); +const utilityAnalysisLoading = ref(false); +const utilityAnalysisResult = ref(null); const selectedLabelValues = ref([]); const currentLabel = ref(''); @@ -46,7 +50,7 @@ const state = reactive({ message: '', templateButtonText: 'Please rate us', surveyRuleOperator: 'contains', - templateLanguage: '', + templateLanguage: 'en', }); const templateStatus = ref(null); @@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({ const shouldShowTemplateStatus = computed( () => templateStatus.value && !templateLoading.value ); +const showUtilityAnalyzer = computed( + () => isAnyWhatsAppChannel.value && captainEnabled.value +); const templateApprovalStatus = computed(() => { const statusMap = { @@ -218,6 +225,85 @@ const updateDisplayType = type => { state.displayType = type; }; +const resetUtilityAnalysis = () => { + utilityAnalysisResult.value = null; +}; + +const analyzeTemplateUtility = async () => { + if (!showUtilityAnalyzer.value || !state.message?.trim()) return; + + utilityAnalysisLoading.value = true; + resetUtilityAnalysis(); + + try { + const response = await store.dispatch( + 'inboxes/analyzeCSATTemplateUtility', + { + inboxId: props.inbox.id, + template: { + message: state.message, + button_text: state.templateButtonText, + language: state.templateLanguage, + }, + } + ); + utilityAnalysisResult.value = response; + } catch (error) { + const errorMessage = + error.response?.data?.error || + t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE'); + useAlert(errorMessage); + } finally { + utilityAnalysisLoading.value = false; + } +}; + +const applyUtilitySuggestion = () => { + const suggestion = utilityAnalysisResult.value?.optimized_message; + if (!suggestion) return; + + state.message = suggestion; + resetUtilityAnalysis(); +}; + +watch( + () => [state.message, state.templateButtonText, state.templateLanguage], + (newValues, oldValues) => { + if (!oldValues || !utilityAnalysisResult.value) { + return; + } + + const changed = newValues.some( + (value, index) => value !== oldValues[index] + ); + if (changed) { + resetUtilityAnalysis(); + } + } +); + +const getUtilityClassificationLabel = classification => { + if (classification === 'LIKELY_UTILITY') { + return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY'); + } + if (classification === 'LIKELY_MARKETING') { + return t( + 'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING' + ); + } + return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR'); +}; + +const getUtilityClassificationClass = classification => { + if (classification === 'LIKELY_UTILITY') { + return 'bg-n-teal-3 text-n-teal-11'; + } + if (classification === 'LIKELY_MARKETING') { + return 'bg-n-ruby-3 text-n-ruby-11'; + } + return 'bg-n-amber-3 text-n-amber-11'; +}; + const updateSurveyRuleOperator = operator => { state.surveyRuleOperator = operator; }; @@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => { class="w-full" /> +
+ +

+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }} +

+
+ +
+
+ + {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }} + + + {{ + getUtilityClassificationLabel( + utilityAnalysisResult.classification + ) + }} + +
+

+ {{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }} +

+
+

+ {{ + $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL') + }} +

+

+ {{ utilityAnalysisResult.optimized_message }} +

+ +
+
{ + const response = await InboxesAPI.analyzeCSATTemplateUtility( + inboxId, + template + ); + return response.data; + }, }; export const mutations = { diff --git a/app/services/csat_template_management_service.rb b/app/services/csat_template_management_service.rb index 064804082..e83b3413a 100644 --- a/app/services/csat_template_management_service.rb +++ b/app/services/csat_template_management_service.rb @@ -110,6 +110,7 @@ class CsatTemplateManagementService template_service = Twilio::CsatTemplateService.new(@inbox.channel) status_result = template_service.get_template_status(content_sid) + return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash) if status_result[:success] { @@ -130,6 +131,7 @@ class CsatTemplateManagementService def get_whatsapp_template_status(template) template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id) status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name) + return { template_exists: false, error: 'Template not found' } unless status_result.is_a?(Hash) if status_result[:success] { diff --git a/app/services/csat_template_utility_analysis_service.rb b/app/services/csat_template_utility_analysis_service.rb new file mode 100644 index 000000000..b1b089b53 --- /dev/null +++ b/app/services/csat_template_utility_analysis_service.rb @@ -0,0 +1,86 @@ +class CsatTemplateUtilityAnalysisService + include CsatTemplateUtilityRubric + + pattr_initialize [:account!, :inbox!, :message!, { button_text: nil, language: 'en' }] + + def perform + baseline = rule_based_result + return baseline if baseline[:classification] == 'LIKELY_MARKETING' + + llm_result = llm_result_or_nil(baseline) + llm_result || baseline + end + + private + + def llm_result_or_nil(baseline) + llm_output = Captain::CsatUtilityAnalysisService.new( + account: account, + message: message, + button_text: button_text, + language: language, + baseline: baseline + ).perform + + return nil if llm_output[:error] + + normalize_llm_result(llm_output, baseline: baseline) + rescue StandardError => e + Rails.logger.error("CSAT utility LLM analysis failed for inbox #{inbox.id}: #{e.message}") + nil + end + + def normalize_llm_result(result, baseline:) + classification = normalized_classification(result[:classification], baseline: baseline) + optimized_message = result[:optimized_message].presence || baseline[:optimized_message] + optimized_message = baseline[:optimized_message] if baseline[:classification] == 'LIKELY_MARKETING' + + { + classification: classification, + optimized_message: optimized_message + } + end + + def normalized_classification(value, baseline:) + raw = value.to_s + return 'LIKELY_MARKETING' if baseline[:classification] == 'LIKELY_MARKETING' + + raw + end + + def rule_based_result + text = sanitized_message + marketing_hits_count = MARKETING_PATTERNS.count { |pattern| pattern.match?(text) } + utility_hits_count = UTILITY_PATTERNS.count { |pattern| pattern.match?(text) } + criteria = evaluate_criteria(text: text, marketing_hits_count: marketing_hits_count) + classification = classify(criteria: criteria, utility_hits_count: utility_hits_count) + build_rule_payload( + classification: classification + ) + end + + def build_rule_payload(payload) + { + classification: payload[:classification], + optimized_message: optimized_message_for(payload[:classification]) + } + end + + def sanitized_message + message.to_s.squish + end + + def classify(criteria:, utility_hits_count:) + return 'LIKELY_MARKETING' unless criteria[:marketing_prohibition] + return 'LIKELY_MARKETING' unless criteria[:prohibited_content] + return 'LIKELY_UTILITY' if criteria.values.all? && utility_hits_count >= 2 + + 'UNCLEAR' + end + + def optimized_message_for(classification) + return sanitized_message if classification == 'LIKELY_UTILITY' + + build_input_aware_utility_message + end +end diff --git a/app/services/csat_template_utility_rubric.rb b/app/services/csat_template_utility_rubric.rb new file mode 100644 index 000000000..0253a003a --- /dev/null +++ b/app/services/csat_template_utility_rubric.rb @@ -0,0 +1,125 @@ +# rubocop:disable Metrics/ModuleLength +module CsatTemplateUtilityRubric + LANGUAGE_FALLBACKS = { + 'en' => { + support_request: 'support request', + support_ticket: 'support ticket', + support_conversation: 'support conversation', + status_closed: 'closed', + status_resolved: 'resolved', + status_completed: 'completed', + line_status: 'Your %s has been %s.', + line_help: 'If you still need help, simply reply to this message.', + line_rate: 'To rate this support interaction, please use the button below.' + } + }.freeze + + MARKETING_PATTERNS = [ + /\b(discounts?|offers?|promos?|promotions?|deals?|sales?|buy|shop|subscribe)\b/i, + /\b(limited\s*time|don't\s*miss|exclusive|special\s*offer)\b/i, + /\b(click\s*(here|below)\s*to\s*(buy|get|shop))\b/i, + /\b(new\s*(plans?|products?|services?))\b/i + ].freeze + + TRANSACTION_TRIGGER_PATTERNS = [ + /\b(closed|closing|resolved|completed)\b/i, + /\b(ticket|request|case|conversation|support)\b/i + ].freeze + + TRANSACTIONAL_CONTENT_PATTERNS = [ + /\b(ticket|request|case|conversation)\b/i, + /\b(reply\s+to\s+this\s+message|if\s+you\s+still\s+need\s+help)\b/i, + /\b(rate|califica|calificar)\b/i + ].freeze + + PROHIBITED_CONTENT_PATTERNS = [ + /\b(contest|sweepstake|lottery|quiz)\b/i, + /\b(password|otp|pin|cvv|credit\s*card)\b/i, + /\b(weapon|drugs|gambling)\b/i + ].freeze + + STATUS_PATTERNS = { + 'closed' => /\b(closed|closing)\b/i, + 'resolved' => /\b(resolved|resolve[sd]?)\b/i, + 'completed' => /\b(completed|complete[sd]?)\b/i + }.freeze + + SUBJECT_PATTERNS = { + 'support ticket' => /\b(ticket)\b/i, + 'support conversation' => /\b(conversation|chat)\b/i, + 'support request' => /\b(request|case|support)\b/i + }.freeze + + UTILITY_PATTERNS = [ + /\b(support|ticket|request|conversation|case)\b/i, + /\b(closed|resolved|completed)\b/i, + /\b(reply\s+to\s+this\s+message\b)/i, + /\b(if\s+you\s+still\s+need\s+help)\b/i, + /\b(rate\s+this\s+(support|interaction|conversation))\b/i + ].freeze + + private + + def build_input_aware_utility_message + text = translation_pack + subject = detected_subject + status = detected_status + intro = extracted_intro_sentence + + parts = [] + parts << intro if intro.present? + parts << format(text[:line_status], subject: subject, status: status) + parts << text[:line_help] + parts << text[:line_rate] + parts.join(' ') + end + + def detected_status + matched = STATUS_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) } + status_key = matched&.first || 'closed' + translation_pack[:"status_#{status_key}"] + end + + def detected_subject + matched = SUBJECT_PATTERNS.find { |_key, pattern| pattern.match?(sanitized_message) } + subject_key = matched&.first&.tr(' ', '_') || 'support_request' + translation_pack[subject_key.to_sym] + end + + def extracted_intro_sentence + first_sentence = sanitized_message.split(/(?<=[.!?])\s+/).first.to_s + return nil if first_sentence.blank? + return nil if MARKETING_PATTERNS.any? { |pattern| pattern.match?(first_sentence) } + return nil unless first_sentence.match?(/\b(thanks|thank you|hello|hi)\b/i) + + normalized = first_sentence.gsub(/\s+/, ' ').strip + normalized.ends_with?('.', '!', '?') ? normalized : "#{normalized}." + end + + def translation_pack + LANGUAGE_FALLBACKS.fetch(primary_language_code, LANGUAGE_FALLBACKS['en']) + end + + def primary_language_code + language.to_s.downcase.split(/[-_]/).first + end + + def evaluate_criteria(text:, marketing_hits_count:) + { + trigger: TRANSACTION_TRIGGER_PATTERNS.any? { |pattern| pattern.match?(text) }, + transactional_content: TRANSACTIONAL_CONTENT_PATTERNS.count { |pattern| pattern.match?(text) } >= 2, + marketing_prohibition: marketing_hits_count.zero?, + prohibited_content: PROHIBITED_CONTENT_PATTERNS.none? { |pattern| pattern.match?(text) }, + clarity_and_utility: clear_utility_intent?(text) + } + end + + def clear_utility_intent?(text) + has_support_context = text.match?(/\b(support|ticket|request|case|conversation)\b/i) + has_actionable_next_step = text.match?(/\b(reply\s+to\s+this\s+message)\b/i) || + text.match?(/\b(if\s+you\s+still\s+need\s+help)\b/i) || + text.match?(/\b(rate\s+this\s+(support|interaction|conversation))\b/i) + has_support_context && has_actionable_next_step + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/app/services/whatsapp/csat_template_service.rb b/app/services/whatsapp/csat_template_service.rb index 9bdbef8ca..9dc057a10 100644 --- a/app/services/whatsapp/csat_template_service.rb +++ b/app/services/whatsapp/csat_template_service.rb @@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze DEFAULT_LANGUAGE = 'en'.freeze WHATSAPP_API_VERSION = 'v14.0'.freeze - TEMPLATE_CATEGORY = 'MARKETING'.freeze + TEMPLATE_CATEGORY = 'UTILITY'.freeze TEMPLATE_STATUS_PENDING = 'PENDING'.freeze def initialize(whatsapp_channel) diff --git a/config/routes.rb b/config/routes.rb index 38c93b91e..1efd9f526 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -224,7 +224,9 @@ Rails.application.routes.draw do end end - resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' + resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do + post :analyze, on: :collection + end end resources :inbox_members, only: [:create, :show], param: :inbox_id do diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb new file mode 100644 index 000000000..e04a98a7f --- /dev/null +++ b/lib/captain/csat_utility_analysis_service.rb @@ -0,0 +1,66 @@ +class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService + pattr_initialize [:account!, :message!, { button_text: nil, language: 'en', baseline: {} }] + + def perform + api_response = make_api_call( + model: GPT_MODEL, + messages: [ + { role: 'system', content: system_prompt }, + { role: 'user', content: message } + ] + ) + + return api_response if api_response[:error] + + build_result(api_response[:message]) + end + + private + + def build_result(response_message) + parsed = parse_json_response(response_message) + return { error: 'Invalid LLM response format' } if parsed.blank? + + core_result(parsed).merge(message: response_message) + end + + def core_result(parsed) + { + classification: normalize_classification(parsed['classification']), + optimized_message: parsed['optimized_message'].presence || baseline[:optimized_message] + } + end + + def system_prompt + template = prompt_from_file('csat_utility_analysis') + Liquid::Template.parse(template).render(prompt_variables) + end + + def prompt_variables + { + 'message' => message.to_s, + 'button_text' => button_text.to_s, + 'language' => language.to_s, + 'baseline_classification' => baseline[:classification].to_s + } + end + + def parse_json_response(content) + raw = content.to_s.strip + json = raw.match(/```json\s*(.*?)\s*```/m)&.captures&.first || raw + JSON.parse(json) + rescue JSON::ParserError + nil + end + + def normalize_classification(value) + normalized = value.to_s.upcase + return normalized if %w[LIKELY_UTILITY LIKELY_MARKETING UNCLEAR].include?(normalized) + + baseline[:classification].presence || 'UNCLEAR' + end + + def event_name + 'csat_utility_analysis' + end +end diff --git a/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid new file mode 100644 index 000000000..a4638241e --- /dev/null +++ b/lib/integrations/openai/openai_prompts/csat_utility_analysis.liquid @@ -0,0 +1,27 @@ +You are a WhatsApp template compliance assistant. +Your task is to evaluate whether a CSAT template message is likely to be approved as UTILITY vs MARKETING under Meta policy. + +Rules: +1. Prefer UTILITY only when the message is tied to an existing support or transactional event. +2. Avoid promotional language, upsell, cross-sell, offers, discounts, or purchase intent. +3. Keep the rewritten message concise, explicit, and purely transactional. +4. Do not invent product offers or marketing phrases. + +Input: +- Message: {{ message }} +- Button text: {{ button_text }} +- Language code: {{ language }} + +Baseline heuristic: +- Classification: {{ baseline_classification }} + +Return ONLY valid JSON with this shape (example): +{ + "classification": "LIKELY_UTILITY", + "optimized_message": "rewritten utility-safe message" +} + +Allowed values for "classification": "LIKELY_UTILITY", "LIKELY_MARKETING", or "UNCLEAR". + +Important: +- Write `optimized_message` in the same language as `Language code`. diff --git a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb index d577c1b5d..ea23319fe 100644 --- a/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inbox_csat_templates_controller_spec.rb @@ -10,10 +10,12 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d let(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) } let(:web_widget_inbox) { create(:inbox, account: account) } let(:mock_service) { instance_double(Whatsapp::CsatTemplateService) } + let(:analysis_service) { instance_double(CsatTemplateUtilityAnalysisService) } before do create(:inbox_member, user: agent, inbox: whatsapp_inbox) allow(Whatsapp::CsatTemplateService).to receive(:new).and_return(mock_service) + allow(CsatTemplateUtilityAnalysisService).to receive(:new).and_return(analysis_service) end describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template' do @@ -380,4 +382,93 @@ RSpec.describe Api::V1::Accounts::InboxCsatTemplatesController, type: :request d end end end + + describe 'POST /api/v1/accounts/{account.id}/inboxes/{inbox.id}/csat_template/analyze' do + let(:valid_template_params) do + { + template: { + message: 'How would you rate your experience?', + button_text: 'Rate Us', + language: 'en' + } + } + end + + context 'when captain_integration feature is disabled' do + before do + account.disable_features!('captain_integration') + end + + it 'returns forbidden' do + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:forbidden) + expect(response.parsed_body['error']).to eq('Captain is required for template analysis') + end + end + + context 'when captain_integration feature is enabled' do + before do + account.enable_features!('captain_integration') + account.reload + end + + it 'returns analysis response' do + allow(analysis_service).to receive(:perform).and_return({ + classification: 'LIKELY_UTILITY', + optimized_message: 'Your support request has been closed.' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze", + headers: admin.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:success) + response_data = response.parsed_body + expect(response_data['classification']).to eq('LIKELY_UTILITY') + expect(response_data['optimized_message']).to eq('Your support request has been closed.') + end + + it 'returns error when message is missing' do + invalid_params = { template: { button_text: 'Rate Us', language: 'en' } } + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze", + headers: admin.create_new_auth_token, + params: invalid_params, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to eq('Message is required') + end + + it 'returns unauthorized when agent is not assigned to inbox' do + other_agent = create(:user, account: account, role: :agent) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze", + headers: other_agent.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'allows access when agent is assigned to inbox' do + allow(analysis_service).to receive(:perform).and_return({ + classification: 'LIKELY_UTILITY', + optimized_message: 'Your support request has been closed.' + }) + + post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/csat_template/analyze", + headers: agent.create_new_auth_token, + params: valid_template_params, + as: :json + + expect(response).to have_http_status(:success) + end + end + end end diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb new file mode 100644 index 000000000..e4e980e01 --- /dev/null +++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe Captain::CsatUtilityAnalysisService do + let(:account) { create(:account) } + let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) } + + describe '#perform' do + before do + allow(account).to receive(:feature_enabled?).and_call_original + allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + allow(service).to receive(:make_api_call).and_return({ + message: '{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}' + }) + end + + it 'returns parsed payload and preserves raw message for usage metering' do + result = service.perform + + expect(result[:classification]).to eq('LIKELY_UTILITY') + expect(result[:optimized_message]).to eq('Utility-safe message') + expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}') + end + end +end diff --git a/spec/services/csat_template_utility_analysis_service_spec.rb b/spec/services/csat_template_utility_analysis_service_spec.rb new file mode 100644 index 000000000..7f5521d4a --- /dev/null +++ b/spec/services/csat_template_utility_analysis_service_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +RSpec.describe CsatTemplateUtilityAnalysisService do + let(:account) { build_stubbed(:account) } + let(:inbox) { build_stubbed(:inbox) } + let(:llm_service) { instance_double(Captain::CsatUtilityAnalysisService) } + + before do + allow(Captain::CsatUtilityAnalysisService).to receive(:new).and_return(llm_service) + allow(llm_service).to receive(:perform).and_return({ error: 'LLM unavailable' }) + end + + describe '#perform' do + context 'when message is utility-compatible' do + it 'returns likely utility classification and keeps original message' do + message = 'Your support request has been closed. If you still need help, reply to this message.' + result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform + + expect(result[:classification]).to eq('LIKELY_UTILITY') + expect(result[:optimized_message]).to eq(message) + expect(result.keys).to contain_exactly(:classification, :optimized_message) + end + end + + context 'when message contains marketing intent' do + it 'returns likely marketing classification with utility-safe rewrite' do + message = 'Please rate us and check out our special offer with a discount.' + result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform + + expect(result[:classification]).to eq('LIKELY_MARKETING') + expect(result[:optimized_message]).to include('support request') + expect(result[:optimized_message]).to include('reply to this message') + expect(result.keys).to contain_exactly(:classification, :optimized_message) + end + end + + context 'when language is non-English and fallback rewrite is used' do + it 'returns English rewrite content' do + message = 'Tu caso está cerrado. Califícanos y no te pierdas nuestra oferta.' + result = described_class.new(account: account, inbox: inbox, message: message, language: 'es').perform + + expect(result[:optimized_message]).to include('Your support request has been closed.') + expect(result[:optimized_message]).to include('If you still need help') + end + end + + context 'when llm returns inconsistent marketing classification' do + it 'keeps likely marketing classification' do + allow(llm_service).to receive(:perform).and_return({ + classification: 'LIKELY_MARKETING', + optimized_message: 'Your support request has been closed.' + }) + + message = "Your case is closed. Don't miss our limited-time premium offer. Rate us below." + result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform + + expect(result[:classification]).to eq('LIKELY_MARKETING') + end + end + + context 'when rules classify as marketing' do + it 'short-circuits without calling llm' do + expect(llm_service).not_to receive(:perform) + + message = 'Your request is closed. Special offer: subscribe now and save.' + result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform + + expect(result[:classification]).to eq('LIKELY_MARKETING') + end + end + + context 'when rules classify as marketing for plural promo terms' do + it 'keeps likely marketing classification from baseline rules' do + message = 'Thanks for contacting us. Rate us and check out our new plans with special discounts.' + result = described_class.new(account: account, inbox: inbox, message: message, language: 'en').perform + + expect(result[:classification]).to eq('LIKELY_MARKETING') + end + end + end +end diff --git a/spec/services/whatsapp/csat_template_service_spec.rb b/spec/services/whatsapp/csat_template_service_spec.rb index f21d87227..d7124529d 100644 --- a/spec/services/whatsapp/csat_template_service_spec.rb +++ b/spec/services/whatsapp/csat_template_service_spec.rb @@ -119,7 +119,7 @@ RSpec.describe Whatsapp::CsatTemplateService do expect(result).to eq({ name: expected_template_name, language: 'en', - category: 'MARKETING', + category: 'UTILITY', components: [ { type: 'BODY', @@ -169,7 +169,7 @@ RSpec.describe Whatsapp::CsatTemplateService do expected_body = { name: expected_template_name, language: 'en', - category: 'MARKETING', + category: 'UTILITY', components: [ { type: 'BODY',