-
-
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/features.yml b/config/features.yml
index 5171b1c01..4706c46f0 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -176,3 +176,14 @@
- name: notion_integration
display_name: Notion Integration
enabled: false
+- name: captain_integration_v2
+ 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 58d593502..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 ------- #
@@ -126,6 +129,26 @@
type: boolean
# ------- End of Facebook Channel Related Config ------- #
+# ------- WhatsApp Channel Related Config ------- #
+- name: WHATSAPP_APP_ID
+ display_title: 'WhatsApp App ID'
+ description: 'The Facebook App ID for WhatsApp Business API integration'
+ locked: false
+- name: WHATSAPP_CONFIGURATION_ID
+ display_title: 'WhatsApp Configuration ID'
+ description: 'The Configuration ID for WhatsApp Embedded Signup flow (required for embedded signup)'
+ locked: false
+- name: WHATSAPP_APP_SECRET
+ display_title: 'WhatsApp App Secret'
+ description: 'The App Secret for WhatsApp Embedded Signup flow (required for embedded signup)'
+ locked: false
+- name: WHATSAPP_API_VERSION
+ display_title: 'WhatsApp API Version'
+ description: 'Configure this if you want to use a different WhatsApp API version. Make sure its prefixed with `v`'
+ value: 'v22.0'
+ locked: false
+# ------- End of WhatsApp Channel Related Config ------- #
+
# MARK: Microsoft Email Channel Config
- name: AZURE_APP_ID
display_title: 'Azure App ID'
@@ -374,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/locales/ru.yml b/config/locales/ru.yml
index 21b859d44..bdde6d62a 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -183,7 +183,7 @@ ru:
csat:
not_sent_due_to_messaging_window: 'CSAT survey not sent due to outgoing message restrictions'
auto_resolve:
- not_sent_due_to_messaging_window: 'Auto-resolve message not sent due to outgoing message restrictions'
+ not_sent_due_to_messaging_window: 'Сообщение автозавершения не отправлено из-за ограничений исходящих сообщений'
muted: '%{user_name} заглушил(а) этот разговор'
unmuted: '%{user_name} включил(а) уведомления для разговора'
auto_resolution_message: 'Разговор закрывается, поскольку он был неактивен в течение длительного времени. Пожалуйста, начните новый разговор, если потребуется дополнительная помощь.'
diff --git a/config/routes.rb b/config/routes.rb
index 41ee52f42..f262febdf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -57,6 +57,7 @@ Rails.application.routes.draw do
post :playground
end
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
+ resources :scenarios
end
resources :assistant_responses
resources :bulk_actions, only: [:create]
@@ -232,6 +233,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
+ namespace :whatsapp do
+ resource :authorization, only: [:create]
+ end
+
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
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/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb
index 9e2a25723..2d1a41e74 100644
--- a/db/migrate/20250620120000_create_channel_voice.rb
+++ b/db/migrate/20250620120000_create_channel_voice.rb
@@ -13,4 +13,4 @@ class CreateChannelVoice < ActiveRecord::Migration[7.0]
add_index :channel_voice, :phone_number, unique: true
add_index :channel_voice, :account_id
end
-end
\ No newline at end of file
+end
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/migrate/20250710145708_create_captain_scenarios.rb b/db/migrate/20250710145708_create_captain_scenarios.rb
new file mode 100644
index 000000000..a922bd478
--- /dev/null
+++ b/db/migrate/20250710145708_create_captain_scenarios.rb
@@ -0,0 +1,18 @@
+class CreateCaptainScenarios < ActiveRecord::Migration[7.1]
+ def change
+ create_table :captain_scenarios do |t|
+ t.string :title
+ t.text :description
+ t.text :instruction
+ t.jsonb :tools, default: []
+ t.boolean :enabled, default: true, null: false
+ t.references :assistant, null: false
+ t.references :account, null: false
+
+ t.timestamps
+ end
+
+ add_index :captain_scenarios, :enabled
+ add_index :captain_scenarios, [:assistant_id, :enabled]
+ end
+end
diff --git a/db/migrate/20250714104358_add_response_guidelines_and_guardrails_to_captain_assistants.rb b/db/migrate/20250714104358_add_response_guidelines_and_guardrails_to_captain_assistants.rb
new file mode 100644
index 000000000..3c048b8f7
--- /dev/null
+++ b/db/migrate/20250714104358_add_response_guidelines_and_guardrails_to_captain_assistants.rb
@@ -0,0 +1,6 @@
+class AddResponseGuidelinesAndGuardrailsToCaptainAssistants < ActiveRecord::Migration[7.1]
+ def change
+ add_column :captain_assistants, :response_guidelines, :jsonb, default: []
+ add_column :captain_assistants, :guardrails, :jsonb, default: []
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index af0c89086..34637315b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
+ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) 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"
@@ -277,6 +278,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {}, null: false
+ t.jsonb "response_guidelines", default: []
+ t.jsonb "guardrails", default: []
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
@@ -305,6 +308,22 @@ ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
+ create_table "captain_scenarios", force: :cascade do |t|
+ t.string "title"
+ t.text "description"
+ t.text "instruction"
+ t.jsonb "tools", default: []
+ t.boolean "enabled", default: true, null: false
+ t.bigint "assistant_id", null: false
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_captain_scenarios_on_account_id"
+ t.index ["assistant_id", "enabled"], name: "index_captain_scenarios_on_assistant_id_and_enabled"
+ t.index ["assistant_id"], name: "index_captain_scenarios_on_assistant_id"
+ t.index ["enabled"], name: "index_captain_scenarios_on_enabled"
+ end
+
create_table "categories", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "portal_id", null: false
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index e5a055836..37890fade 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- params[:message_content],
- message_history
+ additional_message: params[:message_content],
+ message_history: message_history
)
render json: response
@@ -43,12 +43,18 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def assistant_params
- params.require(:assistant).permit(:name, :description,
- config: [
- :product_name, :feature_faq, :feature_memory,
- :welcome_message, :handoff_message, :resolution_message,
- :instructions, :temperature
- ])
+ permitted = params.require(:assistant).permit(:name, :description,
+ config: [
+ :product_name, :feature_faq, :feature_memory,
+ :welcome_message, :handoff_message, :resolution_message,
+ :instructions, :temperature
+ ])
+
+ # Handle array parameters separately to allow partial updates
+ permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant][:response_guidelines].present?
+ permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant][:guardrails].present?
+
+ permitted
end
def playground_params
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
new file mode 100644
index 000000000..376cee0b3
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb
@@ -0,0 +1,47 @@
+class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::Scenario) }
+ before_action :set_assistant
+ before_action :set_scenario, only: [:show, :update, :destroy]
+
+ def index
+ @scenarios = assistant_scenarios.enabled
+ end
+
+ def show; end
+
+ def create
+ @scenario = assistant_scenarios.create!(scenario_params.merge(account: Current.account))
+ end
+
+ def update
+ @scenario.update!(scenario_params)
+ end
+
+ def destroy
+ @scenario.destroy
+ head :no_content
+ end
+
+ private
+
+ def set_assistant
+ @assistant = account_assistants.find(params[:assistant_id])
+ end
+
+ def account_assistants
+ @account_assistants ||= Current.account.captain_assistants
+ end
+
+ def set_scenario
+ @scenario = assistant_scenarios.find(params[:id])
+ end
+
+ def assistant_scenarios
+ @assistant.scenarios
+ end
+
+ def scenario_params
+ params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
+ end
+end
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index f341a6e98..b207bd2a4 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -1,6 +1,7 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
- retry_on ActiveStorage::FileNotFoundError, attempts: 3
+ retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
+ retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
def perform(conversation, assistant)
@conversation = conversation
@@ -13,7 +14,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
- raise e if e.is_a?(ActiveStorage::FileNotFoundError)
+ raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
handle_error(e)
ensure
@@ -26,8 +27,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- @conversation.messages.incoming.last.content,
- collect_previous_messages
+ message_history: collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -43,39 +43,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
- {
- content: message_content(message),
- role: determine_role(message)
- }
- end
- end
-
- def message_content(message)
- return message.content if message.content.present?
- return 'User has shared a message without content' unless message.attachments.any?
-
- audio_transcriptions = extract_audio_transcriptions(message.attachments)
- return audio_transcriptions if audio_transcriptions.present?
-
- 'User has shared an attachment'
- end
-
- def extract_audio_transcriptions(attachments)
- audio_attachments = attachments.where(file_type: :audio)
- return '' if audio_attachments.blank?
-
- transcriptions = ''
- audio_attachments.each do |attachment|
- result = Messages::AudioTranscriptionService.new(attachment).perform
- transcriptions += result[:transcriptions] if result[:success]
+ {
+ content: prepare_multimodal_message_content(message),
+ role: determine_role(message)
+ }
end
- transcriptions
end
def determine_role(message)
- return 'system' if message.content.blank?
+ message.message_type == 'incoming' ? 'user' : 'assistant'
+ end
- message.message_type == 'incoming' ? 'user' : 'system'
+ def prepare_multimodal_message_content(message)
+ Captain::OpenAiMessageBuilderService.new(message: message).generate_content
end
def handoff_requested?
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 40cf99df9..4ce4c4afd 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -2,13 +2,15 @@
#
# Table name: captain_assistants
#
-# id :bigint not null, primary key
-# config :jsonb not null
-# description :string
-# name :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# account_id :bigint not null
+# id :bigint not null, primary key
+# config :jsonb not null
+# description :string
+# guardrails :jsonb
+# name :string not null
+# response_guidelines :jsonb
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
#
# Indexes
#
@@ -30,6 +32,7 @@ class Captain::Assistant < ApplicationRecord
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
+ has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
validates :name, presence: true
validates :description, presence: true
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
new file mode 100644
index 000000000..ff8e63c64
--- /dev/null
+++ b/enterprise/app/models/captain/scenario.rb
@@ -0,0 +1,44 @@
+# == Schema Information
+#
+# Table name: captain_scenarios
+#
+# id :bigint not null, primary key
+# description :text
+# enabled :boolean default(TRUE), not null
+# instruction :text
+# title :string
+# tools :jsonb
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# assistant_id :bigint not null
+#
+# Indexes
+#
+# index_captain_scenarios_on_account_id (account_id)
+# index_captain_scenarios_on_assistant_id (assistant_id)
+# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
+# index_captain_scenarios_on_enabled (enabled)
+#
+class Captain::Scenario < ApplicationRecord
+ self.table_name = 'captain_scenarios'
+
+ belongs_to :assistant, class_name: 'Captain::Assistant'
+ belongs_to :account
+
+ validates :title, presence: true
+ validates :description, presence: true
+ validates :instruction, presence: true
+ validates :assistant_id, presence: true
+ validates :account_id, presence: true
+
+ scope :enabled, -> { where(enabled: true) }
+
+ before_save :populate_tools
+
+ private
+
+ def populate_tools
+ # TODO: Implement tools population logic
+ end
+end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
index a313129e2..20ae5a74c 100644
--- a/enterprise/app/models/channel/voice.rb
+++ b/enterprise/app/models/channel/voice.rb
@@ -61,4 +61,3 @@ class Channel::Voice < ApplicationRecord
end
end
end
-
diff --git a/enterprise/app/policies/captain/scenario_policy.rb b/enterprise/app/policies/captain/scenario_policy.rb
new file mode 100644
index 000000000..d532b5b92
--- /dev/null
+++ b/enterprise/app/policies/captain/scenario_policy.rb
@@ -0,0 +1,21 @@
+class Captain::ScenarioPolicy < ApplicationPolicy
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ @account_user.administrator?
+ end
+
+ def update?
+ @account_user.administrator?
+ end
+
+ def destroy?
+ @account_user.administrator?
+ end
+end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 569931d44..ca8fafaa0 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
- def generate_response(input, previous_messages = [], role = 'user')
- @messages += previous_messages
- @messages << { role: role, content: input } if input.present?
+ # additional_message: A single message (String) from the user that should be appended to the chat.
+ # It can be an empty String or nil when you only want to supply historical messages.
+ # message_history: An Array of already formatted messages that provide the previous context.
+ # role: The role for the additional_message (defaults to `user`).
+ #
+ # NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
+ # positional ordering.
+ def generate_response(additional_message: nil, message_history: [], role: 'user')
+ @messages += message_history
+ @messages << { role: role, content: additional_message } if additional_message.present?
request_chat_completion
end
diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb
new file mode 100644
index 000000000..4aaa64e0a
--- /dev/null
+++ b/enterprise/app/services/captain/open_ai_message_builder_service.rb
@@ -0,0 +1,60 @@
+class Captain::OpenAiMessageBuilderService
+ pattr_initialize [:message!]
+
+ def generate_content
+ parts = []
+ parts << text_part(@message.content) if @message.content.present?
+ parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
+
+ return 'Message without content' if parts.blank?
+ return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
+
+ parts
+ end
+
+ private
+
+ def text_part(text)
+ { type: 'text', text: text }
+ end
+
+ def image_part(image_url)
+ { type: 'image_url', image_url: { url: image_url } }
+ end
+
+ def attachment_parts(attachments)
+ image_attachments = attachments.where(file_type: :image)
+ image_content = image_parts(image_attachments)
+
+ transcription = extract_audio_transcriptions(attachments)
+ transcription_part = text_part(transcription) if transcription.present?
+
+ attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
+
+ [image_content, transcription_part, attachment_part].flatten.compact
+ end
+
+ def image_parts(image_attachments)
+ image_attachments.each_with_object([]) do |attachment, parts|
+ url = get_attachment_url(attachment)
+ parts << image_part(url) if url.present?
+ end
+ end
+
+ def get_attachment_url(attachment)
+ return attachment.download_url if attachment.download_url.present?
+ return attachment.external_url if attachment.external_url.present?
+
+ attachment.file.attached? ? attachment.file_url : nil
+ end
+
+ def extract_audio_transcriptions(attachments)
+ audio_attachments = attachments.where(file_type: :audio)
+ return '' if audio_attachments.blank?
+
+ audio_attachments.map do |attachment|
+ result = Messages::AudioTranscriptionService.new(attachment).perform
+ result[:success] ? result[:transcriptions] : ''
+ end.join
+ end
+end
diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
index 92ccba553..ac1c3a95d 100644
--- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
+++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
@@ -1,13 +1,34 @@
module Enterprise::MessageTemplates::HookExecutionService
+ MAX_ATTACHMENT_WAIT_SECONDS = 4
+
def trigger_templates
super
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active?
- Captain::Conversation::ResponseBuilderJob.perform_later(
- conversation,
- conversation.inbox.captain_assistant
- )
+ schedule_captain_response
+ end
+
+ private
+
+ def schedule_captain_response
+ job_args = [conversation, conversation.inbox.captain_assistant]
+
+ if message.attachments.blank?
+ Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
+ else
+ wait_time = calculate_attachment_wait_time
+ Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
+ end
+ end
+
+ def calculate_attachment_wait_time
+ attachment_count = message.attachments.size
+ base_wait = 1.second
+
+ # Wait longer for more attachments or larger files
+ additional_wait = [attachment_count * 1, MAX_ATTACHMENT_WAIT_SECONDS].min.seconds
+ base_wait + additional_wait
end
def should_process_captain_response?
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder
new file mode 100644
index 000000000..8963e07c5
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
new file mode 100644
index 000000000..dc5860fb9
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/index.json.jbuilder
@@ -0,0 +1,5 @@
+json.data do
+ json.array! @scenarios do |scenario|
+ json.partial! 'api/v1/models/captain/scenario', scenario: scenario
+ end
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder
new file mode 100644
index 000000000..8963e07c5
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder
new file mode 100644
index 000000000..8963e07c5
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/scenarios/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
diff --git a/enterprise/app/views/api/v1/models/captain/_assistant.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_assistant.json.jbuilder
index 80008423c..d597ed220 100644
--- a/enterprise/app/views/api/v1/models/captain/_assistant.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_assistant.json.jbuilder
@@ -2,6 +2,8 @@ json.account_id resource.account_id
json.config resource.config
json.created_at resource.created_at.to_i
json.description resource.description
+json.guardrails resource.guardrails
json.id resource.id
json.name resource.name
+json.response_guidelines resource.response_guidelines
json.updated_at resource.updated_at.to_i
diff --git a/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder
new file mode 100644
index 000000000..a0a832efc
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/captain/_scenario.json.jbuilder
@@ -0,0 +1,16 @@
+json.id scenario.id
+json.title scenario.title
+json.description scenario.description
+json.instruction scenario.instruction
+json.tools scenario.tools
+json.enabled scenario.enabled
+json.assistant_id scenario.assistant_id
+json.account_id scenario.account_id
+json.created_at scenario.created_at
+json.updated_at scenario.updated_at
+if scenario.assistant.present?
+ json.assistant do
+ json.id scenario.assistant.id
+ json.name scenario.assistant.name
+ end
+end
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/public/assets/images/dashboard/channels/whatsapp.png b/public/assets/images/dashboard/channels/whatsapp.png
index 547ff675e..7bee740d9 100644
Binary files a/public/assets/images/dashboard/channels/whatsapp.png and b/public/assets/images/dashboard/channels/whatsapp.png differ
diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
index ac4bc2841..b003d5c85 100644
--- a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
@@ -50,4 +50,4 @@ RSpec.describe 'Notion Authorization API', type: :request do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb
new file mode 100644
index 000000000..eca193c76
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/whatsapp/authorizations_controller_spec.rb
@@ -0,0 +1,303 @@
+require 'rails_helper'
+
+RSpec.describe 'WhatsApp Authorization API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'POST /api/v1/accounts/{account.id}/whatsapp/authorization' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+
+ context 'when feature is not enabled' do
+ before do
+ account.disable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns forbidden' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:forbidden)
+ expect(response.parsed_body['error']).to eq('WhatsApp embedded signup is not enabled for this account')
+ end
+ end
+
+ context 'when feature is enabled' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns unprocessable entity when code is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('code')
+ end
+
+ it 'returns unprocessable entity when business_id is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('business_id')
+ end
+
+ it 'returns unprocessable entity when waba_id is missing' do
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to include('waba_id')
+ end
+
+ it 'creates whatsapp channel successfully' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service to prevent HTTP calls
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ response_data = response.parsed_body
+ expect(response_data['success']).to be true
+ expect(response_data['id']).to eq(inbox.id)
+ expect(response_data['name']).to eq(inbox.name)
+ expect(response_data['channel_type']).to eq('whatsapp')
+ end
+
+ it 'calls the embedded signup service with correct parameters' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
+ account: account,
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ ).and_return(embedded_signup_service)
+
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: 'test_phone_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+ end
+
+ it 'accepts phone_number_id as optional parameter' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+
+ expect(Whatsapp::EmbeddedSignupService).to receive(:new).with(
+ account: account,
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id',
+ phone_number_id: nil
+ ).and_return(embedded_signup_service)
+
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unprocessable entity when service fails' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ response_data = response.parsed_body
+ expect(response_data['success']).to be false
+ expect(response_data['error']).to eq('Service error')
+ end
+
+ it 'logs error when service fails' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_raise(StandardError, 'Service error')
+
+ expect(Rails.logger).to receive(:error).with(/\[WHATSAPP AUTHORIZATION\] Embedded signup error: Service error/)
+ expect(Rails.logger).to receive(:error).with(/authorizations_controller/)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+ end
+
+ it 'handles token exchange errors' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new)
+ .and_raise(StandardError, 'Invalid authorization code')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'invalid_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Invalid authorization code')
+ end
+
+ it 'handles channel already exists error' do
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new)
+ .and_raise(StandardError, 'Channel already exists')
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq('Channel already exists')
+ end
+ end
+
+ context 'when user is not authorized for the account' do
+ let(:other_account) { create(:account) }
+
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{other_account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when user is an administrator' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'allows channel creation' do
+ embedded_signup_service = instance_double(Whatsapp::EmbeddedSignupService)
+ whatsapp_channel = create(:channel_whatsapp, account: account, validate_provider_config: false, sync_templates: false)
+ inbox = create(:inbox, account: account, channel: whatsapp_channel)
+
+ allow(Whatsapp::EmbeddedSignupService).to receive(:new).and_return(embedded_signup_service)
+ allow(embedded_signup_service).to receive(:perform).and_return(whatsapp_channel)
+ allow(whatsapp_channel).to receive(:inbox).and_return(inbox)
+
+ # Stub webhook setup service
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ post "/api/v1/accounts/#{account.id}/whatsapp/authorization",
+ params: {
+ code: 'test_code',
+ business_id: 'test_business_id',
+ waba_id: 'test_waba_id'
+ },
+ headers: administrator.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 1f6d83d80..b6418c8ec 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -62,7 +62,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
{
assistant: {
name: 'New Assistant',
- description: 'Assistant Description'
+ description: 'Assistant Description',
+ response_guidelines: ['Be helpful', 'Be concise'],
+ guardrails: ['No harmful content', 'Stay on topic']
}
}
end
@@ -96,6 +98,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end.to change(Captain::Assistant, :count).by(1)
expect(json_response[:name]).to eq('New Assistant')
+ expect(json_response[:response_guidelines]).to eq(['Be helpful', 'Be concise'])
+ expect(json_response[:guardrails]).to eq(['No harmful content', 'Stay on topic'])
expect(response).to have_http_status(:success)
end
end
@@ -106,7 +110,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:update_attributes) do
{
assistant: {
- name: 'Updated Assistant'
+ name: 'Updated Assistant',
+ response_guidelines: ['Updated guideline'],
+ guardrails: ['Updated guardrail']
}
}
end
@@ -139,6 +145,38 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(json_response[:name]).to eq('Updated Assistant')
+ expect(json_response[:response_guidelines]).to eq(['Updated guideline'])
+ expect(json_response[:guardrails]).to eq(['Updated guardrail'])
+ end
+
+ it 'updates only response_guidelines when only that is provided' do
+ assistant.update!(response_guidelines: ['Original guideline'], guardrails: ['Original guardrail'])
+ original_name = assistant.name
+
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
+ params: { assistant: { response_guidelines: ['New guideline only'] } },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:name]).to eq(original_name)
+ expect(json_response[:response_guidelines]).to eq(['New guideline only'])
+ expect(json_response[:guardrails]).to eq(['Original guardrail'])
+ end
+
+ it 'updates only guardrails when only that is provided' do
+ assistant.update!(response_guidelines: ['Original guideline'], guardrails: ['Original guardrail'])
+ original_name = assistant.name
+
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
+ params: { assistant: { guardrails: ['New guardrail only'] } },
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:name]).to eq(original_name)
+ expect(json_response[:response_guidelines]).to eq(['Original guideline'])
+ expect(json_response[:guardrails]).to eq(['New guardrail only'])
end
end
end
@@ -211,8 +249,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- valid_params[:message_content],
- valid_params[:message_history]
+ additional_message: valid_params[:message_content],
+ message_history: valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
@@ -232,8 +270,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- params_without_history[:message_content],
- []
+ additional_message: params_without_history[:message_content],
+ message_history: []
)
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
new file mode 100644
index 000000000..ed223622b
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/scenarios_controller_spec.rb
@@ -0,0 +1,258 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status' do
+ create_list(:captain_scenario, 3, assistant: assistant, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:data].length).to eq(3)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'returns success status and scenarios' do
+ create_list(:captain_scenario, 5, assistant: assistant, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:data].length).to eq(5)
+ end
+
+ it 'returns only enabled scenarios' do
+ create(:captain_scenario, assistant: assistant, account: account, enabled: true)
+ create(:captain_scenario, assistant: assistant, account: account, enabled: false)
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:data].length).to eq(1)
+ expect(json_response[:data].first[:enabled]).to be(true)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
+ let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status and scenario' do
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:id]).to eq(scenario.id)
+ expect(json_response[:title]).to eq(scenario.title)
+ end
+ end
+
+ context 'when scenario does not exist' do
+ it 'returns not found status' do
+ get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999",
+ headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do
+ let(:valid_attributes) do
+ {
+ scenario: {
+ title: 'Test Scenario',
+ description: 'Test description',
+ instruction: 'Test instruction',
+ enabled: true,
+ tools: %w[tool1 tool2]
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ params: valid_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ params: valid_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'creates a new scenario and returns success status' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ params: valid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Captain::Scenario, :count).by(1)
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Test Scenario')
+ expect(json_response[:description]).to eq('Test description')
+ expect(json_response[:enabled]).to be(true)
+ expect(json_response[:assistant_id]).to eq(assistant.id)
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ scenario: {
+ title: '',
+ description: '',
+ instruction: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
+ let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
+ let(:update_attributes) do
+ {
+ scenario: {
+ title: 'Updated Scenario Title',
+ enabled: false
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ params: update_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ params: update_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'updates the scenario and returns success status' do
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ params: update_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Updated Scenario Title')
+ expect(json_response[:enabled]).to be(false)
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ scenario: {
+ title: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
+ let!(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'deletes the scenario and returns no content status' do
+ expect do
+ delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
+ headers: admin.create_new_auth_token
+ end.to change(Captain::Scenario, :count).by(-1)
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ context 'when scenario does not exist' do
+ it 'returns not found status' do
+ delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
index 1e4a6e824..c21205d52 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -30,5 +30,142 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
+
+ context 'when message contains an image' do
+ let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
+ let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
+
+ before do
+ image_attachment
+ end
+
+ it 'includes image URL directly in the message content for OpenAI vision analysis' do
+ # Expect the generate_response to receive multimodal content with image URL
+ expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
+ history = kwargs[:message_history]
+ last_entry = history.last
+ expect(last_entry[:content]).to be_an(Array)
+ expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
+ expect(last_entry[:content].any? do |part|
+ part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
+ end).to be true
+ { 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
+ end
+
+ described_class.perform_now(conversation, assistant)
+ end
+ end
+ end
+
+ describe 'retry mechanisms for image processing' do
+ let(:conversation) { create(:conversation, inbox: inbox, account: account) }
+ let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
+ let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) }
+
+ before do
+ create(:message, conversation: conversation, content: 'Hello with image', message_type: :incoming)
+ allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
+ allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
+ allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
+ allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
+ end
+
+ context 'when ActiveStorage::FileNotFoundError occurs' do
+ it 'handles file errors and triggers handoff' do
+ allow(mock_message_builder).to receive(:generate_content)
+ .and_raise(ActiveStorage::FileNotFoundError, 'Image file not found')
+
+ # For retryable errors, the job should handle them and proceed with handoff
+ described_class.perform_now(conversation, assistant)
+
+ # Verify handoff occurred due to repeated failures
+ expect(conversation.reload.status).to eq('open')
+ end
+
+ it 'succeeds when no error occurs' do
+ # Don't raise any error, should succeed normally
+ allow(mock_message_builder).to receive(:generate_content)
+ .and_return('Image content processed successfully')
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.messages.outgoing.count).to eq(1)
+ expect(conversation.messages.outgoing.last.content).to eq('Test response')
+ end
+ end
+
+ context 'when Faraday::BadRequestError occurs' do
+ it 'handles API errors and triggers handoff' do
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_raise(Faraday::BadRequestError, 'Bad request to image service')
+
+ described_class.perform_now(conversation, assistant)
+ expect(conversation.reload.status).to eq('open')
+ end
+
+ it 'succeeds when no error occurs' do
+ # Don't raise any error, should succeed normally
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_return({ 'response' => 'Response after retry' })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.messages.outgoing.last.content).to eq('Response after retry')
+ end
+ end
+
+ context 'when image processing fails permanently' do
+ before do
+ allow(mock_message_builder).to receive(:generate_content)
+ .and_raise(ActiveStorage::FileNotFoundError, 'Image permanently unavailable')
+ end
+
+ it 'triggers handoff after max retries' do
+ # Since perform_now re-raises retryable errors, simulate the final failure after retries
+ allow(mock_message_builder).to receive(:generate_content)
+ .and_raise(StandardError, 'Max retries exceeded')
+
+ expect(ChatwootExceptionTracker).to receive(:new).and_call_original
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ end
+ end
+
+ context 'when non-retryable error occurs' do
+ let(:standard_error) { StandardError.new('Generic error') }
+
+ before do
+ allow(mock_llm_chat_service).to receive(:generate_response).and_raise(standard_error)
+ end
+
+ it 'handles error and triggers handoff' do
+ expect(ChatwootExceptionTracker).to receive(:new)
+ .with(standard_error, account: account)
+ .and_call_original
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ end
+
+ it 'ensures Current.executed_by is reset' do
+ expect(Current).to receive(:executed_by=).with(assistant)
+ expect(Current).to receive(:executed_by=).with(nil)
+
+ described_class.perform_now(conversation, assistant)
+ end
+ end
+ end
+
+ describe 'job configuration' do
+ it 'has retry_on configuration for retryable errors' do
+ expect(described_class).to respond_to(:retry_on)
+ end
+
+ it 'defines MAX_MESSAGE_LENGTH constant' do
+ expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
+ end
end
end
diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb
new file mode 100644
index 000000000..1f13a362b
--- /dev/null
+++ b/spec/enterprise/models/captain/scenario_spec.rb
@@ -0,0 +1,63 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Scenario, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
+ it { is_expected.to belong_to(:account) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:title) }
+ it { is_expected.to validate_presence_of(:description) }
+ it { is_expected.to validate_presence_of(:instruction) }
+ it { is_expected.to validate_presence_of(:assistant_id) }
+ it { is_expected.to validate_presence_of(:account_id) }
+ end
+
+ describe 'scopes' do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ describe '.enabled' do
+ it 'returns only enabled scenarios' do
+ enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
+ disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
+
+ expect(described_class.enabled).to include(enabled_scenario)
+ expect(described_class.enabled).not_to include(disabled_scenario)
+ end
+ end
+ end
+
+ describe 'callbacks' do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ describe 'before_save :populate_tools' do
+ it 'calls populate_tools before saving' do
+ scenario = build(:captain_scenario, assistant: assistant, account: account)
+ expect(scenario).to receive(:populate_tools)
+ scenario.save
+ end
+ end
+ end
+
+ describe 'factory' do
+ it 'creates a valid scenario with associations' do
+ account = create(:account)
+ assistant = create(:captain_assistant, account: account)
+ scenario = build(:captain_scenario, assistant: assistant, account: account)
+ expect(scenario).to be_valid
+ end
+
+ it 'creates a scenario with all required attributes' do
+ scenario = create(:captain_scenario)
+ expect(scenario.title).to be_present
+ expect(scenario.description).to be_present
+ expect(scenario.instruction).to be_present
+ expect(scenario.enabled).to be true
+ expect(scenario.assistant).to be_present
+ expect(scenario.account).to be_present
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
new file mode 100644
index 000000000..76e91ae7a
--- /dev/null
+++ b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
@@ -0,0 +1,310 @@
+require 'rails_helper'
+
+RSpec.describe Captain::OpenAiMessageBuilderService do
+ subject(:service) { described_class.new(message: message) }
+
+ let(:message) { create(:message, content: 'Hello world') }
+
+ describe '#generate_content' do
+ context 'when message has only text content' do
+ it 'returns the text content directly' do
+ expect(service.generate_content).to eq('Hello world')
+ end
+ end
+
+ context 'when message has no content and no attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ it 'returns default message' do
+ expect(service.generate_content).to eq('Message without content')
+ end
+ end
+
+ context 'when message has text content and attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'text', text: 'Hello world' })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'when message has only non-text attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts without text' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
+ end
+ end
+ end
+
+ describe '#attachment_parts' do
+ let(:message) { create(:message, content: nil) }
+ let(:attachments) { message.attachments }
+
+ context 'with image attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'includes image parts' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'with audio attachments' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
+ )
+ end
+
+ it 'includes transcription text part' do
+ audio_attachment # trigger creation
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'Audio transcription text' })
+ end
+ end
+
+ context 'with other file types' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ end
+
+ it 'includes generic attachment message' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+
+ context 'with mixed attachment types' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:document_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
+ )
+ end
+
+ it 'includes all relevant parts' do
+ image_attachment # trigger creation
+ audio_attachment # trigger creation
+ document_attachment # trigger creation
+
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).to include({ type: 'text', text: 'Audio text' })
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+ end
+
+ describe '#image_parts' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with valid image attachments' do
+ let(:image1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:image2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
+ attachment.save!
+ attachment
+ end
+
+ it 'returns image parts for all valid images' do
+ image1 # trigger creation
+ image2 # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
+ end
+ end
+
+ context 'with image attachments without URLs' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'skips images without valid URLs' do
+ image_attachment # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to be_empty
+ end
+ end
+ end
+
+ describe '#get_attachment_url' do
+ let(:attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
+ attachment.save!
+ attachment
+ end
+
+ context 'when attachment has external_url' do
+ before { attachment.update(external_url: 'https://example.com/image.jpg') }
+
+ it 'returns external_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
+ end
+ end
+
+ context 'when attachment has attached file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
+ allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
+ allow(attachment).to receive(:download_url).and_return('')
+ end
+
+ it 'returns file_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
+ end
+ end
+
+ context 'when attachment has no URL or file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'returns nil' do
+ expect(service.send(:get_attachment_url, attachment)).to be_nil
+ end
+ end
+ end
+
+ describe '#extract_audio_transcriptions' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with no audio attachments' do
+ it 'returns empty string' do
+ result = service.send(:extract_audio_transcriptions, message.attachments)
+ expect(result).to eq('')
+ end
+ end
+
+ context 'with successful audio transcriptions' do
+ let(:audio1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:audio2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
+ )
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
+ )
+ end
+
+ it 'concatenates all successful transcriptions' do
+ audio1 # trigger creation
+ audio2 # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('First audio text. Second audio text.')
+ end
+ end
+
+ context 'with failed audio transcriptions' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
+ )
+ end
+
+ it 'returns empty string for failed transcriptions' do
+ audio_attachment # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('')
+ end
+ end
+ end
+
+ describe 'private helper methods' do
+ describe '#text_part' do
+ it 'returns correct text part format' do
+ result = service.send(:text_part, 'Hello world')
+ expect(result).to eq({ type: 'text', text: 'Hello world' })
+ end
+ end
+
+ describe '#image_part' do
+ it 'returns correct image part format' do
+ result = service.send(:image_part, 'https://example.com/image.jpg')
+ expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+ end
+end
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/captain/scenario.rb b/spec/factories/captain/scenario.rb
new file mode 100644
index 000000000..fc9149bfa
--- /dev/null
+++ b/spec/factories/captain/scenario.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :captain_scenario, class: 'Captain::Scenario' do
+ sequence(:title) { |n| "Scenario #{n}" }
+ description { 'Test scenario description' }
+ instruction { 'Test scenario instruction for the assistant to follow' }
+ tools { [] }
+ enabled { true }
+ association :assistant, factory: :captain_assistant
+ association :account
+ 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/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 4368807fe..29a00fd96 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -61,4 +61,65 @@ RSpec.describe Channel::Whatsapp do
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
end
+
+ describe 'webhook setup after creation' do
+ let(:account) { create(:account) }
+ let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
+
+ before do
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+ end
+
+ context 'when channel is created through embedded signup' do
+ it 'does not raise error if webhook setup fails' do
+ allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
+
+ expect do
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_access_token'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end.not_to raise_error
+ end
+ end
+
+ context 'when channel is created through manual setup' do
+ it 'does not setup webhooks' do
+ expect(Whatsapp::WebhookSetupService).not_to receive(:new)
+
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ 'business_account_id' => 'test_waba_id',
+ 'api_key' => 'test_access_token'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end
+ end
+
+ context 'when channel is created with different provider' do
+ it 'does not setup webhooks for 360dialog provider' do
+ expect(Whatsapp::WebhookSetupService).not_to receive(:new)
+
+ create(:channel_whatsapp,
+ account: account,
+ provider: 'default',
+ provider_config: {
+ 'source' => 'embedded_signup',
+ 'api_key' => 'test_360dialog_key'
+ },
+ validate_provider_config: false,
+ sync_templates: false)
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
new file mode 100644
index 000000000..1cf0b87f2
--- /dev/null
+++ b/spec/models/concerns/featurable_spec.rb
@@ -0,0 +1,57 @@
+require 'rails_helper'
+
+RSpec.describe Featurable do
+ let(:account) { create(:account) }
+
+ describe 'WhatsApp embedded signup feature' do
+ it 'is disabled by default' do
+ expect(account.feature_whatsapp_embedded_signup?).to be false
+ expect(account.feature_enabled?('whatsapp_embedded_signup')).to be false
+ end
+
+ describe '#enable_features!' do
+ it 'enables the whatsapp embedded signup feature' do
+ account.enable_features!(:whatsapp_embedded_signup)
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+ expect(account.feature_enabled?('whatsapp_embedded_signup')).to be true
+ end
+
+ it 'enables multiple features at once' do
+ account.enable_features!(:whatsapp_embedded_signup, :help_center)
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+ expect(account.feature_help_center?).to be true
+ end
+ end
+
+ describe '#disable_features!' do
+ before do
+ account.enable_features!(:whatsapp_embedded_signup)
+ end
+
+ it 'disables the whatsapp embedded signup feature' do
+ expect(account.feature_whatsapp_embedded_signup?).to be true
+
+ account.disable_features!(:whatsapp_embedded_signup)
+ expect(account.feature_whatsapp_embedded_signup?).to be false
+ end
+ end
+
+ describe '#enabled_features' do
+ it 'includes whatsapp_embedded_signup when enabled' do
+ account.enable_features!(:whatsapp_embedded_signup)
+ expect(account.enabled_features).to include('whatsapp_embedded_signup' => true)
+ end
+
+ it 'does not include whatsapp_embedded_signup when disabled' do
+ account.disable_features!(:whatsapp_embedded_signup)
+ expect(account.enabled_features).not_to include('whatsapp_embedded_signup' => true)
+ end
+ end
+
+ describe '#all_features' do
+ it 'includes whatsapp_embedded_signup in all features list' do
+ expect(account.all_features).to have_key('whatsapp_embedded_signup')
+ end
+ end
+ end
+end
diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb
index f85bdb8d1..fe709fd21 100644
--- a/spec/presenters/message_content_presenter_spec.rb
+++ b/spec/presenters/message_content_presenter_spec.rb
@@ -63,4 +63,4 @@ RSpec.describe MessageContentPresenter do
expect(presenter.conversation).to eq(conversation)
end
end
-end
\ No newline at end of file
+end
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/csat_survey_service_spec.rb b/spec/services/csat_survey_service_spec.rb
index 5b3b3279a..ecb5a75c6 100644
--- a/spec/services/csat_survey_service_spec.rb
+++ b/spec/services/csat_survey_service_spec.rb
@@ -88,4 +88,4 @@ describe CsatSurveyService do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/linear/activity_message_service_spec.rb b/spec/services/linear/activity_message_service_spec.rb
index 4b51f6520..e950ec8e3 100644
--- a/spec/services/linear/activity_message_service_spec.rb
+++ b/spec/services/linear/activity_message_service_spec.rb
@@ -171,4 +171,4 @@ RSpec.describe Linear::ActivityMessageService, type: :service do
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb
new file mode 100644
index 000000000..1c1f46232
--- /dev/null
+++ b/spec/services/whatsapp/channel_creation_service_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+describe Whatsapp::ChannelCreationService do
+ let(:account) { create(:account) }
+ let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
+ let(:phone_info) do
+ {
+ phone_number_id: 'test_phone_id',
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ }
+ end
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
+
+ describe '#perform' do
+ before do
+ # Clean up any existing channels to avoid phone number conflicts
+ Channel::Whatsapp.destroy_all
+
+ # Stub the webhook setup service to prevent HTTP calls during tests
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+
+ # Stub the provider validation and sync_templates
+ allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
+ channel = method.call(*args)
+ allow(channel).to receive(:validate_provider_config)
+ allow(channel).to receive(:sync_templates)
+ channel
+ end
+ end
+
+ context 'when channel does not exist' do
+ it 'creates a new channel' do
+ expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
+ end
+
+ it 'creates channel with correct attributes' do
+ channel = service.perform
+ expect(channel.phone_number).to eq('+1234567890')
+ expect(channel.provider).to eq('whatsapp_cloud')
+ expect(channel.provider_config['api_key']).to eq(access_token)
+ expect(channel.provider_config['phone_number_id']).to eq('test_phone_id')
+ expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
+ expect(channel.provider_config['source']).to eq('embedded_signup')
+ end
+
+ it 'creates an inbox for the channel' do
+ channel = service.perform
+ inbox = channel.inbox
+ expect(inbox).not_to be_nil
+ expect(inbox.name).to eq('Test Business WhatsApp')
+ expect(inbox.account).to eq(account)
+ end
+ end
+
+ context 'when channel already exists' do
+ before do
+ create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Channel already exists/)
+ end
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when account is nil' do
+ service = described_class.new(nil, waba_info, phone_info, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
+ end
+
+ it 'raises error when waba_info is nil' do
+ service = described_class.new(account, nil, phone_info, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
+ end
+
+ it 'raises error when phone_info is nil' do
+ service = described_class.new(account, waba_info, nil, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
+ end
+
+ it 'raises error when access_token is blank' do
+ service = described_class.new(account, waba_info, phone_info, '')
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when business_name is in different places' do
+ context 'when business_name is only in phone_info' do
+ let(:waba_info) { { waba_id: 'test_waba_id' } }
+
+ it 'uses business_name from phone_info' do
+ channel = service.perform
+ expect(channel.inbox.name).to eq('Test Business WhatsApp')
+ end
+ end
+
+ context 'when business_name is only in waba_info' do
+ let(:phone_info) do
+ {
+ phone_number_id: 'test_phone_id',
+ phone_number: '+1234567890',
+ verified: true
+ }
+ end
+
+ it 'uses business_name from waba_info' do
+ channel = service.perform
+ expect(channel.inbox.name).to eq('Test Business WhatsApp')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
new file mode 100644
index 000000000..95af73523
--- /dev/null
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -0,0 +1,127 @@
+require 'rails_helper'
+
+describe Whatsapp::EmbeddedSignupService do
+ let(:account) { create(:account) }
+ let(:code) { 'test_authorization_code' }
+ let(:business_id) { 'test_business_id' }
+ let(:waba_id) { 'test_waba_id' }
+ let(:phone_number_id) { 'test_phone_number_id' }
+ let(:service) do
+ described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ end
+
+ describe '#perform' do
+ let(:access_token) { 'test_access_token' }
+ let(:phone_info) do
+ {
+ phone_number_id: phone_number_id,
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ }
+ end
+ let(:channel) { instance_double(Channel::Whatsapp) }
+
+ let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
+ let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
+ let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
+ let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
+
+ before do
+ allow(GlobalConfig).to receive(:clear_cache)
+
+ allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
+ allow(token_exchange_service).to receive(:perform).and_return(access_token)
+
+ allow(Whatsapp::PhoneInfoService).to receive(:new)
+ .with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
+ allow(phone_info_service).to receive(:perform).and_return(phone_info)
+
+ allow(Whatsapp::TokenValidationService).to receive(:new)
+ .with(access_token, waba_id).and_return(token_validation_service)
+ allow(token_validation_service).to receive(:perform)
+
+ allow(Whatsapp::ChannelCreationService).to receive(:new)
+ .with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
+ .and_return(channel_creation_service)
+ allow(channel_creation_service).to receive(:perform).and_return(channel)
+
+ # Webhook setup is now handled in the channel after_create callback
+ # So we stub it at the model level
+ webhook_service = instance_double(Whatsapp::WebhookSetupService)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
+ allow(webhook_service).to receive(:perform)
+ end
+
+ it 'orchestrates all services in the correct order' do
+ expect(token_exchange_service).to receive(:perform).ordered
+ expect(phone_info_service).to receive(:perform).ordered
+ expect(token_validation_service).to receive(:perform).ordered
+ expect(channel_creation_service).to receive(:perform).ordered
+
+ result = service.perform
+ expect(result).to eq(channel)
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when code is blank' do
+ service = described_class.new(
+ account: account,
+ code: '',
+ business_id: business_id,
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
+ end
+
+ it 'raises error when business_id is blank' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: '',
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
+ end
+
+ it 'raises error when waba_id is blank' do
+ service = described_class.new(
+ account: account,
+ code: code,
+ business_id: business_id,
+ waba_id: '',
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
+ end
+
+ it 'raises error when multiple parameters are blank' do
+ service = described_class.new(
+ account: account,
+ code: '',
+ business_id: '',
+ waba_id: waba_id,
+ phone_number_id: phone_number_id
+ )
+ expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
+ end
+ end
+
+ context 'when any service fails' do
+ it 'logs and re-raises the error' do
+ allow(token_exchange_service).to receive(:perform).and_raise('Token error')
+
+ expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
+ expect { service.perform }.to raise_error('Token error')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb
new file mode 100644
index 000000000..6c2af7716
--- /dev/null
+++ b/spec/services/whatsapp/facebook_api_client_spec.rb
@@ -0,0 +1,197 @@
+require 'rails_helper'
+
+describe Whatsapp::FacebookApiClient do
+ let(:access_token) { 'test_access_token' }
+ let(:api_client) { described_class.new(access_token) }
+ let(:api_version) { 'v22.0' }
+ let(:app_id) { 'test_app_id' }
+ let(:app_secret) { 'test_app_secret' }
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
+ end
+
+ describe '#exchange_code_for_token' do
+ let(:code) { 'test_code' }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: { client_id: app_id, client_secret: app_secret, code: code })
+ .to_return(
+ status: 200,
+ body: { access_token: 'new_token' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the response data' do
+ result = api_client.exchange_code_for_token(code)
+ expect(result['access_token']).to eq('new_token')
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
+ .with(query: { client_id: app_id, client_secret: app_secret, code: code })
+ .to_return(status: 400, body: { error: 'Invalid code' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.exchange_code_for_token(code) }.to raise_error(/Token exchange failed/)
+ end
+ end
+ end
+
+ describe '#fetch_phone_numbers' do
+ let(:waba_id) { 'test_waba_id' }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: { access_token: access_token })
+ .to_return(
+ status: 200,
+ body: { data: [{ id: '123', display_phone_number: '1234567890' }] }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the phone numbers data' do
+ result = api_client.fetch_phone_numbers(waba_id)
+ expect(result['data']).to be_an(Array)
+ expect(result['data'].first['id']).to eq('123')
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
+ .with(query: { access_token: access_token })
+ .to_return(status: 403, body: { error: 'Access denied' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.fetch_phone_numbers(waba_id) }.to raise_error(/WABA phone numbers fetch failed/)
+ end
+ end
+ end
+
+ describe '#debug_token' do
+ let(:input_token) { 'test_input_token' }
+ let(:app_access_token) { "#{app_id}|#{app_secret}" }
+
+ context 'when successful' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: { input_token: input_token, access_token: app_access_token })
+ .to_return(
+ status: 200,
+ body: { data: { app_id: app_id, is_valid: true } }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns the debug token data' do
+ result = api_client.debug_token(input_token)
+ expect(result['data']['is_valid']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
+ .with(query: { input_token: input_token, access_token: app_access_token })
+ .to_return(status: 400, body: { error: 'Invalid token' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.debug_token(input_token) }.to raise_error(/Token validation failed/)
+ end
+ end
+ end
+
+ describe '#register_phone_number' do
+ let(:phone_number_id) { 'test_phone_id' }
+ let(:pin) { '123456' }
+
+ context 'when successful' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { messaging_product: 'whatsapp', pin: pin }.to_json
+ )
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns success response' do
+ result = api_client.register_phone_number(phone_number_id, pin)
+ expect(result['success']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { messaging_product: 'whatsapp', pin: pin }.to_json
+ )
+ .to_return(status: 400, body: { error: 'Registration failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.register_phone_number(phone_number_id, pin) }.to raise_error(/Phone registration failed/)
+ end
+ end
+ end
+
+ describe '#subscribe_waba_webhook' do
+ let(:waba_id) { 'test_waba_id' }
+ let(:callback_url) { 'https://example.com/webhook' }
+ let(:verify_token) { 'test_verify_token' }
+
+ context 'when successful' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
+ )
+ .to_return(
+ status: 200,
+ body: { success: true }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'returns success response' do
+ result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token)
+ expect(result['success']).to be(true)
+ end
+ end
+
+ context 'when failed' do
+ before do
+ stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
+ .with(
+ headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
+ body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
+ )
+ .to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
+ end
+
+ it 'raises an error' do
+ expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
+ 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
diff --git a/spec/services/whatsapp/phone_info_service_spec.rb b/spec/services/whatsapp/phone_info_service_spec.rb
new file mode 100644
index 000000000..bf1517a39
--- /dev/null
+++ b/spec/services/whatsapp/phone_info_service_spec.rb
@@ -0,0 +1,147 @@
+require 'rails_helper'
+
+describe Whatsapp::PhoneInfoService do
+ let(:waba_id) { 'test_waba_id' }
+ let(:phone_number_id) { 'test_phone_number_id' }
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(waba_id, phone_number_id, access_token) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
+ end
+
+ describe '#perform' do
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => phone_number_id,
+ 'display_phone_number' => '1234567890',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ context 'when all parameters are valid' do
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'returns formatted phone info' do
+ result = service.perform
+ expect(result).to eq({
+ phone_number_id: phone_number_id,
+ phone_number: '+1234567890',
+ verified: true,
+ business_name: 'Test Business'
+ })
+ end
+ end
+
+ context 'when phone_number_id is not provided' do
+ let(:phone_number_id) { nil }
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => 'first_phone_id',
+ 'display_phone_number' => '1234567890',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'uses the first available phone number' do
+ result = service.perform
+ expect(result[:phone_number_id]).to eq('first_phone_id')
+ end
+ end
+
+ context 'when specific phone_number_id is not found' do
+ let(:phone_number_id) { 'different_id' }
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => 'available_phone_id',
+ 'display_phone_number' => '9876543210',
+ 'verified_name' => 'Different Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'uses the first available phone number as fallback' do
+ result = service.perform
+ expect(result[:phone_number_id]).to eq('available_phone_id')
+ expect(result[:phone_number]).to eq('+9876543210')
+ end
+ end
+
+ context 'when no phone numbers are available' do
+ let(:phone_response) { { 'data' => [] } }
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/No phone numbers found for WABA/)
+ end
+ end
+
+ context 'when waba_id is blank' do
+ let(:waba_id) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+ end
+
+ context 'when access_token is blank' do
+ let(:access_token) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when phone number has special characters' do
+ let(:phone_response) do
+ {
+ 'data' => [
+ {
+ 'id' => phone_number_id,
+ 'display_phone_number' => '+1 (234) 567-8900',
+ 'verified_name' => 'Test Business',
+ 'code_verification_status' => 'VERIFIED'
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
+ end
+
+ it 'sanitizes the phone number' do
+ result = service.perform
+ expect(result[:phone_number]).to eq('+12345678900')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/token_exchange_service_spec.rb b/spec/services/whatsapp/token_exchange_service_spec.rb
new file mode 100644
index 000000000..cbe97751b
--- /dev/null
+++ b/spec/services/whatsapp/token_exchange_service_spec.rb
@@ -0,0 +1,45 @@
+require 'rails_helper'
+
+describe Whatsapp::TokenExchangeService do
+ let(:code) { 'test_authorization_code' }
+ let(:service) { described_class.new(code) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when code is valid' do
+ let(:token_response) { { 'access_token' => 'new_access_token' } }
+
+ before do
+ allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
+ end
+
+ it 'returns the access token' do
+ expect(service.perform).to eq('new_access_token')
+ end
+ end
+
+ context 'when code is blank' do
+ let(:service) { described_class.new('') }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Authorization code is required')
+ end
+ end
+
+ context 'when response has no access token' do
+ let(:token_response) { { 'error' => 'Invalid code' } }
+
+ before do
+ allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/No access token in response/)
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb
new file mode 100644
index 000000000..d9cf40257
--- /dev/null
+++ b/spec/services/whatsapp/token_validation_service_spec.rb
@@ -0,0 +1,99 @@
+require 'rails_helper'
+
+describe Whatsapp::TokenValidationService do
+ let(:access_token) { 'test_access_token' }
+ let(:waba_id) { 'test_waba_id' }
+ let(:service) { described_class.new(access_token, waba_id) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when token has access to WABA' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'whatsapp_business_management',
+ 'target_ids' => [waba_id, 'another_waba_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'validates successfully' do
+ expect { service.perform }.not_to raise_error
+ end
+ end
+
+ context 'when token does not have access to WABA' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'whatsapp_business_management',
+ 'target_ids' => ['different_waba_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error(/Token does not have access to WABA/)
+ end
+ end
+
+ context 'when no WABA scope is found' do
+ let(:debug_response) do
+ {
+ 'data' => {
+ 'granular_scopes' => [
+ {
+ 'scope' => 'some_other_scope',
+ 'target_ids' => ['some_id']
+ }
+ ]
+ }
+ }
+ end
+
+ before do
+ allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
+ end
+
+ it 'raises an error' do
+ expect { service.perform }.to raise_error('No WABA scope found in token')
+ end
+ end
+
+ context 'when access_token is blank' do
+ let(:access_token) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when waba_id is blank' do
+ let(:waba_id) { '' }
+
+ it 'raises ArgumentError' do
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb
new file mode 100644
index 000000000..89beca922
--- /dev/null
+++ b/spec/services/whatsapp/webhook_setup_service_spec.rb
@@ -0,0 +1,119 @@
+require 'rails_helper'
+
+describe Whatsapp::WebhookSetupService do
+ let(:channel) do
+ create(:channel_whatsapp,
+ phone_number: '+1234567890',
+ provider_config: {
+ 'phone_number_id' => 'test_phone_id',
+ 'webhook_verify_token' => 'test_verify_token'
+ },
+ provider: 'whatsapp_cloud',
+ sync_templates: false,
+ validate_provider_config: false)
+ end
+ let(:waba_id) { 'test_waba_id' }
+ let(:access_token) { 'test_access_token' }
+ let(:service) { described_class.new(channel, waba_id, access_token) }
+ let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+
+ before do
+ # Clean up any existing channels to avoid phone number conflicts
+ Channel::Whatsapp.destroy_all
+ allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
+ end
+
+ describe '#perform' do
+ context 'when all operations succeed' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'test_verify_token')
+ .and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'registers the phone number' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ service.perform
+ end
+ end
+
+ it 'sets up webhook subscription' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
+ service.perform
+ end
+ end
+ end
+
+ context 'when phone registration fails' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number)
+ .and_raise('Registration failed')
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .and_return({ 'success' => true })
+ end
+
+ it 'continues with webhook setup' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ expect { service.perform }.not_to raise_error
+ end
+ end
+ end
+
+ context 'when webhook setup fails' do
+ before do
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .and_raise('Webhook failed')
+ end
+
+ it 'raises an error' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect { service.perform }.to raise_error(/Webhook setup failed/)
+ end
+ end
+ end
+
+ context 'when required parameters are missing' do
+ it 'raises error when channel is nil' do
+ service = described_class.new(nil, waba_id, access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'Channel is required')
+ end
+
+ it 'raises error when waba_id is blank' do
+ service = described_class.new(channel, '', access_token)
+ expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
+ end
+
+ it 'raises error when access_token is blank' do
+ service = described_class.new(channel, waba_id, '')
+ expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
+ end
+ end
+
+ context 'when PIN already exists' do
+ before do
+ channel.provider_config['verification_pin'] = 123_456
+ allow(api_client).to receive(:register_phone_number)
+ allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'reuses existing PIN' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 123_456)
+ expect(SecureRandom).not_to receive(:random_number)
+ service.perform
+ end
+ end
+ end
+ end
+end