From 89a748eef633ff831b5414ccc5db9deb9ec320f2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 15 Jul 2025 21:55:09 +0530 Subject: [PATCH 01/14] feat: update tools --- .../captain/tools/add_contact_note_tool.rb | 30 ++++-------- .../tools/add_label_to_conversation_tool.rb | 34 ++++++++++++++ .../captain/tools/add_private_note_tool.rb | 29 ++++-------- .../lib/captain/tools/base_agent_tool.rb | 46 ------------------- .../lib/captain/tools/base_public_tool.rb | 45 ++++++++++++++++++ .../lib/captain/tools/update_priority_tool.rb | 39 +++++----------- 6 files changed, 110 insertions(+), 113 deletions(-) create mode 100644 enterprise/lib/captain/tools/add_label_to_conversation_tool.rb delete mode 100644 enterprise/lib/captain/tools/base_agent_tool.rb create mode 100644 enterprise/lib/captain/tools/base_public_tool.rb diff --git a/enterprise/lib/captain/tools/add_contact_note_tool.rb b/enterprise/lib/captain/tools/add_contact_note_tool.rb index ea3841ef7..e1475abf0 100644 --- a/enterprise/lib/captain/tools/add_contact_note_tool.rb +++ b/enterprise/lib/captain/tools/add_contact_note_tool.rb @@ -1,36 +1,26 @@ -class Captain::Tools::AddContactNoteTool < Captain::Tools::BaseAgentTool +class Captain::Tools::AddContactNoteTool < Captain::Tools::BasePublicTool description 'Add a note to a contact profile' - param :contact_id, type: 'string', desc: 'The ID of the contact' param :note, type: 'string', desc: 'The note content to add to the contact' - def perform(_tool_context, note:, contact_id:) - log_tool_usage('add_contact_note', { contact_id: contact_id, note_length: note.length }) - - return 'Missing required parameters: contact_id, note' if note.blank? || contact_id.blank? - - contact = find_contact(contact_id) + def perform(tool_context, note:) + contact = find_contact(tool_context.state) return 'Contact not found' unless contact + return 'Note content is required' if note.blank? + + log_tool_usage('add_contact_note', { contact_id: contact.id, note_length: note.length }) + create_contact_note(contact, note) "Note added successfully to contact #{contact.name} (ID: #{contact.id})" end private - def find_contact(contact_id) - account_scoped(::Contact).find_by(id: contact_id) - end - def create_contact_note(contact, note) - contact.notes.create!( - account: @assistant.account, - contact: contact, - content: note, - user: @user - ) + contact.notes.create!(content: note) end - def active? - user_has_permission('contact_manage') + def permissions + %w[contact_manage] end end diff --git a/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb new file mode 100644 index 000000000..429f33b7b --- /dev/null +++ b/enterprise/lib/captain/tools/add_label_to_conversation_tool.rb @@ -0,0 +1,34 @@ +class Captain::Tools::AddLabelToConversationTool < Captain::Tools::BasePublicTool + description 'Add a label to a conversation' + param :label_name, type: 'string', desc: 'The name of the label to add' + + def perform(tool_context, label_name:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + + label_name = label_name&.strip&.downcase + return 'Label name is required' if label_name.blank? + + label = find_label(label_name) + return 'Label not found' unless label + + add_label_to_conversation(conversation, label_name) + + log_tool_usage('added_label', conversation_id: conversation.id, label: label_name) + + "Label '#{label_name}' added to conversation ##{conversation.display_id}" + end + + private + + def find_label(label_name) + account_scoped(Label).find_by(title: label_name) + end + + def add_label_to_conversation(conversation, label_name) + conversation.add_labels(label_name) + rescue StandardError => e + Rails.logger.error "Failed to add label to conversation: #{e.message}" + raise + end +end diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb index 28606c231..4a2328d43 100644 --- a/enterprise/lib/captain/tools/add_private_note_tool.rb +++ b/enterprise/lib/captain/tools/add_private_note_tool.rb @@ -1,40 +1,31 @@ -class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BaseAgentTool +class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool description 'Add a private note to a conversation' - param :conversation_id, type: 'string', desc: 'The display ID of the conversation' param :note, type: 'string', desc: 'The private note content' - def perform(_tool_context, conversation_id:, note:) - log_tool_usage('add_private_note', { conversation_id: conversation_id, note_length: note.length }) - - return 'Missing required parameters: conversation_id, note' if conversation_id.blank? || note.blank? - - conversation = find_conversation(conversation_id) + def perform(tool_context, note:) + conversation = find_conversation(tool_context.state) return 'Conversation not found' unless conversation + log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length }) create_private_note(conversation, note) - "Private note added successfully to conversation #{conversation_id}" + + 'Private note added successfully' end private - def find_conversation(conversation_id) - account_scoped(::Conversation).find_by(display_id: conversation_id) - end - def create_private_note(conversation, note) conversation.messages.create!( account: @assistant.account, inbox: conversation.inbox, - sender: @user, + sender: @assistant, + message_type: :outgoing, content: note, - message_type: 'activity', private: true ) end - def active? - user_has_permission('conversation_manage') || - user_has_permission('conversation_unassigned_manage') || - user_has_permission('conversation_participating_manage') + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] end end diff --git a/enterprise/lib/captain/tools/base_agent_tool.rb b/enterprise/lib/captain/tools/base_agent_tool.rb deleted file mode 100644 index 8020cd5f0..000000000 --- a/enterprise/lib/captain/tools/base_agent_tool.rb +++ /dev/null @@ -1,46 +0,0 @@ -require 'agents' - -class Captain::Tools::BaseAgentTool < Agents::Tool - def initialize(assistant, user: nil) - @assistant = assistant - @user = user - @account_user = find_account_user if @user.present? - super() - end - - def active? - user_has_permission(required_permission) - end - - protected - - def required_permission - # Override in subclasses to specify the required permission - 'agent' - end - - private - - def user_has_permission(permission) - return false if @account_user.blank? - - return @account_user.custom_role.permissions.include?(permission) if @account_user.custom_role.present? - - # Default permission for agents without custom roles - @account_user.administrator? || @account_user.agent? - end - - def find_account_user - AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id) - end - - def account_scoped(model_class) - model_class.where(account_id: @assistant.account_id) - end - - def log_tool_usage(action, details = {}) - Rails.logger.info do - "#{self.class.name}: #{action} by user #{@user&.id} for assistant #{@assistant&.id} - #{details.inspect}" - end - end -end diff --git a/enterprise/lib/captain/tools/base_public_tool.rb b/enterprise/lib/captain/tools/base_public_tool.rb new file mode 100644 index 000000000..e53f7a0f1 --- /dev/null +++ b/enterprise/lib/captain/tools/base_public_tool.rb @@ -0,0 +1,45 @@ +require 'agents' + +class Captain::Tools::BasePublicTool < Agents::Tool + def initialize(assistant) + @assistant = assistant + super() + end + + def active? + # Public tools are always active + true + end + + def permissions + # Override in subclasses to specify required permissions + # Returns empty array for public tools (no permissions required) + [] + end + + private + + def account_scoped(model_class) + model_class.where(account_id: @assistant.account_id) + end + + def find_conversation(state) + conversation_id = state[: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] + return nil unless contact_id + + account_scoped(::Contact).find_by(id: contact_id) + end + + def log_tool_usage(action, details = {}) + Rails.logger.info do + "#{self.class.name}: #{action} for assistant #{@assistant&.id} - #{details.inspect}" + end + end +end diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb index 738bcab22..8fc75f601 100644 --- a/enterprise/lib/captain/tools/update_priority_tool.rb +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -1,40 +1,25 @@ -class Captain::Tools::UpdatePriorityTool < Captain::Tools::BaseAgentTool +class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool description 'Update the priority of a conversation' - param :conversation_id, type: 'string', desc: 'The display ID of the conversation' param :priority, type: 'string', desc: 'The priority level: low, medium, high, urgent, or nil to remove priority' - def perform(_tool_context, conversation_id:, priority:) - log_tool_usage('update_priority', { conversation_id: conversation_id, priority: priority }) + def perform(tool_context, priority:) + @conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless @conversation - error = validate_and_prepare(conversation_id, priority) - return error if error + @normalized_priority = normalize_priority(priority) + return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority) + + log_tool_usage('update_priority', { conversation_id: @conversation.id, priority: priority }) execute_priority_update end private - def validate_and_prepare(conversation_id, priority) - return 'Missing required parameter: conversation_id' if conversation_id.blank? - - @conversation = find_conversation(conversation_id) - return 'Conversation not found' unless @conversation - - @normalized_priority = normalize_priority(priority) - return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority) - - @conversation_id = conversation_id - nil - end - def execute_priority_update update_conversation_priority(@conversation, @normalized_priority) priority_text = @normalized_priority || 'none' - "Priority updated to '#{priority_text}' for conversation #{@conversation_id}" - end - - def find_conversation(conversation_id) - account_scoped(::Conversation).find_by(display_id: conversation_id) + "Priority updated to '#{priority_text}' for conversation ##{@conversation.display_id}" end def normalize_priority(priority) @@ -57,9 +42,7 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BaseAgentTool conversation.update!(priority: priority) end - def active? - user_has_permission('conversation_manage') || - user_has_permission('conversation_unassigned_manage') || - user_has_permission('conversation_participating_manage') + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] end end From b1d26887f0f323a1264abcecc17cc5d05a2e09ae Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 15 Jul 2025 21:55:38 +0530 Subject: [PATCH 02/14] chore: update tools.yml --- config/agents/tools.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/config/agents/tools.yml b/config/agents/tools.yml index 4d1823b33..b994e93d3 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -6,16 +6,21 @@ ####################################################### - id: add_contact_note - title: "Add Contact Note" - description: "Add a note to a contact profile" - icon: "note-add" + title: 'Add Contact Note' + description: 'Add a note to a contact profile' + icon: 'note-add' - id: add_private_note - title: "Add Private Note" - description: "Add a private note to a conversation (internal only)" - icon: "eye-off" + title: 'Add Private Note' + description: 'Add a private note to a conversation (internal only)' + icon: 'eye-off' - id: update_priority - title: "Update Priority" - description: "Update conversation priority level" - icon: "exclamation-triangle" \ No newline at end of file + title: 'Update Priority' + description: 'Update conversation priority level' + icon: 'exclamation-triangle' + +- id: add_label_to_conversation + title: 'Add Label to Conversation' + description: 'Add a label to a conversation' + icon: 'tag' From 6b8dd3c86ad0b45f72b2820a1671e3bec5f27c59 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 16 Jul 2025 08:19:00 +0530 Subject: [PATCH 03/14] chore: move UpdateMessageStatus to deferred queue (#11943) - move `UpdateMessageStatus` to `deferred` queue below `scheduled_jobs` --------- Co-authored-by: Pranav Co-authored-by: Muhsin Keloth --- app/jobs/conversations/update_message_status_job.rb | 2 +- config/sidekiq.yml | 1 + spec/jobs/conversations/update_message_status_job_spec.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) 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/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/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 From 0ea616a6eaeb0398b474d46192d142bf43c3d1f0 Mon Sep 17 00:00:00 2001 From: Aman Kumar <72304680+Aman-14@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:04:02 +0530 Subject: [PATCH 04/14] feat: WhatsApp campaigns (#11910) # Pull Request Template ## Description This PR adds support for WhatsApp campaigns to Chatwoot, allowing businesses to reach their customers through WhatsApp. The implementation includes backend support for WhatsApp template messages, frontend UI components, and integration with the existing campaign system. Fixes #8465 Fixes https://linear.app/chatwoot/issue/CW-3390/whatsapp-campaigns ## Type of change - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - Tested WhatsApp campaign creation UI flow - Verified backend API endpoints for campaign creation - Tested campaign service integration with WhatsApp templates - Validated proper filtering of WhatsApp campaigns in the store ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] 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 ## What we have changed: We have added support for WhatsApp campaigns as requested in the discussion. Ref: https://github.com/orgs/chatwoot/discussions/8465 **Note:** This implementation doesn't exactly match the maintainer's specification and variable support is missing. This is an initial implementation that provides the core WhatsApp campaign functionality. ### Changes included: **Backend:** - Added `template_params` column to campaigns table (migration + schema) - Created `Whatsapp::OneoffCampaignService` for WhatsApp campaign execution - Updated campaign model to support WhatsApp inbox types - Added template_params support to campaign controller and API **Frontend:** - Added WhatsApp campaign page, dialog, and form components - Updated campaign store to filter WhatsApp campaigns separately - Added WhatsApp-specific routes and empty state - Updated i18n translations for WhatsApp campaigns - Modified sidebar to include WhatsApp campaigns navigation This provides a foundation for WhatsApp campaigns that can be extended with variable support and other enhancements in future iterations. --------- Co-authored-by: Muhsin Keloth Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/v1/accounts/campaigns_controller.rb | 2 +- .../EmptyState/WhatsAppCampaignEmptyState.vue | 37 ++ .../WhatsAppCampaignDialog.vue | 48 +++ .../WhatsAppCampaign/WhatsAppCampaignForm.vue | 357 ++++++++++++++++++ .../components-next/sidebar/Sidebar.vue | 5 + app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/campaign.json | 64 ++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/campaigns/campaigns.routes.js | 10 + .../campaigns/pages/LiveChatCampaignsPage.vue | 7 +- .../campaigns/pages/SMSCampaignsPage.vue | 5 +- .../campaigns/pages/WhatsAppCampaignsPage.vue | 74 ++++ .../dashboard/store/modules/campaigns.js | 35 +- .../dashboard/store/modules/inboxes.js | 5 + .../store/modules/specs/campaigns/fixtures.js | 55 +++ .../modules/specs/campaigns/getters.spec.js | 64 +++- app/models/campaign.rb | 19 +- .../whatsapp/oneoff_campaign_service.rb | 94 +++++ .../whatsapp/send_on_whatsapp_service.rb | 88 +---- .../whatsapp/template_processor_service.rb | 95 +++++ .../api/v1/models/_campaign.json.jbuilder | 1 + config/features.yml | 3 + ...102213_add_template_params_to_campaigns.rb | 5 + db/schema.rb | 1 + spec/factories/campaigns.rb | 17 + spec/factories/channel/channel_whatsapp.rb | 1 + .../whatsapp/oneoff_campaign_service_spec.rb | 169 +++++++++ 27 files changed, 1152 insertions(+), 111 deletions(-) create mode 100644 app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue create mode 100644 app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue create mode 100644 app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue create mode 100644 app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue create mode 100644 app/services/whatsapp/oneoff_campaign_service.rb create mode 100644 app/services/whatsapp/template_processor_service.rb create mode 100644 db/migrate/20250709102213_add_template_params_to_campaigns.rb create mode 100644 spec/services/whatsapp/oneoff_campaign_service_spec.rb 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/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/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/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/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/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/config/features.yml b/config/features.yml index 99d7bea6d..85b95a732 100644 --- a/config/features.yml +++ b/config/features.yml @@ -183,3 +183,6 @@ - name: whatsapp_embedded_signup display_name: WhatsApp Embedded Signup enabled: false +- name: whatsapp_campaign + display_name: WhatsApp Campaign + enabled: false 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/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/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 From 9db096f046ff5d94ae7e67c5101d848c143506ef Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 09:52:37 +0530 Subject: [PATCH 05/14] refactor: namespace for the concern --- config/application.rb | 1 - enterprise/app/models/captain/assistant.rb | 2 +- enterprise/app/models/captain/scenario.rb | 2 +- enterprise/app/models/concerns/captain_tools_helpers.rb | 2 +- .../models/concerns/captain_tools_helpers_spec.rb | 8 ++++---- 5 files changed, 7 insertions(+), 8 deletions(-) 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/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 34163226e..cdf2b53f3 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -18,7 +18,7 @@ # class Captain::Assistant < ApplicationRecord include Avatarable - include CaptainToolsHelpers + include Concerns::CaptainToolsHelpers self.table_name = 'captain_assistants' diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index e6b989073..69fc9eef1 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -21,7 +21,7 @@ # index_captain_scenarios_on_enabled (enabled) # class Captain::Scenario < ApplicationRecord - include CaptainToolsHelpers + include Concerns::CaptainToolsHelpers self.table_name = 'captain_scenarios' diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb index 7aa924d3d..311d7e277 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -1,6 +1,6 @@ # 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. diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb index 1c441bc61..3d88522d9 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' @@ -17,7 +17,7 @@ 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) + matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX) expect(matches.flatten).to eq(%w[add_contact_note update_priority]) end @@ -32,7 +32,7 @@ RSpec.describe CaptainToolsHelpers, type: :concern do ] 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 From 45d4d3660c646faf8b755e0e73c1022eae84fd69 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 15 Jul 2025 21:27:35 -0700 Subject: [PATCH 06/14] feat: Add private note action to automations (#11926) ## Summary - allow AutomationRule to accept `add_private_note` action - support `add_private_note` in automation action service - expose private note action in frontend constants and i18n - test new automation rule action ## Testing - `pnpm eslint app/javascript/dashboard/routes/dashboard/settings/automation/constants.js` - `bundle exec rubocop app/services/automation_rules/action_service.rb app/models/automation_rule.rb spec/services/automation_rules/action_service_spec.rb` - `bundle exec rspec spec/services/automation_rules/action_service_spec.rb` ------ https://chatgpt.com/codex/tasks/task_e_6870c5f7b8b88326a9bd60b2ba710ccd Co-authored-by: Muhsin Keloth --- .../dashboard/i18n/locale/en/automation.json | 1 + .../settings/automation/constants.js | 5 +++++ app/models/automation_rule.rb | 3 ++- .../automation_rules/action_service.rb | 7 ++++++ .../automation_rules/action_service_spec.rb | 22 +++++++++++++++++++ 5 files changed, 37 insertions(+), 1 deletion(-) 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/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/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/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/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 From 13b4fdb34ce13aba73483eb583e3849a546c81cb Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 15 Jul 2025 21:28:39 -0700 Subject: [PATCH 07/14] chore: Add submenu for super admin settings (#11860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improve how settings are rendered in Chatwoot Super admin panel - Add google settings support - show setting for community edition ## Settings page - community edition Screenshot 2025-07-08 at 9 08 03 PM ## Expanded settings Screenshot 2025-07-03 at 2 17 16 AM --------- Co-authored-by: Sojan Jose Co-authored-by: Muhsin Keloth --- .../super_admin/app_configs_controller.rb | 3 +- .../super_admin/application_controller.rb | 3 +- .../helpers/super_admin/features.yml | 35 +++++++++++---- .../helpers/super_admin/features_helper.rb | 2 +- app/helpers/super_admin/navigation_helper.rb | 16 +++++++ .../super_admin/application/_icons.html.erb | 6 +++ .../application/_nav_item.html.erb | 4 +- .../application/_navigation.html.erb | 5 +-- .../application/_settings_menu.html.erb | 24 +++++++++++ .../_upgrade_button_community.html.erb | 4 ++ .../_upgrade_button_enterprise.html.erb | 4 ++ app/views/super_admin/settings/show.html.erb | 43 ++++++++++--------- config/installation_config.yml | 22 ++++++++++ lib/chatwoot_hub.rb | 4 ++ 14 files changed, 137 insertions(+), 38 deletions(-) rename {enterprise/app => app}/helpers/super_admin/features.yml (90%) rename {enterprise/app => app}/helpers/super_admin/features_helper.rb (78%) create mode 100644 app/helpers/super_admin/navigation_helper.rb create mode 100644 app/views/super_admin/application/_settings_menu.html.erb create mode 100644 app/views/super_admin/settings/_upgrade_button_community.html.erb create mode 100644 app/views/super_admin/settings/_upgrade_button_enterprise.html.erb 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/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? %>
      - <% if ChatwootApp.enterprise? %> - <%= render partial: "nav_item", locals: { icon: 'icon-settings-2-line', url: super_admin_settings_url, label: 'Settings' } %> - <% end %> <%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %> <%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %> <%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %> diff --git a/app/views/super_admin/application/_settings_menu.html.erb b/app/views/super_admin/application/_settings_menu.html.erb new file mode 100644 index 000000000..3d5173c12 --- /dev/null +++ b/app/views/super_admin/application/_settings_menu.html.erb @@ -0,0 +1,24 @@ +
    • +
      > + + <%= link_to super_admin_settings_url, class: 'flex items-center flex-1' do %> + + Settings + <% end %> + + + + +
        + <% settings_pages.each do |_feature_key, attrs| %> + <% url = super_admin_app_config_url(config: attrs['config_key']) %> +
      • + <% text_class = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %> + <%= link_to url, class: text_class + ' -ml-1 flex items-center px-2 py-1.5 hover:text-woot-500 hover:bg-slate-25 rounded-lg' do %> + <%= attrs['name'] %> + <% end %> +
      • + <% end %> +
      +
      +
    • diff --git a/app/views/super_admin/settings/_upgrade_button_community.html.erb b/app/views/super_admin/settings/_upgrade_button_community.html.erb new file mode 100644 index 000000000..b80ee44c8 --- /dev/null +++ b/app/views/super_admin/settings/_upgrade_button_community.html.erb @@ -0,0 +1,4 @@ + + + Switch to Enterprise edition + diff --git a/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb b/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb new file mode 100644 index 000000000..4fbd96367 --- /dev/null +++ b/app/views/super_admin/settings/_upgrade_button_enterprise.html.erb @@ -0,0 +1,4 @@ + + + Upgrade now + diff --git a/app/views/super_admin/settings/show.html.erb b/app/views/super_admin/settings/show.html.erb index 4c15cbdab..fd424ed90 100644 --- a/app/views/super_admin/settings/show.html.erb +++ b/app/views/super_admin/settings/show.html.erb @@ -39,24 +39,26 @@
    -
    -
    -
    -

    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/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/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 From fcd604dcde3627cfe29e7d198a84406dd0c89c96 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 10:09:46 +0530 Subject: [PATCH 08/14] chore: Make `captain_integration_v2` an internal feature (#11953) --- config/features.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/features.yml b/config/features.yml index 85b95a732..4706c46f0 100644 --- a/config/features.yml +++ b/config/features.yml @@ -180,6 +180,7 @@ display_name: Captain V2 enabled: false premium: true + chatwoot_internal: true - name: whatsapp_embedded_signup display_name: WhatsApp Embedded Signup enabled: false From 2a0f2a8b1e01261b33ca39064d29dece514b1cc4 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 10:21:53 +0530 Subject: [PATCH 09/14] feat: handle empty note --- CLAUDE.local.md | 304 ++++++++++++++++++ .../captain/tools/add_private_note_tool.rb | 2 + 2 files changed, 306 insertions(+) create mode 100644 CLAUDE.local.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 000000000..4686565e1 --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,304 @@ +## How to write tests + +Before you start writing any new tests, run the entire test suite to ensure everything is working as expected. Fix any issues that arise. Here's some things to keep in mind: + +1. Always fix the tests one by one +2. Ensure each test passes before moving on to the next. +3. Once done, run rubocop -A for the test file, but ignore errors +4. Commit the existing fixes with a appropriate semantic commit message + +Once done, we can start testing the new stuff. Here's a pattern you need to follow: + +1. Create the skeleton of the test +2. Write an example, run it to ensure it passes +3. Run rubocop -A for the test file, but ignore errors +4. Once all the tests are complete for a file + +**NEVER ADD CO-AUTHOR TO THE COMMIT** + + +# Chatwoot Captain Agent System - AIAgents Ruby Gem Integration + +## AI Agents Documentation Index + +### Core Concepts +- **[Agents](https://ruby-ai-agents.netlify.app/concepts/agents.html)** - Core agent concepts and creation +- **[Tools](https://ruby-ai-agents.netlify.app/concepts/tools.html)** - Tool implementation and usage +- **[Agent-Tool Pattern](https://ruby-ai-agents.netlify.app/concepts/agent-tool.html)** - How agents interact with tools +- **[Context](https://ruby-ai-agents.netlify.app/concepts/context.html)** - Managing shared context between agents +- **[Handoffs](https://ruby-ai-agents.netlify.app/concepts/handoffs.html)** - Agent-to-agent handoff mechanisms +- **[Runner](https://ruby-ai-agents.netlify.app/concepts/runner.html)** - AgentRunner for orchestration +- **[Callbacks](https://ruby-ai-agents.netlify.app/concepts/callbacks.html)** - Event callbacks and hooks + +### Integration Guides +- **[Rails Integration](https://ruby-ai-agents.netlify.app/guides/rails-integration.html)** - Integrating with Rails applications +- **[Multi-Agent Systems](https://ruby-ai-agents.netlify.app/guides/multi-agent-systems.html)** - Building complex agent systems +- **[Agent as Tool Pattern](https://ruby-ai-agents.netlify.app/guides/agent-as-tool-pattern.html)** - Using agents as tools +- **[State Persistence](https://ruby-ai-agents.netlify.app/guides/state-persistence.html)** - Persisting agent state + +### General +- **[Homepage](https://ruby-ai-agents.netlify.app/)** - Overview and getting started +- **[Concepts](https://ruby-ai-agents.netlify.app/concepts.html)** - All concepts overview +- **[Guides](https://ruby-ai-agents.netlify.app/guides.html)** - All guides overview + +## Overview + +The Chatwoot Captain agent system is built using the `ai-agents` gem (version >= 0.2.1) to provide AI-powered automation capabilities. The system follows a modular architecture with scenarios, assistants, and tools. + +## Core Components + +### 1. Captain::Scenario (`enterprise/app/models/captain/scenario.rb`) +- Main entity that defines agent behaviors and configurations +- Belongs to a `Captain::Assistant` and `Account` +- Key attributes: + - `title`: Scenario name + - `description`: What the scenario does + - `instruction`: Instructions for the agent with tool references + - `tools`: JSONB array of tool IDs + - `enabled`: Boolean flag + +#### Agent Creation +```ruby +def agent(user) + tool_instances = agent_tools.map { |tool| tool.new(assistant, user: user) } + Agents::Agent.new( + name: "#{title} Agent".titleize, + instructions: agent_instructions, + tools: tool_instances + ) +end +``` + +### 2. Captain::Assistant (`enterprise/app/models/captain/assistant.rb`) +- Represents an AI assistant that can have multiple scenarios +- Has many: + - `scenarios`: Different agent behaviors + - `documents`: Knowledge base documents + - `captain_inboxes`: Connected inboxes + - `copilot_threads`: Conversation threads + - `responses`: Historical responses + +### 3. Tool System + +#### Base Tool Class (`enterprise/lib/captain/tools/base_agent_tool.rb`) +```ruby +class Captain::Tools::BaseAgentTool < Agents::Tool + def initialize(assistant, user: nil) + @assistant = assistant + @user = user + @account_user = find_account_user if @user.present? + super() + end +``` + +- Extends `Agents::Tool` from the ai-agents gem +- Provides permission checking via `active?` method +- Helper methods for account-scoped queries and logging + +#### Available Tools +From `config/agents/tools.yml`: +1. **add_contact_note**: Add notes to contact profiles +2. **add_private_note**: Add internal notes to conversations +3. **update_priority**: Change conversation priority levels +4. **search_contact**: Search contacts by email/phone/identifier +5. **add_label_to_conversation**: Tag conversations with labels + +#### Tool Implementation Pattern +All tools now follow a consistent pattern using the ai-agents DSL: + +```ruby +class Captain::Tools::AddContactNoteTool < Captain::Tools::BaseAgentTool + description 'Add a note to a contact profile' + param :contact_id, type: 'string', desc: 'The ID of the contact' + param :note, type: 'string', desc: 'The note content to add to the contact' + + def perform(_tool_context, note:, contact_id:) + # Implementation + end +end +``` + +**Key aspects of the pattern:** +- Use `description` class method to describe the tool's purpose +- Use `param` class method to define parameters with type and description +- Implement `perform` method that receives tool context and named parameters +- Return structured responses (success/error) for consistent handling + +### 4. Tool Resolution System (`CaptainToolsHelpers`) +- Provides methods to load and resolve tools +- Tool references in instructions use format: `(tool://tool_id)` +- Automatically extracts and validates tool references from scenario instructions + +## Integration with AIAgents Gem + +### Configuration +The ai-agents gem is configured with API keys (likely in an initializer): +```ruby +Agents.configure do |config| + config.openai_api_key = ENV['OPENAI_API_KEY'] + # Other provider keys +end +``` + +### Agent Creation Flow +1. User creates a `Captain::Scenario` with instructions and tool references +2. Scenario validates tool references against available tools +3. When needed, scenario creates an `Agents::Agent` instance with: + - Name derived from scenario title + - Instructions including resolved tool descriptions + - Tool instances initialized with assistant and user context + +### Tool Execution +- Tools inherit from `Agents::Tool` base class +- Each tool has access to: + - `@assistant`: The Captain assistant instance + - `@user`: Current user executing the tool + - `@account_user`: Account-specific user permissions +- Tools check permissions before execution +- All actions are logged for audit trails + +## Security & Permissions +- Tools implement `active?` method to check user permissions +- Permissions checked include: + - `contact_manage`: For contact-related operations + - `conversation_manage`: For conversation updates + - `conversation_unassigned_manage`: For unassigned conversations + - `conversation_participating_manage`: For participated conversations +- Account-scoped queries prevent cross-account data access + +## Current State +All tools have been updated to use the ai-agents gem's DSL for consistent parameter definitions. The integration provides a flexible framework for adding new AI-powered automation capabilities to Chatwoot. + +## Assistant and Scenario Agent Architecture + +### Assistant as Main Agent +The `Captain::Assistant` model now has an `agent` method that creates the main assistant agent with scenario handoffs: + +```ruby +def agent(user) + # Get enabled scenario agents as handoff agents + handoff_agents = scenarios.enabled.map { |scenario| scenario.agent(user) } + + # Create the main assistant agent with scenario agents as handoffs + Agents::Agent.new( + name: name, + instructions: agent_instructions, + handoff_agents: handoff_agents + ) +end +``` + +### Handoff Pattern +- Assistant agent analyzes user requests and determines if they match specific scenarios +- When a match is found, the assistant hands off to the appropriate scenario agent +- Scenario agents have specialized instructions and tools for their specific tasks + +## Conversation Context Management + +### Current Implementation +The existing system maintains conversation context through: + +1. **Message History Collection** (`Captain::Conversation::ResponseBuilderJob`): +```ruby +def collect_previous_messages + @conversation + .messages + .where(message_type: [:incoming, :outgoing]) + .where(private: false) + .map do |message| + { + content: prepare_multimodal_message_content(message), + role: determine_role(message) + } + end +end +``` + +2. **Message Content Building** (`Captain::OpenAiMessageBuilderService`): +- Handles text content +- Processes image attachments +- Includes audio transcriptions +- Supports multimodal messages + +3. **Direct OpenAI Integration**: +- Uses `Captain::Llm::AssistantChatService` for chat completions +- Maintains message history as an array of role/content pairs +- Not yet using the ai-agents gem's context management + +### AI-Agents Context Management +The ai-agents gem provides sophisticated context management: + +1. **Context Structure**: +- Serializable state management system +- Preserves information across agent interactions +- Can be stored and restored between sessions + +2. **Usage with AgentRunner**: +```ruby +runner = Agents::AgentRunner.new(agent: assistant_agent) +result = runner.run(message, context: previous_context) +new_context = result.context +``` + +3. **Integration Opportunity**: +- Convert conversation messages to ai-agents context format +- Persist context between conversation turns +- Enable seamless handoffs with shared context + +## Current Captain Assistant Flow + +### 1. Message Creation Trigger +When a new message is created in a conversation: +- `Message#execute_after_create_commit_callbacks` is called +- This invokes `execute_message_template_hooks` +- Which calls `MessageTemplates::HookExecutionService.new(message: self).perform` + +### 2. Hook Execution Service (Enterprise Module) +The enterprise module `Enterprise::MessageTemplates::HookExecutionService` is prepended to the base service: +- Checks `should_process_captain_response?`: + - Conversation must be pending + - Message must be incoming + - Inbox must have a captain_assistant assigned +- Checks if `inbox.captain_active?`: + - Captain assistant must be present + - Account must have available captain responses (usage limits) +- If active, schedules `Captain::Conversation::ResponseBuilderJob` +- If not active (limit exceeded), performs handoff + +### 3. Response Builder Job +`Captain::Conversation::ResponseBuilderJob` processes the assistant response: +1. **Collects message history**: + - Fetches all non-private incoming/outgoing messages from conversation + - Uses `Captain::OpenAiMessageBuilderService` to format messages (handles text, images, audio transcriptions) + - Maps messages to role/content format for OpenAI + +2. **Generates response**: + - Uses `Captain::Llm::AssistantChatService` with the assistant + - Passes message_history to `generate_response` + - This service uses direct OpenAI API calls (not ai-agents gem) + +3. **Processes response**: + - If response is 'conversation_handoff', triggers handoff + - Otherwise, creates outgoing message with assistant as sender + - Increments account's response usage counter + +### 4. Key Models and Associations +- **Inbox** has_one **CaptainInbox** has_one **Captain::Assistant** +- **Captain::Assistant** has_many **scenarios** (but not used in current flow) +- **Message** belongs_to **sender** (polymorphic - can be Captain::Assistant) + +### 5. Limitations of Current Implementation +- Uses direct OpenAI API integration, not ai-agents gem +- No scenario execution or handoffs to specialized agents +- No persistent context management between conversations +- Tool execution happens through a separate registry system, not ai-agents tools + +## Notes +- The `Captain::Agent` class in `enterprise/lib/captain/agent.rb` appears to be a legacy implementation +- Current assistant implementation uses direct OpenAI API calls via `AssistantChatService` +- Scenario agents are defined but not yet integrated into the conversation flow +- To fully leverage the ai-agents gem, a new service using `AgentRunner` needs to be created that: + - Uses the assistant's `agent(user)` method + - Manages context across conversation turns + - Enables handoffs to scenario agents + - Integrates with the existing conversation flow 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) From d9a0d4eee02a3434215bd8d4edd4b33fcb9771f2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 10:22:08 +0530 Subject: [PATCH 10/14] refactor: safer state access --- enterprise/lib/captain/tools/base_public_tool.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From e6d720082c9037d405ef2e76b14b68cdd25f7d38 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 10:28:39 +0530 Subject: [PATCH 11/14] test: captain tools --- .../tools/add_contact_note_tool_spec.rb | 116 ++++++++++++++++ .../add_label_to_conversation_tool_spec.rb | 125 ++++++++++++++++++ .../tools/add_private_note_tool_spec.rb | 124 +++++++++++++++++ .../tools/update_priority_tool_spec.rb | 117 ++++++++++++++++ 4 files changed, 482 insertions(+) create mode 100644 spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb 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 From b771652656f93b7b6f9f4df54b9503f6ece2a3eb Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 10:30:21 +0530 Subject: [PATCH 12/14] feat: remove claude local --- CLAUDE.local.md | 304 ------------------------------------------------ 1 file changed, 304 deletions(-) delete mode 100644 CLAUDE.local.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md deleted file mode 100644 index 4686565e1..000000000 --- a/CLAUDE.local.md +++ /dev/null @@ -1,304 +0,0 @@ -## How to write tests - -Before you start writing any new tests, run the entire test suite to ensure everything is working as expected. Fix any issues that arise. Here's some things to keep in mind: - -1. Always fix the tests one by one -2. Ensure each test passes before moving on to the next. -3. Once done, run rubocop -A for the test file, but ignore errors -4. Commit the existing fixes with a appropriate semantic commit message - -Once done, we can start testing the new stuff. Here's a pattern you need to follow: - -1. Create the skeleton of the test -2. Write an example, run it to ensure it passes -3. Run rubocop -A for the test file, but ignore errors -4. Once all the tests are complete for a file - -**NEVER ADD CO-AUTHOR TO THE COMMIT** - - -# Chatwoot Captain Agent System - AIAgents Ruby Gem Integration - -## AI Agents Documentation Index - -### Core Concepts -- **[Agents](https://ruby-ai-agents.netlify.app/concepts/agents.html)** - Core agent concepts and creation -- **[Tools](https://ruby-ai-agents.netlify.app/concepts/tools.html)** - Tool implementation and usage -- **[Agent-Tool Pattern](https://ruby-ai-agents.netlify.app/concepts/agent-tool.html)** - How agents interact with tools -- **[Context](https://ruby-ai-agents.netlify.app/concepts/context.html)** - Managing shared context between agents -- **[Handoffs](https://ruby-ai-agents.netlify.app/concepts/handoffs.html)** - Agent-to-agent handoff mechanisms -- **[Runner](https://ruby-ai-agents.netlify.app/concepts/runner.html)** - AgentRunner for orchestration -- **[Callbacks](https://ruby-ai-agents.netlify.app/concepts/callbacks.html)** - Event callbacks and hooks - -### Integration Guides -- **[Rails Integration](https://ruby-ai-agents.netlify.app/guides/rails-integration.html)** - Integrating with Rails applications -- **[Multi-Agent Systems](https://ruby-ai-agents.netlify.app/guides/multi-agent-systems.html)** - Building complex agent systems -- **[Agent as Tool Pattern](https://ruby-ai-agents.netlify.app/guides/agent-as-tool-pattern.html)** - Using agents as tools -- **[State Persistence](https://ruby-ai-agents.netlify.app/guides/state-persistence.html)** - Persisting agent state - -### General -- **[Homepage](https://ruby-ai-agents.netlify.app/)** - Overview and getting started -- **[Concepts](https://ruby-ai-agents.netlify.app/concepts.html)** - All concepts overview -- **[Guides](https://ruby-ai-agents.netlify.app/guides.html)** - All guides overview - -## Overview - -The Chatwoot Captain agent system is built using the `ai-agents` gem (version >= 0.2.1) to provide AI-powered automation capabilities. The system follows a modular architecture with scenarios, assistants, and tools. - -## Core Components - -### 1. Captain::Scenario (`enterprise/app/models/captain/scenario.rb`) -- Main entity that defines agent behaviors and configurations -- Belongs to a `Captain::Assistant` and `Account` -- Key attributes: - - `title`: Scenario name - - `description`: What the scenario does - - `instruction`: Instructions for the agent with tool references - - `tools`: JSONB array of tool IDs - - `enabled`: Boolean flag - -#### Agent Creation -```ruby -def agent(user) - tool_instances = agent_tools.map { |tool| tool.new(assistant, user: user) } - Agents::Agent.new( - name: "#{title} Agent".titleize, - instructions: agent_instructions, - tools: tool_instances - ) -end -``` - -### 2. Captain::Assistant (`enterprise/app/models/captain/assistant.rb`) -- Represents an AI assistant that can have multiple scenarios -- Has many: - - `scenarios`: Different agent behaviors - - `documents`: Knowledge base documents - - `captain_inboxes`: Connected inboxes - - `copilot_threads`: Conversation threads - - `responses`: Historical responses - -### 3. Tool System - -#### Base Tool Class (`enterprise/lib/captain/tools/base_agent_tool.rb`) -```ruby -class Captain::Tools::BaseAgentTool < Agents::Tool - def initialize(assistant, user: nil) - @assistant = assistant - @user = user - @account_user = find_account_user if @user.present? - super() - end -``` - -- Extends `Agents::Tool` from the ai-agents gem -- Provides permission checking via `active?` method -- Helper methods for account-scoped queries and logging - -#### Available Tools -From `config/agents/tools.yml`: -1. **add_contact_note**: Add notes to contact profiles -2. **add_private_note**: Add internal notes to conversations -3. **update_priority**: Change conversation priority levels -4. **search_contact**: Search contacts by email/phone/identifier -5. **add_label_to_conversation**: Tag conversations with labels - -#### Tool Implementation Pattern -All tools now follow a consistent pattern using the ai-agents DSL: - -```ruby -class Captain::Tools::AddContactNoteTool < Captain::Tools::BaseAgentTool - description 'Add a note to a contact profile' - param :contact_id, type: 'string', desc: 'The ID of the contact' - param :note, type: 'string', desc: 'The note content to add to the contact' - - def perform(_tool_context, note:, contact_id:) - # Implementation - end -end -``` - -**Key aspects of the pattern:** -- Use `description` class method to describe the tool's purpose -- Use `param` class method to define parameters with type and description -- Implement `perform` method that receives tool context and named parameters -- Return structured responses (success/error) for consistent handling - -### 4. Tool Resolution System (`CaptainToolsHelpers`) -- Provides methods to load and resolve tools -- Tool references in instructions use format: `(tool://tool_id)` -- Automatically extracts and validates tool references from scenario instructions - -## Integration with AIAgents Gem - -### Configuration -The ai-agents gem is configured with API keys (likely in an initializer): -```ruby -Agents.configure do |config| - config.openai_api_key = ENV['OPENAI_API_KEY'] - # Other provider keys -end -``` - -### Agent Creation Flow -1. User creates a `Captain::Scenario` with instructions and tool references -2. Scenario validates tool references against available tools -3. When needed, scenario creates an `Agents::Agent` instance with: - - Name derived from scenario title - - Instructions including resolved tool descriptions - - Tool instances initialized with assistant and user context - -### Tool Execution -- Tools inherit from `Agents::Tool` base class -- Each tool has access to: - - `@assistant`: The Captain assistant instance - - `@user`: Current user executing the tool - - `@account_user`: Account-specific user permissions -- Tools check permissions before execution -- All actions are logged for audit trails - -## Security & Permissions -- Tools implement `active?` method to check user permissions -- Permissions checked include: - - `contact_manage`: For contact-related operations - - `conversation_manage`: For conversation updates - - `conversation_unassigned_manage`: For unassigned conversations - - `conversation_participating_manage`: For participated conversations -- Account-scoped queries prevent cross-account data access - -## Current State -All tools have been updated to use the ai-agents gem's DSL for consistent parameter definitions. The integration provides a flexible framework for adding new AI-powered automation capabilities to Chatwoot. - -## Assistant and Scenario Agent Architecture - -### Assistant as Main Agent -The `Captain::Assistant` model now has an `agent` method that creates the main assistant agent with scenario handoffs: - -```ruby -def agent(user) - # Get enabled scenario agents as handoff agents - handoff_agents = scenarios.enabled.map { |scenario| scenario.agent(user) } - - # Create the main assistant agent with scenario agents as handoffs - Agents::Agent.new( - name: name, - instructions: agent_instructions, - handoff_agents: handoff_agents - ) -end -``` - -### Handoff Pattern -- Assistant agent analyzes user requests and determines if they match specific scenarios -- When a match is found, the assistant hands off to the appropriate scenario agent -- Scenario agents have specialized instructions and tools for their specific tasks - -## Conversation Context Management - -### Current Implementation -The existing system maintains conversation context through: - -1. **Message History Collection** (`Captain::Conversation::ResponseBuilderJob`): -```ruby -def collect_previous_messages - @conversation - .messages - .where(message_type: [:incoming, :outgoing]) - .where(private: false) - .map do |message| - { - content: prepare_multimodal_message_content(message), - role: determine_role(message) - } - end -end -``` - -2. **Message Content Building** (`Captain::OpenAiMessageBuilderService`): -- Handles text content -- Processes image attachments -- Includes audio transcriptions -- Supports multimodal messages - -3. **Direct OpenAI Integration**: -- Uses `Captain::Llm::AssistantChatService` for chat completions -- Maintains message history as an array of role/content pairs -- Not yet using the ai-agents gem's context management - -### AI-Agents Context Management -The ai-agents gem provides sophisticated context management: - -1. **Context Structure**: -- Serializable state management system -- Preserves information across agent interactions -- Can be stored and restored between sessions - -2. **Usage with AgentRunner**: -```ruby -runner = Agents::AgentRunner.new(agent: assistant_agent) -result = runner.run(message, context: previous_context) -new_context = result.context -``` - -3. **Integration Opportunity**: -- Convert conversation messages to ai-agents context format -- Persist context between conversation turns -- Enable seamless handoffs with shared context - -## Current Captain Assistant Flow - -### 1. Message Creation Trigger -When a new message is created in a conversation: -- `Message#execute_after_create_commit_callbacks` is called -- This invokes `execute_message_template_hooks` -- Which calls `MessageTemplates::HookExecutionService.new(message: self).perform` - -### 2. Hook Execution Service (Enterprise Module) -The enterprise module `Enterprise::MessageTemplates::HookExecutionService` is prepended to the base service: -- Checks `should_process_captain_response?`: - - Conversation must be pending - - Message must be incoming - - Inbox must have a captain_assistant assigned -- Checks if `inbox.captain_active?`: - - Captain assistant must be present - - Account must have available captain responses (usage limits) -- If active, schedules `Captain::Conversation::ResponseBuilderJob` -- If not active (limit exceeded), performs handoff - -### 3. Response Builder Job -`Captain::Conversation::ResponseBuilderJob` processes the assistant response: -1. **Collects message history**: - - Fetches all non-private incoming/outgoing messages from conversation - - Uses `Captain::OpenAiMessageBuilderService` to format messages (handles text, images, audio transcriptions) - - Maps messages to role/content format for OpenAI - -2. **Generates response**: - - Uses `Captain::Llm::AssistantChatService` with the assistant - - Passes message_history to `generate_response` - - This service uses direct OpenAI API calls (not ai-agents gem) - -3. **Processes response**: - - If response is 'conversation_handoff', triggers handoff - - Otherwise, creates outgoing message with assistant as sender - - Increments account's response usage counter - -### 4. Key Models and Associations -- **Inbox** has_one **CaptainInbox** has_one **Captain::Assistant** -- **Captain::Assistant** has_many **scenarios** (but not used in current flow) -- **Message** belongs_to **sender** (polymorphic - can be Captain::Assistant) - -### 5. Limitations of Current Implementation -- Uses direct OpenAI API integration, not ai-agents gem -- No scenario execution or handoffs to specialized agents -- No persistent context management between conversations -- Tool execution happens through a separate registry system, not ai-agents tools - -## Notes -- The `Captain::Agent` class in `enterprise/lib/captain/agent.rb` appears to be a legacy implementation -- Current assistant implementation uses direct OpenAI API calls via `AssistantChatService` -- Scenario agents are defined but not yet integrated into the conversation flow -- To fully leverage the ai-agents gem, a new service using `AgentRunner` needs to be created that: - - Uses the assistant's `agent(user)` method - - Manages context across conversation turns - - Enables handoffs to scenario agents - - Integrates with the existing conversation flow From 090316a078f41b9621bcda9c92661d1ce3fbf57e Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Wed, 16 Jul 2025 00:09:26 -0700 Subject: [PATCH 13/14] Bump version to 4.4.0 --- config/app.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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}", From 2e1529c755c41f0488e12e1dc6221850f47335bf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 16 Jul 2025 16:57:19 +0530 Subject: [PATCH 14/14] fix: tool mention format --- enterprise/app/models/captain/scenario.rb | 6 +++--- .../models/concerns/captain_tools_helpers.rb | 4 ++-- .../concerns/captain_tools_helpers_spec.rb | 18 +++++++++++------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index 69fc9eef1..ecfba396f 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -48,11 +48,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 @@ -76,7 +76,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 311d7e277..5a660310c 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -4,8 +4,8 @@ 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/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb index 3d88522d9..afe482385 100644 --- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -16,7 +16,7 @@ RSpec.describe Concerns::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)' + 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]) @@ -28,7 +28,11 @@ RSpec.describe Concerns::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| @@ -136,14 +140,14 @@ RSpec.describe Concerns::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 Concerns::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)