-
-
Current plan
-
-
- Refresh
-
+ <% if ChatwootApp.enterprise? %>
+
+
+
+
<%= 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] %>
-
-
- Upgrade now
-
+ <% if ChatwootApp.enterprise? %>
+ <%= render 'upgrade_button_enterprise' %>
+ <% else %>
+ <%= render 'upgrade_button_community' %>
+ <% end %>
<% end %>
diff --git a/config/app.yml b/config/app.yml
index 6ba134f19..e6fc39be3 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.3.0'
+ version: '4.4.0'
development:
<<: *shared
diff --git a/config/application.rb b/config/application.rb
index 96025e9f9..5316e65bf 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -43,7 +43,6 @@ module Chatwoot
config.eager_load_paths << Rails.root.join('enterprise/listeners')
# rubocop:disable Rails/FilePath
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
- config.eager_load_paths << Rails.root.join('enterprise/app/models/concerns')
# rubocop:enable Rails/FilePath
# Add enterprise views to the view paths
config.paths['app/views'].unshift('enterprise/app/views')
diff --git a/config/features.yml b/config/features.yml
index 99d7bea6d..4706c46f0 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -180,6 +180,10 @@
display_name: Captain V2
enabled: false
premium: true
+ chatwoot_internal: true
- name: whatsapp_embedded_signup
display_name: WhatsApp Embedded Signup
enabled: false
+- name: whatsapp_campaign
+ display_name: WhatsApp Campaign
+ enabled: false
diff --git a/config/installation_config.yml b/config/installation_config.yml
index b17d3cec0..2e8d94a96 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -87,11 +87,14 @@
# ------- Email Related Config ------- #
- name: MAILER_INBOUND_EMAIL_DOMAIN
+ display_title: 'Inbound Email Domain'
value:
description: 'The domain name to be used for generating conversation continuity emails (reply+id@domain.com)'
locked: false
- name: MAILER_SUPPORT_EMAIL
+ display_title: 'Support Email'
value:
+ description: 'The support email address for your installation'
locked: false
# ------- End of Email Related Config ------- #
@@ -394,3 +397,22 @@
locked: false
type: secret
# ------- End of OG Image Related Config ------- #
+
+## ------ Configs added for Google OAuth ------ ##
+- name: GOOGLE_OAUTH_CLIENT_ID
+ display_title: 'Google OAuth Client ID'
+ value:
+ locked: false
+ description: 'Google OAuth Client ID for email authentication'
+- name: GOOGLE_OAUTH_CLIENT_SECRET
+ display_title: 'Google OAuth Client Secret'
+ value:
+ locked: false
+ description: 'Google OAuth Client Secret for email authentication'
+ type: secret
+- name: GOOGLE_OAUTH_REDIRECT_URI
+ display_title: 'Google OAuth Redirect URI'
+ value:
+ locked: false
+ description: 'The redirect URI configured in your Google OAuth app'
+## ------ End of Configs added for Google OAuth ------ ##
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index af813d92a..d2d764382 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -23,6 +23,7 @@
- action_mailbox_routing
- low
- scheduled_jobs
+ - deferred
- housekeeping
- async_database_migration
- active_storage_analysis
diff --git a/db/migrate/20250709102213_add_template_params_to_campaigns.rb b/db/migrate/20250709102213_add_template_params_to_campaigns.rb
new file mode 100644
index 000000000..d70359b30
--- /dev/null
+++ b/db/migrate/20250709102213_add_template_params_to_campaigns.rb
@@ -0,0 +1,5 @@
+class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
+ def change
+ add_column :campaigns, :template_params, :jsonb, default: {}, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1806ab23f..34637315b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
t.jsonb "audience", default: []
t.datetime "scheduled_at", precision: nil
t.boolean "trigger_only_during_business_hours", default: false
+ t.jsonb "template_params"
t.index ["account_id"], name: "index_campaigns_on_account_id"
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index d008a8852..23d50e400 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -80,11 +80,11 @@ class Captain::Scenario < ApplicationRecord
# @return [void]
# @api private
# @example Valid instruction
- # scenario.instruction = "Use (tool://add_contact_note) to document"
+ # scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
# scenario.valid? # => true
#
# @example Invalid instruction
- # scenario.instruction = "Use (tool://invalid_tool) to process"
+ # scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
# scenario.valid? # => false
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
def validate_instruction_tools
@@ -108,7 +108,7 @@ class Captain::Scenario < ApplicationRecord
# @return [void]
# @api private
# @example
- # scenario.instruction = "First (tool://add_private_note) then (tool://update_priority)"
+ # scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
# scenario.save!
# scenario.tools # => ["add_private_note", "update_priority"]
#
diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb
index 7aa924d3d..5a660310c 100644
--- a/enterprise/app/models/concerns/captain_tools_helpers.rb
+++ b/enterprise/app/models/concerns/captain_tools_helpers.rb
@@ -1,11 +1,11 @@
# Provides helper methods for working with Captain agent tools including
# tool resolution, text parsing, and metadata retrieval.
-module CaptainToolsHelpers
+module Concerns::CaptainToolsHelpers
extend ActiveSupport::Concern
# Regular expression pattern for matching tool references in text.
- # Matches patterns like (tool://tool_id) following Chatwoot's mention syntax.
- TOOL_REFERENCE_REGEX = %r{\(tool://([^/)]+)\)}
+ # Matches patterns like [Tool name](tool://tool_id) following markdown link syntax.
+ TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
class_methods do
# Returns all available agent tools with their metadata.
diff --git a/enterprise/lib/captain/tools/add_private_note_tool.rb b/enterprise/lib/captain/tools/add_private_note_tool.rb
index 4a2328d43..36e1ef977 100644
--- a/enterprise/lib/captain/tools/add_private_note_tool.rb
+++ b/enterprise/lib/captain/tools/add_private_note_tool.rb
@@ -6,6 +6,8 @@ class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
+ return 'Note content is required' if note.blank?
+
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
create_private_note(conversation, note)
diff --git a/enterprise/lib/captain/tools/base_public_tool.rb b/enterprise/lib/captain/tools/base_public_tool.rb
index e53f7a0f1..e1f779b36 100644
--- a/enterprise/lib/captain/tools/base_public_tool.rb
+++ b/enterprise/lib/captain/tools/base_public_tool.rb
@@ -24,14 +24,14 @@ class Captain::Tools::BasePublicTool < Agents::Tool
end
def find_conversation(state)
- conversation_id = state[:conversation][:id]
+ conversation_id = state&.dig(:conversation, :id)
return nil unless conversation_id
account_scoped(::Conversation).find_by(id: conversation_id)
end
def find_contact(state)
- contact_id = state[:contact][:id]
+ contact_id = state&.dig(:contact, :id)
return nil unless contact_id
account_scoped(::Contact).find_by(id: contact_id)
diff --git a/lib/chatwoot_hub.rb b/lib/chatwoot_hub.rb
index 0d99becd3..c18fb299b 100644
--- a/lib/chatwoot_hub.rb
+++ b/lib/chatwoot_hub.rb
@@ -19,10 +19,14 @@ class ChatwootHub
end
def self.pricing_plan
+ return 'community' unless ChatwootApp.enterprise?
+
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
end
def self.pricing_plan_quantity
+ return 0 unless ChatwootApp.enterprise?
+
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
end
diff --git a/package.json b/package.json
index 1ca8d1025..e0fd2cf7b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.3.0",
+ "version": "4.4.0",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
diff --git a/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
new file mode 100644
index 000000000..c087242dc
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
@@ -0,0 +1,116 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::AddContactNoteTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:tool) { described_class.new(assistant) }
+ let(:user) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:tool_context) { Struct.new(:state).new({ contact: { id: contact.id } }) }
+
+ describe '#description' do
+ it 'returns the correct description' do
+ expect(tool.description).to eq('Add a note to a contact profile')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the correct parameters' do
+ expect(tool.parameters).to have_key(:note)
+ expect(tool.parameters[:note].name).to eq(:note)
+ expect(tool.parameters[:note].type).to eq('string')
+ expect(tool.parameters[:note].description).to eq('The note content to add to the contact')
+ end
+ end
+
+ describe '#perform' do
+ context 'when contact exists' do
+ context 'with valid note content' do
+ it 'creates a contact note and returns success message' do
+ note_content = 'This is a contact note'
+
+ expect do
+ result = tool.perform(tool_context, note: note_content)
+ expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})")
+ end.to change(Note, :count).by(1)
+
+ created_note = Note.last
+ expect(created_note.content).to eq(note_content)
+ expect(created_note.account).to eq(account)
+ expect(created_note.contact).to eq(contact)
+ expect(created_note.user).to eq(assistant.account.users.first)
+ end
+
+ it 'logs tool usage' do
+ expect(tool).to receive(:log_tool_usage).with(
+ 'add_contact_note',
+ { contact_id: contact.id, note_length: 19 }
+ )
+
+ tool.perform(tool_context, note: 'This is a test note')
+ end
+ end
+
+ context 'with blank note content' do
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: '')
+ expect(result).to eq('Note content is required')
+ end
+
+ it 'does not create a note' do
+ expect do
+ tool.perform(tool_context, note: '')
+ end.not_to change(Note, :count)
+ end
+ end
+
+ context 'with nil note content' do
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: nil)
+ expect(result).to eq('Note content is required')
+ end
+ end
+ end
+
+ context 'when contact does not exist' do
+ let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Contact not found')
+ end
+
+ it 'does not create a note' do
+ expect do
+ tool.perform(tool_context, note: 'Some note')
+ end.not_to change(Note, :count)
+ end
+ end
+
+ context 'when contact state is missing' do
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Contact not found')
+ end
+ end
+
+ context 'when contact id is nil' do
+ let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Contact not found')
+ end
+ end
+ end
+
+ describe '#active?' do
+ it 'returns true for public tools' do
+ expect(tool.active?).to be true
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb
new file mode 100644
index 000000000..38e4dc7c6
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/add_label_to_conversation_tool_spec.rb
@@ -0,0 +1,125 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::AddLabelToConversationTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:tool) { described_class.new(assistant) }
+ let(:user) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:label) { create(:label, account: account, title: 'urgent') }
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
+
+ describe '#description' do
+ it 'returns the correct description' do
+ expect(tool.description).to eq('Add a label to a conversation')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the correct parameters' do
+ expect(tool.parameters).to have_key(:label_name)
+ expect(tool.parameters[:label_name].name).to eq(:label_name)
+ expect(tool.parameters[:label_name].type).to eq('string')
+ expect(tool.parameters[:label_name].description).to eq('The name of the label to add')
+ end
+ end
+
+ describe '#perform' do
+ context 'when conversation exists' do
+ context 'with valid label that exists' do
+ before { label }
+
+ it 'adds label to conversation and returns success message' do
+ result = tool.perform(tool_context, label_name: 'urgent')
+ expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
+
+ expect(conversation.reload.label_list).to include('urgent')
+ end
+
+ it 'logs tool usage' do
+ expect(tool).to receive(:log_tool_usage).with(
+ 'added_label',
+ { conversation_id: conversation.id, label: 'urgent' }
+ )
+
+ tool.perform(tool_context, label_name: 'urgent')
+ end
+
+ it 'handles case insensitive label names' do
+ result = tool.perform(tool_context, label_name: 'URGENT')
+ expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
+ end
+
+ it 'strips whitespace from label names' do
+ result = tool.perform(tool_context, label_name: ' urgent ')
+ expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
+ end
+ end
+
+ context 'with label that does not exist' do
+ it 'returns error message' do
+ result = tool.perform(tool_context, label_name: 'nonexistent')
+ expect(result).to eq('Label not found')
+ end
+
+ it 'does not add any labels to conversation' do
+ expect do
+ tool.perform(tool_context, label_name: 'nonexistent')
+ end.not_to(change { conversation.reload.labels.count })
+ end
+ end
+
+ context 'with blank label name' do
+ it 'returns error message for empty string' do
+ result = tool.perform(tool_context, label_name: '')
+ expect(result).to eq('Label name is required')
+ end
+
+ it 'returns error message for nil' do
+ result = tool.perform(tool_context, label_name: nil)
+ expect(result).to eq('Label name is required')
+ end
+
+ it 'returns error message for whitespace only' do
+ result = tool.perform(tool_context, label_name: ' ')
+ expect(result).to eq('Label name is required')
+ end
+ end
+ end
+
+ context 'when conversation does not exist' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, label_name: 'urgent')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation state is missing' do
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, label_name: 'urgent')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation id is nil' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, label_name: 'urgent')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+ end
+
+ describe '#active?' do
+ it 'returns true for public tools' do
+ expect(tool.active?).to be true
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
new file mode 100644
index 000000000..cfce1a7d1
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
@@ -0,0 +1,124 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:tool) { described_class.new(assistant) }
+ let(:user) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
+
+ describe '#description' do
+ it 'returns the correct description' do
+ expect(tool.description).to eq('Add a private note to a conversation')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the correct parameters' do
+ expect(tool.parameters).to have_key(:note)
+ expect(tool.parameters[:note].name).to eq(:note)
+ expect(tool.parameters[:note].type).to eq('string')
+ expect(tool.parameters[:note].description).to eq('The private note content')
+ end
+ end
+
+ describe '#perform' do
+ context 'when conversation exists' do
+ context 'with valid note content' do
+ it 'creates a private note and returns success message' do
+ note_content = 'This is a private note'
+
+ expect do
+ result = tool.perform(tool_context, note: note_content)
+ expect(result).to eq('Private note added successfully')
+ end.to change(Message, :count).by(1)
+ end
+
+ it 'creates a private note with correct attributes' do
+ note_content = 'This is a private note'
+
+ tool.perform(tool_context, note: note_content)
+
+ created_message = Message.last
+ expect(created_message.content).to eq(note_content)
+ expect(created_message.message_type).to eq('outgoing')
+ expect(created_message.private).to be true
+ expect(created_message.account).to eq(account)
+ expect(created_message.inbox).to eq(inbox)
+ expect(created_message.conversation).to eq(conversation)
+ end
+
+ it 'logs tool usage' do
+ expect(tool).to receive(:log_tool_usage).with(
+ 'add_private_note',
+ { conversation_id: conversation.id, note_length: 19 }
+ )
+
+ tool.perform(tool_context, note: 'This is a test note')
+ end
+ end
+
+ context 'with blank note content' do
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: '')
+ expect(result).to eq('Note content is required')
+ end
+
+ it 'does not create a message' do
+ expect do
+ tool.perform(tool_context, note: '')
+ end.not_to change(Message, :count)
+ end
+ end
+
+ context 'with nil note content' do
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: nil)
+ expect(result).to eq('Note content is required')
+ end
+ end
+ end
+
+ context 'when conversation does not exist' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Conversation not found')
+ end
+
+ it 'does not create a message' do
+ expect do
+ tool.perform(tool_context, note: 'Some note')
+ end.not_to change(Message, :count)
+ end
+ end
+
+ context 'when conversation state is missing' do
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation id is nil' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, note: 'Some note')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+ end
+
+ describe '#active?' do
+ it 'returns true for public tools' do
+ expect(tool.active?).to be true
+ end
+ end
+end
diff --git a/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
new file mode 100644
index 000000000..9aa858593
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
@@ -0,0 +1,117 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::UpdatePriorityTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:tool) { described_class.new(assistant) }
+ let(:user) { create(:user, account: account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
+
+ describe '#description' do
+ it 'returns the correct description' do
+ expect(tool.description).to eq('Update the priority of a conversation')
+ end
+ end
+
+ describe '#parameters' do
+ it 'returns the correct parameters' do
+ expect(tool.parameters).to have_key(:priority)
+ expect(tool.parameters[:priority].name).to eq(:priority)
+ expect(tool.parameters[:priority].type).to eq('string')
+ expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority')
+ end
+ end
+
+ describe '#perform' do
+ context 'when conversation exists' do
+ context 'with valid priority levels' do
+ %w[low medium high urgent].each do |priority|
+ it "updates conversation priority to #{priority}" do
+ result = tool.perform(tool_context, priority: priority)
+ expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}")
+
+ expect(conversation.reload.priority).to eq(priority)
+ end
+ end
+
+ it 'removes priority when set to nil' do
+ conversation.update!(priority: 'high')
+
+ result = tool.perform(tool_context, priority: 'nil')
+ expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
+
+ expect(conversation.reload.priority).to be_nil
+ end
+
+ it 'removes priority when set to empty string' do
+ conversation.update!(priority: 'high')
+
+ result = tool.perform(tool_context, priority: '')
+ expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
+
+ expect(conversation.reload.priority).to be_nil
+ end
+
+ it 'logs tool usage' do
+ expect(tool).to receive(:log_tool_usage).with(
+ 'update_priority',
+ { conversation_id: conversation.id, priority: 'high' }
+ )
+
+ tool.perform(tool_context, priority: 'high')
+ end
+ end
+
+ context 'with invalid priority levels' do
+ it 'returns error message for invalid priority' do
+ result = tool.perform(tool_context, priority: 'invalid')
+ expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil')
+ end
+
+ it 'does not update conversation priority' do
+ original_priority = conversation.priority
+
+ tool.perform(tool_context, priority: 'invalid')
+
+ expect(conversation.reload.priority).to eq(original_priority)
+ end
+ end
+ end
+
+ context 'when conversation does not exist' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, priority: 'high')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation state is missing' do
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, priority: 'high')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+
+ context 'when conversation id is nil' do
+ let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
+
+ it 'returns error message' do
+ result = tool.perform(tool_context, priority: 'high')
+ expect(result).to eq('Conversation not found')
+ end
+ end
+ end
+
+ describe '#active?' do
+ it 'returns true for public tools' do
+ expect(tool.active?).to be true
+ end
+ end
+end
diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
index 1c441bc61..afe482385 100644
--- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
+++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
@@ -1,10 +1,10 @@
require 'rails_helper'
-RSpec.describe CaptainToolsHelpers, type: :concern do
+RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
# Create a test class that includes the concern
let(:test_class) do
Class.new do
- include CaptainToolsHelpers
+ include Concerns::CaptainToolsHelpers
def self.name
'TestClass'
@@ -16,8 +16,8 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
describe 'TOOL_REFERENCE_REGEX' do
it 'matches tool references in text' do
- text = 'Use (tool://add_contact_note) and (tool://update_priority)'
- matches = text.scan(CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
+ text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)'
+ matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches.flatten).to eq(%w[add_contact_note update_priority])
end
@@ -28,11 +28,15 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
'tool://invalid',
'(tool:invalid)',
'(tool://)',
- '(tool://with/slash)'
+ '(tool://with/slash)',
+ '(tool://add_contact_note)',
+ '[@Tool](tool://)',
+ '[Tool](tool://with/slash)',
+ '[](tool://valid)'
]
invalid_formats.each do |format|
- matches = format.scan(CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
+ matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches).to be_empty, "Should not match: #{format}"
end
end
@@ -136,14 +140,14 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
- text = 'First (tool://add_contact_note) then (tool://update_priority)'
+ text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(%w[add_contact_note update_priority])
end
it 'returns unique tool IDs' do
- text = 'Use (tool://add_contact_note) and (tool://add_contact_note) again'
+ text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(['add_contact_note'])
@@ -164,9 +168,9 @@ RSpec.describe CaptainToolsHelpers, type: :concern do
it 'handles complex text with multiple tools' do
text = <<~TEXT
- Start with (tool://add_contact_note) to document.
- Then use (tool://update_priority) if needed.
- Finally (tool://add_private_note) for internal notes.
+ Start with [@Add Contact Note](tool://add_contact_note) to document.
+ Then use [@Update Priority](tool://update_priority) if needed.
+ Finally [@Add Private Note](tool://add_private_note) for internal notes.
TEXT
result = test_instance.extract_tool_ids_from_text(text)
diff --git a/spec/factories/campaigns.rb b/spec/factories/campaigns.rb
index 4d4c6de18..c3c32d149 100644
--- a/spec/factories/campaigns.rb
+++ b/spec/factories/campaigns.rb
@@ -12,5 +12,22 @@ FactoryBot.define do
channel: create(:channel_widget, account: campaign.account)
)
end
+
+ trait :whatsapp do
+ after(:build) do |campaign|
+ campaign.inbox = create(
+ :inbox,
+ account: campaign.account,
+ channel: create(:channel_whatsapp, account: campaign.account)
+ )
+ campaign.template_params = {
+ 'name' => 'ticket_status_updated',
+ 'namespace' => '23423423_2342423_324234234_2343224',
+ 'category' => 'UTILITY',
+ 'language' => 'en',
+ 'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
+ }
+ end
+ end
end
end
diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb
index d437ed346..ad2bab241 100644
--- a/spec/factories/channel/channel_whatsapp.rb
+++ b/spec/factories/channel/channel_whatsapp.rb
@@ -36,6 +36,7 @@ FactoryBot.define do
'status' => 'APPROVED',
'category' => 'UTILITY',
'language' => 'en',
+ 'namespace' => '23423423_2342423_324234234_2343224',
'components' => [
{ 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.",
'type' => 'BODY',
diff --git a/spec/jobs/conversations/update_message_status_job_spec.rb b/spec/jobs/conversations/update_message_status_job_spec.rb
index ff5c90292..508d2dc1c 100644
--- a/spec/jobs/conversations/update_message_status_job_spec.rb
+++ b/spec/jobs/conversations/update_message_status_job_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Conversations::UpdateMessageStatusJob do
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(conversation.id, conversation.contact_last_seen_at, :read)
- .on_queue('low')
+ .on_queue('deferred')
end
context 'when called' do
diff --git a/spec/services/automation_rules/action_service_spec.rb b/spec/services/automation_rules/action_service_spec.rb
index bdfa7af0c..e63fd7545 100644
--- a/spec/services/automation_rules/action_service_spec.rb
+++ b/spec/services/automation_rules/action_service_spec.rb
@@ -117,5 +117,27 @@ RSpec.describe AutomationRules::ActionService do
expect(mailer).to have_received(:conversation_transcript).exactly(1).times
end
end
+
+ describe '#perform with add_private_note action' do
+ let(:message_builder) { double }
+
+ before do
+ allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
+ rule.actions.delete_if { |a| a['action_name'] == 'send_message' }
+ rule.actions << { action_name: 'add_private_note', action_params: ['Note'] }
+ end
+
+ it 'will add private note' do
+ expect(message_builder).to receive(:perform)
+ described_class.new(rule, account, conversation).perform
+ end
+
+ it 'will not add note if conversation is a tweet' do
+ twitter_inbox = create(:inbox, channel: create(:channel_twitter_profile, account: account))
+ conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' })
+ expect(message_builder).not_to receive(:perform)
+ described_class.new(rule, account, conversation).perform
+ end
+ end
end
end
diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb
new file mode 100644
index 000000000..33107e8de
--- /dev/null
+++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb
@@ -0,0 +1,169 @@
+require 'rails_helper'
+
+describe Whatsapp::OneoffCampaignService do
+ let(:account) { create(:account) }
+ let!(:whatsapp_channel) do
+ create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
+ end
+ let!(:whatsapp_inbox) { whatsapp_channel.inbox }
+ let(:label1) { create(:label, account: account) }
+ let(:label2) { create(:label, account: account) }
+ let!(:campaign) do
+ create(:campaign, inbox: whatsapp_inbox, account: account,
+ audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }],
+ template_params: template_params)
+ end
+ let(:template_params) do
+ {
+ 'name' => 'ticket_status_updated',
+ 'namespace' => '23423423_2342423_324234234_2343224',
+ 'category' => 'UTILITY',
+ 'language' => 'en',
+ 'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
+ }
+ end
+
+ before do
+ # Stub HTTP requests to WhatsApp API
+ stub_request(:post, /graph\.facebook\.com.*messages/)
+ .to_return(status: 200, body: { messages: [{ id: 'message_id_123' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ # Ensure the service uses our mocked channel object by stubbing the whole delegation chain
+ # Using allow_any_instance_of here because the service is instantiated within individual tests
+ # and we need to mock the delegated channel method for proper test isolation
+ allow_any_instance_of(described_class).to receive(:channel).and_return(whatsapp_channel) # rubocop:disable RSpec/AnyInstance
+ end
+
+ describe '#perform' do
+ before do
+ # Enable WhatsApp campaigns feature flag for all tests
+ account.enable_features!(:whatsapp_campaign)
+ end
+
+ context 'when campaign validation fails' do
+ it 'raises error if campaign is completed' do
+ campaign.completed!
+
+ expect { described_class.new(campaign: campaign).perform }.to raise_error 'Completed Campaign'
+ end
+
+ it 'raises error when campaign is not a WhatsApp campaign' do
+ sms_channel = create(:channel_sms, account: account)
+ sms_inbox = create(:inbox, channel: sms_channel, account: account)
+ invalid_campaign = create(:campaign, inbox: sms_inbox, account: account)
+
+ expect { described_class.new(campaign: invalid_campaign).perform }
+ .to raise_error "Invalid campaign #{invalid_campaign.id}"
+ end
+
+ it 'raises error when campaign is not oneoff' do
+ allow(campaign).to receive(:one_off?).and_return(false)
+
+ expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
+ end
+
+ it 'raises error when channel provider is not whatsapp_cloud' do
+ whatsapp_channel.update!(provider: 'default')
+
+ expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp Cloud provider required'
+ end
+
+ it 'raises error when WhatsApp campaigns feature is not enabled' do
+ account.disable_features!(:whatsapp_campaign)
+
+ expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp campaigns feature not enabled'
+ end
+ end
+
+ context 'when campaign is valid' do
+ it 'marks campaign as completed' do
+ described_class.new(campaign: campaign).perform
+
+ expect(campaign.reload.completed?).to be true
+ end
+
+ it 'processes contacts with matching labels' do
+ contact_with_label1, contact_with_label2, contact_with_both_labels =
+ create_list(:contact, 3, :with_phone_number, account: account)
+ contact_with_label1.update_labels([label1.title])
+ contact_with_label2.update_labels([label2.title])
+ contact_with_both_labels.update_labels([label1.title, label2.title])
+
+ expect(whatsapp_channel).to receive(:send_template).exactly(3).times
+
+ described_class.new(campaign: campaign).perform
+ end
+
+ it 'skips contacts without phone numbers' do
+ contact_without_phone = create(:contact, account: account, phone_number: nil)
+ contact_without_phone.update_labels([label1.title])
+
+ expect(whatsapp_channel).not_to receive(:send_template)
+
+ described_class.new(campaign: campaign).perform
+ end
+
+ it 'uses template processor service to process templates' do
+ contact = create(:contact, :with_phone_number, account: account)
+ contact.update_labels([label1.title])
+
+ expect(Whatsapp::TemplateProcessorService).to receive(:new)
+ .with(channel: whatsapp_channel, template_params: template_params)
+ .and_call_original
+
+ described_class.new(campaign: campaign).perform
+ end
+
+ it 'sends template message with correct parameters' do
+ contact = create(:contact, :with_phone_number, account: account)
+ contact.update_labels([label1.title])
+
+ expect(whatsapp_channel).to receive(:send_template).with(
+ contact.phone_number,
+ hash_including(
+ name: 'ticket_status_updated',
+ namespace: '23423423_2342423_324234234_2343224',
+ lang_code: 'en',
+ parameters: array_including(
+ hash_including(type: 'text', parameter_name: 'name', text: 'John'),
+ hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
+ )
+ )
+ )
+
+ described_class.new(campaign: campaign).perform
+ end
+ end
+
+ context 'when template_params is missing' do
+ let(:template_params) { nil }
+
+ it 'skips contacts and logs error' do
+ contact = create(:contact, :with_phone_number, account: account)
+ contact.update_labels([label1.title])
+
+ expect(Rails.logger).to receive(:error)
+ .with("Skipping contact #{contact.name} - no template_params found for WhatsApp campaign")
+ expect(whatsapp_channel).not_to receive(:send_template)
+
+ described_class.new(campaign: campaign).perform
+ end
+ end
+
+ context 'when send_template raises an error' do
+ it 'logs error and re-raises' do
+ contact = create(:contact, :with_phone_number, account: account)
+ contact.update_labels([label1.title])
+ error_message = 'WhatsApp API error'
+
+ allow(whatsapp_channel).to receive(:send_template).and_raise(StandardError, error_message)
+
+ expect(Rails.logger).to receive(:error)
+ .with("Failed to send WhatsApp template message to #{contact.phone_number}: #{error_message}")
+ expect(Rails.logger).to receive(:error).with(/Backtrace:/)
+
+ expect { described_class.new(campaign: campaign).perform }.to raise_error(StandardError, error_message)
+ end
+ end
+ end
+end