<%= I18n.t('public_portal.hero.sub_title') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
index 46d6f3558..00f408d70 100644
--- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
@@ -11,7 +11,7 @@
<% if portal.logo.present? %>
<% end %>
- <%= portal.name %>
+ <%= portal.localized_value('name', locale) %>
<%= I18n.t('public_portal.sidebar.help_center') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
index a236722ae..aeed7ff6c 100644
--- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
@@ -1,4 +1,4 @@
-
">
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
index 7c8dec8d2..ac36496df 100644
--- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
@@ -1,5 +1,5 @@
-
diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb
index 8577a5f4e..f77517618 100644
--- a/app/views/public/api/v1/portals/search/index.html+documentation.erb
+++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb
index 82c29775f..b4a1e77ed 100644
--- a/app/views/public/api/v1/portals/search/index.html.erb
+++ b/app/views/public/api/v1/portals/search/index.html.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %>
+
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
diff --git a/config/app.yml b/config/app.yml
index fec34cd07..c2494befa 100644
--- a/config/app.yml
+++ b/config/app.yml
@@ -1,5 +1,5 @@
shared: &shared
- version: '4.14.1'
+ version: '4.14.2'
development:
<<: *shared
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 15e78af9b..0158e3284 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -19,7 +19,7 @@ class Rack::Attack
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
class Request < ::Rack::Request
- # You many need to specify a method to fetch the correct remote IP address
+ # You may need to specify a method to fetch the correct remote IP address
# if the web server is behind a load balancer.
def remote_ip
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
@@ -31,9 +31,9 @@ class Rack::Attack
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
- # Rails would allow requests to paths with extentions, so lets compare against the path with extention stripped
+ # Rails would allow requests to paths with extensions, so lets compare against the path with extension stripped
# example /auth & /auth.json would both work
- def path_without_extentions
+ def path_without_extensions
path[/^[^.]+/]
end
end
@@ -75,11 +75,11 @@ class Rack::Attack
### Prevent Brute-Force Super Admin Login Attacks ###
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
- req.ip if req.path_without_extentions == '/super_admin/sign_in' && req.post?
+ req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
end
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
- if req.path_without_extentions == '/super_admin/sign_in' && req.post?
+ if req.path_without_extensions == '/super_admin/sign_in' && req.post?
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
# ref: https://github.com/rack/rack-attack/issues/399
@@ -91,7 +91,7 @@ class Rack::Attack
# ### Prevent Brute-Force Login Attacks ###
# Exclude MFA verification attempts from regular login throttling
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
- if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
+ if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# Skip if this is an MFA verification request
req.ip
end
@@ -99,7 +99,7 @@ class Rack::Attack
throttle('login/email', limit: 10, period: 15.minutes) do |req|
# Skip if this is an MFA verification request
- if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
+ if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# ref: https://github.com/rack/rack-attack/issues/399
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
@@ -110,11 +110,11 @@ class Rack::Attack
## Reset password throttling
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
- req.ip if req.path_without_extentions == '/auth/password' && req.post?
+ req.ip if req.path_without_extensions == '/auth/password' && req.post?
end
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
- if req.path_without_extentions == '/auth/password' && req.post?
+ if req.path_without_extensions == '/auth/password' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
@@ -122,11 +122,11 @@ class Rack::Attack
## Resend confirmation throttling (unauthenticated)
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
- req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
+ req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
end
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
- if req.path_without_extentions == '/resend_confirmation' && req.post?
+ if req.path_without_extensions == '/resend_confirmation' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
@@ -134,25 +134,25 @@ class Rack::Attack
## Resend confirmation throttling (authenticated)
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
- req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
+ req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
end
## MFA throttling - prevent brute force attacks
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
- if req.path_without_extentions == '/api/v1/profile/mfa'
+ if req.path_without_extensions == '/api/v1/profile/mfa'
req.ip if req.delete? # Throttle disable attempts
- elsif req.path_without_extentions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
+ elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
req.ip if req.post? # Throttle verify and backup_codes attempts
end
end
# Separate rate limiting for MFA verification attempts
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
- req.ip if req.path_without_extentions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
+ req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
end
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
- if req.path_without_extentions == '/auth/sign_in' && req.post?
+ if req.path_without_extensions == '/auth/sign_in' && req.post?
# Track by MFA token to prevent brute force on a specific token
mfa_token = req.params['mfa_token'].presence
(mfa_token.presence)
@@ -161,7 +161,7 @@ class Rack::Attack
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
- req.ip if req.path_without_extentions == '/api/v1/accounts' && req.post?
+ req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
end
##-----------------------------------------------##
@@ -176,17 +176,17 @@ class Rack::Attack
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
## Prevent Conversation Bombing on Widget APIs ###
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
- req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
+ req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
end
## Prevent Contact update Bombing in Widget API ###
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
- req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
+ req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
end
## Prevent Conversation Bombing through multiple sessions
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
- req.ip if req.path_without_extentions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
+ req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
end
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
index 9d0d1f35b..6e6ce8134 100644
--- a/config/initializers/session_store.rb
+++ b/config/initializers/session_store.rb
@@ -1,3 +1,10 @@
# Be sure to restart your server when you modify this file.
+# Sessions are used only for the super_admin dashboard (flash/CSRF), not for API auth.
-Rails.application.config.session_store :cookie_store, key: '_chatwoot_session', same_site: :lax
+secure_cookies = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
+
+Rails.application.config.session_store :cookie_store,
+ key: '_chatwoot_session',
+ same_site: :lax,
+ secure: secure_cookies,
+ httponly: true
diff --git a/config/routes.rb b/config/routes.rb
index 351808744..885b69062 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -55,7 +55,9 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
- resource :onboarding, only: [:update]
+ resource :onboarding, only: [:update] do
+ get :help_center_generation
+ end
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -265,6 +267,7 @@ Rails.application.routes.draw do
end
post :enable_whatsapp_calling, on: :member
post :disable_whatsapp_calling, on: :member
+ post :set_inbound_calls, on: :member
end
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates' do
diff --git a/db/migrate/20260604000000_add_provider_config_to_channel_twilio_sms.rb b/db/migrate/20260604000000_add_provider_config_to_channel_twilio_sms.rb
new file mode 100644
index 000000000..6d28aa5dd
--- /dev/null
+++ b/db/migrate/20260604000000_add_provider_config_to_channel_twilio_sms.rb
@@ -0,0 +1,5 @@
+class AddProviderConfigToChannelTwilioSms < ActiveRecord::Migration[7.1]
+ def change
+ add_column :channel_twilio_sms, :provider_config, :jsonb, default: {}
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f2c479571..eb6b82508 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: 2026_05_25_093000) do
+ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -557,6 +557,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.boolean "voice_enabled", default: false, null: false
t.string "twiml_app_sid"
t.string "api_key_secret"
+ t.jsonb "provider_config", default: {}
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
index fab952960..874197870 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -27,8 +27,8 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
def test
tool = account_custom_tools.new(custom_tool_params)
- result = execute_test_request(tool)
- render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
+ body = execute_test_request(tool)
+ render json: { status: 200, body: body.to_s.truncate(500) }
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_content
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index 76f578bf1..cdeeda74e 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -1,4 +1,6 @@
module Enterprise::Api::V1::Accounts::InboxesController
+ extend ActiveSupport::Concern
+
def inbox_attributes
super + ee_inbox_attributes
end
@@ -21,6 +23,24 @@ module Enterprise::Api::V1::Accounts::InboxesController
render_could_not_create_error(e.message)
end
+ # Toggles only the inbound-calls flag in provider_config. Saved with validate: false
+ # so WhatsApp's remote credential re-check (validate_provider_config) can't reject a
+ # simple toggle, mirroring enable_voice_calling!. Voice support (WhatsApp calling or
+ # Twilio voice) is guarded inline by ensure_inbound_calls_supported.
+ def set_inbound_calls
+ return unless ensure_inbound_calls_supported
+
+ channel = @inbox.channel
+ channel.provider_config = (channel.provider_config || {}).merge(
+ 'inbound_calls_enabled' => ActiveModel::Type::Boolean.new.cast(params[:inbound_calls_enabled])
+ )
+ channel.save!(validate: false)
+ @inbox.update_account_cache # bump inbox cache key so the cached inbox list refetches the new flag
+ head :ok
+ rescue StandardError => e
+ render_could_not_create_error(e.message)
+ end
+
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
@@ -35,6 +55,14 @@ module Enterprise::Api::V1::Accounts::InboxesController
false
end
+ # Inbound calls can be toggled on any voice-enabled inbox (WhatsApp calling or Twilio voice).
+ def ensure_inbound_calls_supported
+ return true if @inbox.channel.try(:voice_enabled?)
+
+ render_could_not_create_error('Inbox does not support calling')
+ false
+ end
+
def allowed_channel_types
super + ['voice']
end
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
new file mode 100644
index 000000000..1311bc3fc
--- /dev/null
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/onboardings_controller.rb
@@ -0,0 +1,38 @@
+module Enterprise::Api::V1::Accounts::OnboardingsController
+ def help_center_generation
+ @account = Current.account
+ render json: help_center_generation_status
+ end
+
+ private
+
+ def help_center_generation_status
+ generation_id = help_center_generation_id
+ return super if generation_id.blank?
+
+ state = Onboarding::HelpCenterGenerationState.current(generation_id)
+
+ {
+ generation_id: generation_id,
+ state: state,
+ articles_count: articles_count,
+ categories_count: categories_count
+ }
+ end
+
+ def help_center_generation_id
+ @account.custom_attributes['help_center_generation_id']
+ end
+
+ def articles_count
+ onboarding_portal&.articles&.count || 0
+ end
+
+ def categories_count
+ onboarding_portal&.categories&.count || 0
+ end
+
+ def onboarding_portal
+ @onboarding_portal ||= @account.portals.first
+ end
+end
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
index eb29c00bd..66fef9583 100644
--- a/enterprise/app/controllers/twilio/voice_controller.rb
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -24,6 +24,8 @@ class Twilio::VoiceController < ApplicationController
"TWILIO_VOICE_TWIML account=#{current_account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
)
+ return render xml: reject_twiml if reject_inbound?
+
call = resolve_call
render xml: conference_twiml(call)
end
@@ -88,6 +90,16 @@ class Twilio::VoiceController < ApplicationController
from_number.start_with?('client:')
end
+ # A fresh contact-initiated leg on an inbox with inbound calls turned off.
+ # Reject it so no conference, conversation, or Call row is created.
+ def reject_inbound?
+ twilio_direction == 'inbound' && !agent_leg?(twilio_from) && !inbox.channel.inbound_calls_enabled?
+ end
+
+ def reject_twiml
+ Twilio::TwiML::VoiceResponse.new(&:reject).to_s
+ end
+
def resolve_call
return find_call_for_agent if agent_leg?(twilio_from)
diff --git a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
index ed561d668..b5f7eb247 100644
--- a/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
+++ b/enterprise/app/jobs/onboarding/help_center_article_generation_job.rb
@@ -2,10 +2,10 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
queue_as :low
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
- _account_id, _portal_id, user_id, generation_id = job.arguments
+ _account_id, _portal_id, _user_id, generation_id = job.arguments
reason = "firecrawl exhausted: #{error.message}"
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
- job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
+ job.send(:skip_generation, generation_id: generation_id, reason: reason)
end
def perform(account_id, portal_id, user_id, generation_id)
@@ -19,7 +19,7 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
)
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
- skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
+ skip_generation(generation_id: generation_id, reason: e.message)
end
private
@@ -89,15 +89,12 @@ class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
articles.each do |article|
Onboarding::HelpCenterArticleWriterJob.perform_later(
- account_id, portal_id, user_id, generation_id, { article: article }
+ account_id, portal_id, user_id, generation_id, article
)
end
end
- def skip_and_broadcast(user:, generation_id:, reason:)
+ def skip_generation(generation_id:, reason:)
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
- Onboarding::HelpCenterBroadcaster.completed(
- user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
- )
end
end
diff --git a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
index 2c25b86e9..68c218a19 100644
--- a/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
+++ b/enterprise/app/jobs/onboarding/help_center_article_writer_job.rb
@@ -9,43 +9,27 @@ class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
job.send(:on_writer_failure, error)
end
- def perform(account_id, portal_id, user_id, generation_id, article_payload)
- user = User.find(user_id)
- payload = article_payload.with_indifferent_access
- article = Onboarding::HelpCenterArticleBuilder.new(
+ def perform(account_id, portal_id, user_id, generation_id, article)
+ Onboarding::HelpCenterArticleBuilder.new(
account: Account.find(account_id),
portal: Portal.find(portal_id),
- user: user,
- article: payload[:article]
+ user: User.find(user_id),
+ article: article
).perform
- finalize(user: user, generation_id: generation_id, article: article)
+ finalize(generation_id: generation_id)
end
private
def on_writer_failure(error)
- user, generation_id = failure_context
+ generation_id = arguments[3]
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
- finalize(user: user, generation_id: generation_id, article: nil)
+ finalize(generation_id: generation_id)
end
- def failure_context
- _account_id, _portal_id, user_id, generation_id = arguments
- [User.find_by(id: user_id), generation_id]
- end
-
- def finalize(user:, generation_id:, article:)
- result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
-
- if article
- Onboarding::HelpCenterBroadcaster.article_generated(
- user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
- )
- end
- return unless result[:completed]
-
- Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
+ def finalize(generation_id:)
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
rescue Onboarding::HelpCenterGenerationState::Missing => e
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
end
diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb
index 93f68b05b..3e05244d3 100644
--- a/enterprise/app/services/captain/llm/translate_query_service.rb
+++ b/enterprise/app/services/captain/llm/translate_query_service.rb
@@ -32,6 +32,10 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def query_in_target_language?(query)
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
result = detector.find_language(query)
diff --git a/enterprise/app/services/onboarding/help_center_broadcaster.rb b/enterprise/app/services/onboarding/help_center_broadcaster.rb
deleted file mode 100644
index e12ed4287..000000000
--- a/enterprise/app/services/onboarding/help_center_broadcaster.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-module Onboarding::HelpCenterBroadcaster
- ARTICLE_GENERATED = 'help_center.article_generated'.freeze
- GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
-
- module_function
-
- def article_generated(user:, generation_id:, article:, articles_finished:)
- broadcast(user, ARTICLE_GENERATED, {
- generation_id: generation_id,
- article_id: article.id,
- articles_finished: articles_finished
- })
- end
-
- def completed(user:, generation_id:, status:, skip_reason: nil)
- broadcast(user, GENERATION_COMPLETED, {
- generation_id: generation_id,
- status: status,
- skip_reason: skip_reason
- })
- end
-
- def broadcast(user, event, payload)
- token = user&.pubsub_token
- return if token.blank?
-
- ActionCableBroadcastJob.perform_later([token], event, payload)
- end
-end
diff --git a/enterprise/app/services/onboarding/help_center_creation_service.rb b/enterprise/app/services/onboarding/help_center_creation_service.rb
index 7ccd9bff3..9bbf053b5 100644
--- a/enterprise/app/services/onboarding/help_center_creation_service.rb
+++ b/enterprise/app/services/onboarding/help_center_creation_service.rb
@@ -77,6 +77,7 @@ class Onboarding::HelpCenterCreationService
generation_id = SecureRandom.uuid
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
+ @account.update!(custom_attributes: @account.custom_attributes.merge('help_center_generation_id' => generation_id))
rescue StandardError => e
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
end
diff --git a/enterprise/app/services/whatsapp/call_service.rb b/enterprise/app/services/whatsapp/call_service.rb
index 93eba957c..743409839 100644
--- a/enterprise/app/services/whatsapp/call_service.rb
+++ b/enterprise/app/services/whatsapp/call_service.rb
@@ -9,7 +9,7 @@ class Whatsapp::CallService
call.with_lock do
transition_to_in_progress!
update_message_status('in_progress')
- update_conversation_call_status(call.display_status)
+ claim_conversation_and_set_call_status
broadcast(:accepted, accepted_by_agent_id: agent.id)
end
call
@@ -56,7 +56,6 @@ class Whatsapp::CallService
forward_answer_to_meta!
call.update!(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: Time.current,
meta: (call.meta || {}).merge('sdp_answer' => sdp_answer))
- claim_conversation_for_agent
end
def forward_answer_to_meta!
@@ -64,9 +63,13 @@ class Whatsapp::CallService
invoke_provider!(:accept_call, sdp_answer)
end
- # Take ownership of the conversation if no one holds it; leave assignee alone otherwise (transfer via UI).
- def claim_conversation_for_agent
- call.conversation.update!(assignee: agent) if call.conversation.assignee_id.blank?
+ # Claim an unheld conversation and set call_status in one save so previous_changes carries both the
+ # assignee change (activity message + ASSIGNEE_CHANGED) and the call_status change (conversation.updated webhook).
+ def claim_conversation_and_set_call_status
+ conversation = call.conversation
+ attrs = { additional_attributes: (conversation.additional_attributes || {}).merge('call_status' => call.display_status) }
+ attrs[:assignee] = agent if conversation.assignee_id.blank?
+ conversation.update!(attrs)
end
# Raise on Meta failure (bool false or transport error) so callers bail before
diff --git a/enterprise/app/services/whatsapp/incoming_call_service.rb b/enterprise/app/services/whatsapp/incoming_call_service.rb
index afca508a8..f6185050d 100644
--- a/enterprise/app/services/whatsapp/incoming_call_service.rb
+++ b/enterprise/app/services/whatsapp/incoming_call_service.rb
@@ -72,6 +72,12 @@ class Whatsapp::IncomingCallService
end
def create_inbound_call(payload)
+ unless inbox.channel.inbound_calls_enabled?
+ Rails.logger.info "[WHATSAPP CALL] Inbound calls disabled for inbox #{inbox.id}; rejecting call #{payload[:id]}"
+ inbox.channel.provider_service.reject_call(payload[:id])
+ return
+ end
+
sdp_offer = payload.dig(:session, :sdp)
extra_meta = { 'sdp_offer' => sdp_offer, 'ice_servers' => Call.default_ice_servers }
name = caller_profile_name(payload)
diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb
index aa40e8000..c45559165 100644
--- a/enterprise/lib/captain/conversation_completion_service.rb
+++ b/enterprise/lib/captain/conversation_completion_service.rb
@@ -62,6 +62,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def event_name
'captain.conversation_completion'
end
diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb
index b4593d27f..18ebcf53a 100644
--- a/enterprise/lib/captain/tools/http_tool.rb
+++ b/enterprise/lib/captain/tools/http_tool.rb
@@ -15,8 +15,8 @@ class Captain::Tools::HttpTool < Agents::Tool
url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params)
- response = execute_http_request(url, body, tool_context)
- @custom_tool.format_response(response.body)
+ response_body = execute_http_request(url, body, tool_context)
+ @custom_tool.format_response(response_body)
rescue StandardError => e
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
'An error occurred while executing the request'
@@ -24,89 +24,32 @@ class Captain::Tools::HttpTool < Agents::Tool
private
- PRIVATE_IP_RANGES = [
- IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
- IPAddr.new('10.0.0.0/8'), # IPv4 Private network
- IPAddr.new('172.16.0.0/12'), # IPv4 Private network
- IPAddr.new('192.168.0.0/16'), # IPv4 Private network
- IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
- IPAddr.new('::1'), # IPv6 Loopback
- IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
- IPAddr.new('fe80::/10') # IPv6 Link-local
- ].freeze
-
# Limit response size to prevent memory exhaustion and match LLM token limits
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
MAX_RESPONSE_SIZE = 1.megabyte
+ # Route through SafeFetch so custom tool requests share the app's centralized HTTP
+ # fetching (resolution, timeouts, response size limits, and redirect handling).
def execute_http_request(url, body, tool_context)
- uri = URI.parse(url)
+ json_body = body if @custom_tool.http_method == 'POST'
- # Check if resolved IP is private
- check_private_ip!(uri.host)
-
- http = Net::HTTP.new(uri.host, uri.port)
- http.use_ssl = uri.scheme == 'https'
- http.read_timeout = 30
- http.open_timeout = 10
- http.max_retries = 0 # Disable redirects
-
- request = build_http_request(uri, body)
- apply_authentication(request)
- apply_metadata_headers(request, tool_context)
-
- response = http.request(request)
-
- raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
-
- validate_response!(response)
-
- response
+ response_body = +''
+ SafeFetch.fetch(
+ url,
+ method: @custom_tool.http_method == 'POST' ? :post : :get,
+ body: json_body,
+ headers: request_headers(tool_context, json_body),
+ http_basic_authentication: @custom_tool.build_basic_auth_credentials,
+ max_bytes: MAX_RESPONSE_SIZE,
+ validate_content_type: false
+ ) { |result| response_body = result.tempfile.read }
+ response_body
end
- def check_private_ip!(hostname)
- ip_address = IPAddr.new(Resolv.getaddress(hostname))
-
- raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
- rescue Resolv::ResolvError, SocketError => e
- raise "DNS resolution failed: #{e.message}"
- end
-
- def validate_response!(response)
- content_length = response['content-length']&.to_i
- if content_length && content_length > MAX_RESPONSE_SIZE
- raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
- end
-
- return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
-
- raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
- end
-
- def build_http_request(uri, body)
- if @custom_tool.http_method == 'POST'
- request = Net::HTTP::Post.new(uri.request_uri)
- if body
- request.body = body
- request['Content-Type'] = 'application/json'
- end
- else
- request = Net::HTTP::Get.new(uri.request_uri)
- end
- request
- end
-
- def apply_authentication(request)
+ def request_headers(tool_context, json_body)
headers = @custom_tool.build_auth_headers
- headers.each { |key, value| request[key] = value }
-
- credentials = @custom_tool.build_basic_auth_credentials
- request.basic_auth(*credentials) if credentials
- end
-
- def apply_metadata_headers(request, tool_context)
- state = tool_context&.state || {}
- metadata_headers = @custom_tool.build_metadata_headers(state)
- metadata_headers.each { |key, value| request[key] = value }
+ headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {}))
+ headers['Content-Type'] = 'application/json' if json_body.present?
+ headers
end
end
diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb
index a043d38e2..d382204a5 100644
--- a/lib/captain/base_task_service.rb
+++ b/lib/captain/base_task_service.rb
@@ -150,13 +150,12 @@ class Captain::BaseTaskService
end
# Extension point consulted by the Enterprise quota wrapper. Subclasses
- # whose calls run on the operator's key (e.g. internal/onboarding tasks)
- # should override this to return false. When false, the wrapper neither
- # blocks the call on an exhausted captain_responses quota nor decrements
- # it on success — the call participates in the quota system in neither
- # direction.
+ # whose calls should not consume captain_responses should override this to
+ # return false. When false, the wrapper neither blocks the call on an
+ # exhausted captain_responses quota nor decrements it on success — the call
+ # participates in the quota system in neither direction.
def counts_toward_usage?
- true
+ llm_credential&.dig(:source) != :hook
end
def api_key_configured?
@@ -168,7 +167,15 @@ class Captain::BaseTaskService
end
def llm_credential
- @llm_credential ||= hook_llm_credential || system_llm_credential
+ @llm_credential ||= if use_account_openai_hook?
+ hook_llm_credential || system_llm_credential
+ else
+ system_llm_credential
+ end
+ end
+
+ def use_account_openai_hook?
+ false
end
def hook_llm_credential
diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb
index e04a98a7f..7aab18e6c 100644
--- a/lib/captain/csat_utility_analysis_service.rb
+++ b/lib/captain/csat_utility_analysis_service.rb
@@ -63,4 +63,8 @@ class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
def event_name
'csat_utility_analysis'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/follow_up_service.rb b/lib/captain/follow_up_service.rb
index f02ba9408..c4c1225be 100644
--- a/lib/captain/follow_up_service.rb
+++ b/lib/captain/follow_up_service.rb
@@ -103,4 +103,8 @@ class Captain::FollowUpService < Captain::BaseTaskService
def event_name
'follow_up'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb
index 02f8bd89a..a0e030963 100644
--- a/lib/captain/label_suggestion_service.rb
+++ b/lib/captain/label_suggestion_service.rb
@@ -87,6 +87,10 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
'label_suggestion'
end
+ def use_account_openai_hook?
+ true
+ end
+
def build_follow_up_context?
false
end
diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb
index 2daf0615c..039bdcf26 100644
--- a/lib/captain/reply_suggestion_service.rb
+++ b/lib/captain/reply_suggestion_service.rb
@@ -37,6 +37,10 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
def event_name
'reply_suggestion'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
diff --git a/lib/captain/rewrite_service.rb b/lib/captain/rewrite_service.rb
index 3a217d3c6..6f880e775 100644
--- a/lib/captain/rewrite_service.rb
+++ b/lib/captain/rewrite_service.rb
@@ -56,4 +56,8 @@ class Captain::RewriteService < Captain::BaseTaskService
def event_name
operation
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb
index 030c0e510..f06aa42ca 100644
--- a/lib/captain/summary_service.rb
+++ b/lib/captain/summary_service.rb
@@ -24,4 +24,8 @@ class Captain::SummaryService < Captain::BaseTaskService
def event_name
'summarize'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index 665d4c80c..fa191f5ed 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
+ # Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
+ # so cells without an explicit colwidth render the same minimum here as in the editor.
+ TABLE_CELL_MIN_WIDTH_PX = 50
+ COLWIDTHS_COMMENT = //
+
+ # The article editor serializes column widths as a `` HTML
+ # comment immediately before each resized table. Capture it (emitting nothing) so the
+ # next `table` can size itself; any other raw HTML keeps its default rendering.
+ def html(node)
+ match = node.string_content.match(COLWIDTHS_COMMENT)
+ return super unless match
+
+ @pending_colwidths = match[1].split(',').map(&:to_i)
+ end
+
def table(node)
- out('
')
- super
+ widths = @pending_colwidths
+ @pending_colwidths = nil
+
+ if sized_widths?(widths)
+ out(table_wrapper_open(widths))
+ out(inject_table_sizing(capture_html { super(node) }, widths))
+ else
+ out('
')
+ super
+ end
out('
')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
+ def sized_widths?(widths)
+ widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
+ end
+
+ def fully_sized?(widths)
+ widths.all? { |w| w.to_i.positive? }
+ end
+
+ # Fully-sized tables hug their exact width so the card doesn't trail empty space;
+ # partial tables stay a plain full-width card so flexible columns can expand.
+ def table_wrapper_open(widths)
+ return '
' unless fully_sized?(widths)
+
+ %(
)
+ end
+
+ # Let the gem render the whole table, then splice a
and sizing style
+ # into the opening tag. Delegating the row/cell/tbody/alignment markup to
+ # super keeps this working across commonmarker upgrades.
+ # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
+ def inject_table_sizing(html, widths)
+ opening = %(\n#{colgroup_html(widths)})
+ html.sub(/]*>\n?/, opening)
+ end
+
+ # Capture everything `super` writes by swapping the renderer's output buffer.
+ def capture_html
+ original = @stream
+ @stream = StringIO.new(+'')
+ yield
+ @stream.string
+ ensure
+ @stream = original
+ end
+
+ # Total table width: each column's saved width, or the cell min for unsized ones.
+ def total_width(widths)
+ widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
+ end
+
+ # Fully sized → lock to the exact total (min-width too, so a narrow saved width
+ # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
+ # the container (flexible columns) yet scrolls when the sized columns exceed it.
+ def table_sizing_style(widths)
+ total = total_width(widths)
+ return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
+
+ "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
+ end
+
+ def colgroup_html(widths)
+ cols = widths.map { |w| w.to_i.positive? ? %() : '' }
+ "#{cols.join}\n"
+ end
+
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index 25d0e5196..006a862b2 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -44,6 +44,12 @@ conversations:
- "not_equal_to"
- "is_present"
- "is_not_present"
+ contact_id:
+ attribute_type: "standard"
+ data_type: "number"
+ filter_operators:
+ - "equal_to"
+ - "not_equal_to"
priority:
attribute_type: "standard"
data_type: "text"
diff --git a/package.json b/package.json
index d8527051d..fc4598bc4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.14.1",
+ "version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.17",
+ "@chatwoot/prosemirror-schema": "1.3.22",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4b61061c..6c7bebb79 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.17
- version: 1.3.17
+ specifier: 1.3.22
+ version: 1.3.22
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.17':
- resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
+ '@chatwoot/prosemirror-schema@1.3.22':
+ resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.17':
+ '@chatwoot/prosemirror-schema@1.3.22':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/spec/builders/contact_inbox_with_contact_builder_spec.rb b/spec/builders/contact_inbox_with_contact_builder_spec.rb
index 60f0834af..58502539f 100644
--- a/spec/builders/contact_inbox_with_contact_builder_spec.rb
+++ b/spec/builders/contact_inbox_with_contact_builder_spec.rb
@@ -39,6 +39,21 @@ describe ContactInboxWithContactBuilder do
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
+ it 'truncates long contact names before creating the contact' do
+ long_name = 'a' * 300
+
+ contact_inbox = described_class.new(
+ source_id: '123456',
+ inbox: inbox,
+ contact_attributes: {
+ name: long_name,
+ email: 'testemail@example.com'
+ }
+ ).perform
+
+ expect(contact_inbox.contact.name).to eq(long_name.first(ApplicationRecord::MAX_STRING_COLUMN_LENGTH))
+ end
+
it 'doesnot create contact if it already exist with identifier' do
contact_inbox = described_class.new(
source_id: '123456',
diff --git a/spec/config/session_store_spec.rb b/spec/config/session_store_spec.rb
new file mode 100644
index 000000000..a4f74af52
--- /dev/null
+++ b/spec/config/session_store_spec.rb
@@ -0,0 +1,29 @@
+require 'rails_helper'
+
+# rubocop:disable RSpec/DescribeClass
+describe 'Session Store Configuration' do
+ # rubocop:enable RSpec/DescribeClass
+
+ let(:session_options) { Rails.application.config.session_options }
+
+ it 'uses cookie_store as the session store' do
+ expect(Rails.application.config.session_store).to eq(ActionDispatch::Session::CookieStore)
+ end
+
+ it 'sets the session key' do
+ expect(session_options[:key]).to eq('_chatwoot_session')
+ end
+
+ it 'sets same_site to lax' do
+ expect(session_options[:same_site]).to eq(:lax)
+ end
+
+ it 'sets httponly to true' do
+ expect(session_options[:httponly]).to be(true)
+ end
+
+ it 'sets secure flag based on FORCE_SSL' do
+ expected_secure = ActiveModel::Type::Boolean.new.cast(ENV.fetch('FORCE_SSL', false))
+ expect(session_options[:secure]).to eq(expected_secure)
+ end
+end
diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
index f8fd446d2..bdb8117ac 100644
--- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
@@ -141,6 +141,7 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
+ 'all_count' => 1,
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 9e03e2587..0fd6ad7bf 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -100,6 +100,35 @@ RSpec.describe 'Inboxes API', type: :request do
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
+ it 'returns reauthorization_required for embedded signup whatsapp channel when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(true)
+ end
+
+ it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_channel.update!(provider_config: whatsapp_channel.provider_config.merge('source' => 'manual'))
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(false)
+ end
+
it 'returns the inbox if assigned inbox is assigned as agent' do
create(:inbox_member, user: agent, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
index ec2b4dcaa..6f118624b 100644
--- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as an agent (non-admin)' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when no help center generation has started' do
+ it 'returns not_started with zero counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => nil,
+ 'state' => nil,
+ 'articles_count' => 0,
+ 'categories_count' => 0
+ )
+ end
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 860791c0e..ccb5d7449 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'layout' => 'classic',
- 'social_profiles' => {}
+ 'social_profiles' => {},
+ 'locale_translations' => {}
}
)
end
diff --git a/spec/drops/contact_drop_spec.rb b/spec/drops/contact_drop_spec.rb
index d00a0924d..cd6d1a185 100644
--- a/spec/drops/contact_drop_spec.rb
+++ b/spec/drops/contact_drop_spec.rb
@@ -11,6 +11,11 @@ describe ContactDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word (capitalized) as first name when name has only one word' do
+ contact.update!(name: 'john')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized name') do
contact.update!(name: 'john doe')
expect(subject.name).to eq 'John Doe'
diff --git a/spec/drops/user_drop_spec.rb b/spec/drops/user_drop_spec.rb
index 1093ec4a0..34f8f5eaa 100644
--- a/spec/drops/user_drop_spec.rb
+++ b/spec/drops/user_drop_spec.rb
@@ -11,6 +11,11 @@ describe UserDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word as first name when name has only one word' do
+ user.update!(name: 'John')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized first name') do
user.update!(name: 'john doe')
expect(subject.first_name).to eq 'John'
diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb
index 9ebaf3a55..d3f3cb601 100644
--- a/spec/enterprise/builders/saml_user_builder_spec.rb
+++ b/spec/enterprise/builders/saml_user_builder_spec.rb
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
it 'does not add the user to the target account' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
it 'does not convert the user provider to saml' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to(change { existing_user.reload.provider })
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
new file mode 100644
index 000000000..59d0564fa
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Onboarding API', type: :request do
+ let(:account) { create(:account, domain: 'example.com') }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when help center generation is in progress' do
+ let(:generation_id) { 'generation-123' }
+ let!(:portal) { create(:portal, account_id: account.id) }
+ let!(:category) { create(:category, portal: portal, account_id: account.id) }
+
+ before do
+ account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
+ create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
+ end
+
+ after do
+ Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
+ end
+
+ it 'returns Redis state and help center counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => generation_id,
+ 'articles_count' => 1,
+ 'categories_count' => 1
+ )
+ expect(response.parsed_body['state']).to include(
+ 'status' => 'generating',
+ 'finished' => '1',
+ 'total' => '3'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
index 709a8da22..ead387995 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
@@ -49,6 +49,59 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
end
+ describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_inbound_calls' do
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
+ end
+
+ context 'when administrator' do
+ it 'disables inbound calls on a Twilio voice inbox' do
+ channel = create(:channel_twilio_sms, :with_voice, account: account)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
+ headers: admin.create_new_auth_token,
+ params: { inbound_calls_enabled: false },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(channel.reload.inbound_calls_enabled?).to be false
+ end
+
+ it 'enables inbound calls on a WhatsApp inbox without re-validating provider config' do
+ account.enable_features('channel_voice')
+ account.save!
+ channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
+ validate_provider_config: false, sync_templates: false)
+ channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true, 'inbound_calls_enabled' => false))
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
+ headers: admin.create_new_auth_token,
+ params: { inbound_calls_enabled: true },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(channel.reload.inbound_calls_enabled?).to be true
+ end
+ end
+
+ context 'when agent' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'is forbidden' do
+ channel = create(:channel_twilio_sms, :with_voice, account: account)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{channel.inbox.id}/set_inbound_calls",
+ headers: agent.create_new_auth_token,
+ params: { inbound_calls_enabled: false },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(channel.reload.inbound_calls_enabled?).to be true
+ end
+ end
+ end
+
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
diff --git a/spec/enterprise/controllers/twilio/voice_controller_spec.rb b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
index cde513c86..79fdd67a8 100644
--- a/spec/enterprise/controllers/twilio/voice_controller_spec.rb
+++ b/spec/enterprise/controllers/twilio/voice_controller_spec.rb
@@ -112,6 +112,23 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
}
expect(response).to have_http_status(:not_found)
end
+
+ it 'rejects the inbound contact leg without building a call when inbound calls are disabled' do
+ channel.update!(provider_config: { 'inbound_calls_enabled' => false })
+ expect(Voice::InboundCallBuilder).not_to receive(:perform!)
+
+ expect do
+ post "/twilio/voice/call/#{digits}", params: {
+ 'CallSid' => call_sid,
+ 'From' => from_number,
+ 'To' => to_number,
+ 'Direction' => 'inbound'
+ }
+ end.not_to change(Call, :count)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(' hash_including(
- 'title' => 'Hello',
- 'urls' => ['https://x.test/a'],
- 'category_id' => portal.categories.first.id
- )
+ 'title' => 'Hello',
+ 'urls' => ['https://x.test/a'],
+ 'category_id' => portal.categories.first.id
)
)
)
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Valid'))
+ hash_including('title' => 'Valid')
)
end
end
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
+ hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
expect(state['skip_reason']).to include('firecrawl exhausted')
end
end
-
- describe 'broadcasts' do
- it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
- curator = instance_double(Onboarding::HelpCenterCurator)
- allow(curator).to receive(:perform).and_raise(
- Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
- )
- allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
-
- payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
- end
end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
index a4db6b1d3..b01ec35e3 100644
--- a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
+++ b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
- let(:article_payload) { { 'article' => article_spec } }
- let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
+ let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
- it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
+ it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
- payload = hash_including(generation_id: generation_id, status: 'completed')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ described_class.perform_now(*job_args)
+
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
end
end
- describe 'broadcasts' do
+ describe 'missing state' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
- it 'broadcasts help_center.article_generated on success' do
- payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.article_generated', payload)
- end
-
- it 'broadcasts help_center.generation_completed when the last writer finishes' do
- described_class.perform_now(*job_args)
- payload = hash_including(generation_id: generation_id, status: 'completed')
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
-
- it 'does not broadcast article_generated on builder failure' do
- allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
- Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
- )
-
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
- .with(anything, 'help_center.article_generated', anything)
- end
-
- it 'broadcasts generation_completed on late retries past total' do
- described_class.perform_now(*job_args)
- described_class.perform_now(*job_args)
- clear_enqueued_jobs
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
- end
-
- it 'skips progress broadcasts when state is missing' do
+ it 'does not raise when state is missing' do
Redis::Alfred.delete(state_key)
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
+ expect { described_class.perform_now(*job_args) }.not_to raise_error
end
end
end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index b3dc473eb..fb970f726 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
context 'when usage limit is exceeded' do
@@ -111,6 +112,68 @@ RSpec.describe Captain::BaseTaskService, type: :model do
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
+ context 'when account has its own OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'still increments usage for services that do not opt into BYOK' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'returns usage limit exceeded error for services that do not opt into BYOK' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
+ expect(result[:error_code]).to eq(429)
+ end
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ result = perform_result
+ klass = Class.new(described_class) do
+ define_method(:perform) { result }
+ define_method(:event_name) { 'test_event' }
+ define_method(:use_account_openai_hook?) { true }
+ end
+ klass.prepend(Enterprise::Captain::BaseTaskService)
+ klass
+ end
+
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'does not increment usage on a successful result' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'bypasses the 429 gate and returns the underlying result' do
+ result = service.perform
+ expect(result).to eq(perform_result)
+ end
+ end
+ end
+
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
diff --git a/spec/enterprise/models/channel/twilio_sms_voice_spec.rb b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb
index b2d5dba48..fb768f01b 100644
--- a/spec/enterprise/models/channel/twilio_sms_voice_spec.rb
+++ b/spec/enterprise/models/channel/twilio_sms_voice_spec.rb
@@ -38,6 +38,19 @@ RSpec.describe Channel::TwilioSms do
end
end
+ describe '#inbound_calls_enabled?' do
+ it 'returns true by default when nothing has been toggled' do
+ channel = create(:channel_twilio_sms, :with_voice, account: account)
+ expect(channel.inbound_calls_enabled?).to be true
+ end
+
+ it 'returns false only when explicitly disabled in provider_config' do
+ channel = create(:channel_twilio_sms, :with_voice, account: account,
+ provider_config: { 'inbound_calls_enabled' => false })
+ expect(channel.inbound_calls_enabled?).to be false
+ end
+ end
+
describe '#voice_call_webhook_url' do
it 'returns the webhook URL based on phone number' do
channel = create(:channel_twilio_sms, :with_voice)
diff --git a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
index cbb2d6c98..e8f850e78 100644
--- a/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/enterprise/services/conversations/unread_counts/counter_spec.rb
@@ -26,6 +26,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -40,6 +41,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(2)
expect(result[:inboxes]).to eq(inbox.id.to_s => 2)
expect(result[:labels]).to eq(label.id.to_s => 2)
expect(result[:teams]).to eq(team.id.to_s => 2)
@@ -53,6 +55,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
+ expect(result[:all_count]).to eq(1)
expect(result[:inboxes]).to eq(inbox.id.to_s => 1)
expect(result[:labels]).to eq(label.id.to_s => 1)
expect(result[:teams]).to eq(team.id.to_s => 1)
@@ -65,7 +68,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
- expect(result).to eq(inboxes: {}, labels: {}, teams: {})
+ expect(result).to eq(all_count: 0, inboxes: {}, labels: {}, teams: {})
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
end
diff --git a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
index 53b8ec52f..4651b5f13 100644
--- a/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
+++ b/spec/enterprise/services/whatsapp/incoming_call_service_spec.rb
@@ -30,6 +30,20 @@ describe Whatsapp::IncomingCallService do
end
end
+ context 'when inbound calls are disabled on the channel' do
+ it 'rejects the call with Meta without creating a Call or Conversation' do
+ channel.provider_config = channel.provider_config.merge('inbound_calls_enabled' => false)
+ channel.save!
+ provider_service = instance_double(Whatsapp::Providers::WhatsappCloudService, reject_call: true)
+ allow(inbox.channel).to receive(:provider_service).and_return(provider_service)
+
+ params = call_payload(event: 'connect', session: { sdp: "v=0\r\n...sdp...", sdp_type: 'offer' })
+ expect { described_class.new(inbox: inbox, params: params).perform }
+ .to not_change(Call, :count).and not_change(Conversation, :count)
+ expect(provider_service).to have_received(:reject_call).with(provider_call_id)
+ end
+ end
+
describe 'inbound connect' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let!(:agent) { create(:user, account: account) }
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 91a56407d..e2c03c55a 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -55,4 +55,57 @@ RSpec.describe MutexApplicationJob do
end.to raise_error(StandardError)
end
end
+
+ describe '.retry_on_lock_conflict' do
+ let(:job_class) do
+ Class.new(MutexApplicationJob) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
+
+ attr_reader :fallback_args
+
+ def perform(lock_key, _payload)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+
+ def process_without_lock(lock_key, payload)
+ @fallback_args = [lock_key, payload]
+ end
+ end
+ end
+
+ let(:payload) { { 'message' => 'hello' } }
+
+ before do
+ stub_const('LockConflictTestJob', job_class)
+ end
+
+ it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ job = job_class.new(lock_key, payload)
+
+ expect { job.perform_now }.not_to raise_error
+ expect(job.fallback_args).to eq([lock_key, payload])
+ end
+
+ context 'without an exhaustion handler' do
+ let(:job_class) do
+ Class.new(MutexApplicationJob) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1
+
+ def perform(lock_key)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+ end
+ end
+
+ it 'raises the lock acquisition error when retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ expect do
+ job_class.perform_now(lock_key)
+ end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
+ end
+ end
+ end
end
diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
index d82658102..8d1b24b52 100644
--- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb
+++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
@@ -62,6 +62,14 @@ RSpec.describe Webhooks::WhatsappEventsJob do
job.perform_now(params)
end
+ it 'still enqueues for manual channels even when reauthorization required' do
+ channel.update!(provider_config: channel.provider_config.merge('source' => 'manual'))
+ channel.prompt_reauthorization!
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
+ job.perform_now(params)
+ end
+
it 'will not enqueue if channel is not present' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index 5112e47ed..34c889967 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -260,11 +260,12 @@ RSpec.describe Captain::BaseTaskService do
expect(result[:request_messages]).to eq(messages)
end
- it 'does not track exceptions for account hook failures' do
+ it 'tracks exceptions against the system key when an account hook exists' do
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
- expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
- expect(ChatwootExceptionTracker).not_to receive(:new)
+ expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_raise(error)
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ expect(exception_tracker).to receive(:capture_exception)
result = service.send(:make_api_call, model: model, messages: messages)
@@ -279,11 +280,60 @@ RSpec.describe Captain::BaseTaskService do
before { hook }
+ it 'uses system api key by default' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ Class.new(described_class) do
+ def event_name
+ 'test_event'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+ end
+ end
+
+ before do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ end
+
it 'uses api key from hook' do
expect(service.send(:api_key)).to eq('hook-key')
end
end
+ it 'uses account OpenAI hook for editor task services' do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ user = create(:user, account: account)
+ follow_up_context = {
+ 'event_name' => 'professional',
+ 'original_context' => 'Original text',
+ 'last_response' => 'Last response'
+ }
+
+ editor_services = [
+ Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
+ Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
+ Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::FollowUpService.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: 'Make it shorter',
+ conversation_display_id: conversation.display_id
+ )
+ ]
+
+ editor_services.each do |editor_service|
+ expect(editor_service.send(:api_key)).to eq('hook-key')
+ end
+ end
+
context 'when openai hook is not configured' do
it 'uses system api key' do
expect(service.send(:api_key)).to eq('test-key')
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
index e4e980e01..34e0c9ece 100644
--- a/spec/lib/captain/csat_utility_analysis_service_spec.rb
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -4,6 +4,11 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
+ end
+
describe '#perform' do
before do
allow(account).to receive(:feature_enabled?).and_call_original
@@ -21,4 +26,22 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
end
end
+
+ describe '#api_key' do
+ context 'when account has an OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'uses the account hook key' do
+ expect(service.send(:api_key)).to eq('customer-own-key')
+ end
+ end
+
+ context 'when account does not have an OpenAI hook key' do
+ it 'uses the system key' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+ end
end
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 28c5e069c..6484f2e6c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
+ describe '#table' do
+ def render_table(markdown)
+ doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
+ described_class.new.render(doc)
+ end
+
+ let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
+
+ it 'renders a table without column widths when no marker is present' do
+ output = render_table(plain_table)
+ expect(output).to include('')
+ expect(output).not_to include('colgroup')
+ expect(output).not_to include('cw-colwidths')
+ end
+
+ context 'when every column has a saved width' do
+ it 'lays the table out at the total width with a sized colgroup' do
+ output = render_table("\n#{plain_table}")
+ # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
+ expect(output).to include('')
+ expect(output).to include('
')
+ expect(output).to include('')
+ end
+ end
+
+ context 'when only some columns have a saved width' do
+ it 'fills the container so unsized columns stay flexible, floored at the sized total' do
+ output = render_table("\n#{plain_table}")
+ # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
+ expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
+ expect(output).to include('')
+ # No exact-width lock on the wrapper or table — the table must be free to expand.
+ expect(output).to include('')
+ expect(output).to include('width: 400px !important;')
+ expect(output).to include('')
+ expect(output.scan('colgroup').length).to eq(2)
+ end
+
+ it 'does not emit the marker comment into the rendered html' do
+ expect(render_table("\n#{plain_table}")).not_to include('cw-colwidths')
+ end
+ end
+
describe '#image' do
it 'renders width in px with responsive cap and auto height' do
markdown = ''
diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb
index 08deeb6c4..e3f9f0402 100644
--- a/spec/listeners/agent_bot_listener_spec.rb
+++ b/spec/listeners/agent_bot_listener_spec.rb
@@ -82,7 +82,7 @@ describe AgentBotListener do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
- hash_including(event: 'conversation_status_changed', changed_attributes: anything),
+ hash_including(event: 'conversation_status_changed', account: account.webhook_data, changed_attributes: anything),
:agent_bot_webhook,
hash_including(secret: agent_bot.secret)
).once
@@ -158,6 +158,24 @@ describe AgentBotListener do
end
end
+ describe '#conversation_resolved' do
+ let(:event_name) { 'conversation.resolved' }
+ let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
+
+ context 'when agent bot is configured' do
+ it 'sends account details in the conversation payload' do
+ create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
+ expect(AgentBots::WebhookJob).to receive(:perform_later).with(
+ agent_bot.outgoing_url,
+ hash_including(event: 'conversation_resolved', account: account.webhook_data),
+ :agent_bot_webhook,
+ hash_including(secret: agent_bot.secret)
+ ).once
+ listener.conversation_resolved(event)
+ end
+ end
+ end
+
describe '#webwidget_triggered' do
let(:event_name) { 'webwidget.triggered' }
diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb
index a7a64f175..b63f43c2f 100644
--- a/spec/listeners/webhook_listener_spec.rb
+++ b/spec/listeners/webhook_listener_spec.rb
@@ -101,6 +101,17 @@ describe WebhookListener do
).once
listener.conversation_created(conversation_created_event)
end
+
+ it 'includes account details in the conversation payload' do
+ webhook = create(:webhook, inbox: inbox, account: account)
+ expect(WebhookJob).to receive(:perform_later).with(
+ webhook.url,
+ hash_including(account: account.webhook_data),
+ :account_webhook,
+ hash_including(secret: webhook.secret)
+ ).once
+ listener.conversation_created(conversation_created_event)
+ end
end
context 'when inbox is an API Channel' do
@@ -250,7 +261,7 @@ describe WebhookListener do
context 'when webhook is configured' do
it 'triggers webhook' do
- inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
@@ -258,6 +269,17 @@ describe WebhookListener do
).once
listener.inbox_created(inbox_created_event)
end
+
+ it 'includes account details in the inbox payload' do
+ webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
+ expect(WebhookJob).to receive(:perform_later).with(
+ webhook.url,
+ hash_including(account: account.webhook_data),
+ :account_webhook,
+ hash_including(secret: webhook.secret)
+ ).once
+ listener.inbox_created(inbox_created_event)
+ end
end
end
@@ -287,7 +309,7 @@ describe WebhookListener do
it 'triggers webhook' do
webhook = create(:webhook, account: account, subscriptions: ['inbox_updated'])
- inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
changed_attributes_data = [{ 'name' => { 'previous_value': 'Inbox 1', 'current_value': inbox.name } }]
expect(WebhookJob).to receive(:perform_later).with(
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index 309a38a65..9a6797aaf 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -99,6 +99,33 @@ RSpec.describe Imap::ImapMailbox do
end
end
+ context 'when a new email contains null bytes' do
+ let(:inbound_mail) do
+ Mail.new.tap do |mail|
+ mail.from = 'email@gmail.com'
+ mail.to = 'imap@gmail.com'
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { class_instance.process(inbound_mail, channel) }.to change(Conversation, :count).by(1)
+
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'when a new email with invalid from' do
let(:inbound_mail) { create_inbound_email_from_mail(from: 'invalidemail', to: 'imap@gmail.com', subject: 'Hello!') }
diff --git a/spec/mailboxes/mailbox_helper_spec.rb b/spec/mailboxes/mailbox_helper_spec.rb
index 613040cb3..4b02205ec 100644
--- a/spec/mailboxes/mailbox_helper_spec.rb
+++ b/spec/mailboxes/mailbox_helper_spec.rb
@@ -47,6 +47,31 @@ RSpec.describe MailboxHelper do
helper_instance.send(:create_message)
end
end
+
+ context 'when message data contains null bytes' do
+ let(:mail) do
+ mail = Mail.new
+ mail.from = 'Sender '
+ mail.to = 'Inbox '
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ mail
+ end
+
+ it 'creates the message with sanitized values' do
+ helper_instance = mailbox_helper_obj.new(conversation, processed_mail)
+
+ expect { helper_instance.send(:create_message) }.to change(conversation.messages, :count).by(1)
+
+ message = conversation.messages.last
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.dig('email', 'message_id')).to eq('message@example.com')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
end
describe '#embed_plain_text_email_with_inline_image' do
diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb
index d062c7d73..20ce60dad 100644
--- a/spec/mailboxes/reply_mailbox_spec.rb
+++ b/spec/mailboxes/reply_mailbox_spec.rb
@@ -67,6 +67,40 @@ RSpec.describe ReplyMailbox do
end
end
+ context 'when new conversation email contains null bytes' do
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
+ let(:mail_with_null_bytes) do
+ Mail.new.tap do |mail|
+ mail.from = 'sender@example.com'
+ mail.to = email_channel.email
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ before do
+ allow(null_byte_mail).to receive(:mail).and_return(mail_with_null_bytes)
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { described_class.receive null_byte_mail }.to change(Conversation, :count).by(1)
+
+ conversation = Conversation.last
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'with inline attachments' do
let(:mail_with_inline_images) { create_inbound_email_from_fixture('mail_with_inline_images.eml') }
let(:described_subject) { described_class.receive mail_with_inline_images }
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 95bc83932..cd4fa3a21 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -248,4 +248,22 @@ RSpec.describe Channel::Whatsapp do
expect(channel.voice_enabled?).to be false
end
end
+
+ describe '#inbound_calls_enabled?' do
+ let(:account) { create(:account) }
+
+ it 'returns true by default when nothing has been toggled' do
+ channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
+ validate_provider_config: false, sync_templates: false)
+ expect(channel.inbound_calls_enabled?).to be true
+ end
+
+ it 'returns false only when explicitly disabled in provider_config' do
+ channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
+ validate_provider_config: false, sync_templates: false)
+ channel.update!(provider_config: channel.provider_config.merge('inbound_calls_enabled' => false))
+
+ expect(channel.inbound_calls_enabled?).to be false
+ end
+ end
end
diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb
index 38d9a7da6..c71a458fd 100644
--- a/spec/models/portal_spec.rb
+++ b/spec/models/portal_spec.rb
@@ -60,6 +60,94 @@ RSpec.describe Portal do
portal.update(custom_domain: '')
expect(portal.custom_domain).to be_nil
end
+
+ context 'with locale_translations' do
+ it 'allows valid locale translations' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } })
+
+ expect(portal).to be_valid
+ end
+
+ it 'rejects unknown fields within a locale translation' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'tagline' => 'nope' } } })
+
+ expect(portal).not_to be_valid
+ end
+
+ it 'retains a locale override after it becomes the default so it can still be edited' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro' } } })
+
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } })
+ end
+ end
+ end
+ end
+
+ describe '#localized_value' do
+ let!(:account) { create(:account) }
+ let!(:portal) do
+ create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+ end
+
+ it 'returns the override for the requested locale' do
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it 'falls back to the base column when the locale has no override for the field' do
+ expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme')
+ end
+
+ it 'falls back to the base column when the locale has no overrides at all' do
+ expect(portal.localized_value('name', 'fr')).to eq('Help Center')
+ end
+
+ it 'keeps serving the override for a locale that has become the default' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it "inherits the default locale's override for a locale without its own" do
+ portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the default locale when no locale is given' do
+ expect(portal.localized_value('name')).to eq('Help Center')
+ end
+ end
+
+ describe '#display_title' do
+ let!(:account) { create(:account) }
+
+ it 'prefers the localized page_title' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } })
+
+ expect(portal.display_title('es')).to eq('Centro | Acme')
+ end
+
+ it 'falls back to the localized name when no page_title is set' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+
+ expect(portal.display_title('es')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the base values for the default locale' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme')
+
+ expect(portal.display_title).to eq('Help Center | Acme')
end
end
end
diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb
index 76fd8f8a8..21cb26c98 100644
--- a/spec/presenters/conversations/event_data_presenter_spec.rb
+++ b/spec/presenters/conversations/event_data_presenter_spec.rb
@@ -46,6 +46,10 @@ RSpec.describe Conversations::EventDataPresenter do
end
describe '#webhook_data' do
+ it 'includes account details for webhook consumers' do
+ expect(presenter.webhook_data[:account]).to eq(conversation.account.webhook_data)
+ end
+
it 'normalizes hard-break backslashes in message content' do
message = create(:message, conversation: conversation, account: conversation.account,
message_type: :outgoing, content: "Hello\\\nWorld")
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index 1bf5c219d..fa054b330 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -72,8 +72,8 @@ describe Conversations::FilterService do
it 'filter conversations by additional_attributes and status' do
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by priority' do
@@ -133,12 +133,84 @@ describe Conversations::FilterService do
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
end
+ it 'filters conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ matching_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(matching_conversation.id)
+ end
+
+ it 'filters conversations using not_equal_to contact operator' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ other_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'not_equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(other_conversation.id)
+ end
+
+ it 'applies inbox permissions when filtering conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ restricted_inbox = create(:inbox, account: account)
+ accessible_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: restricted_inbox, contact: contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(accessible_conversation.id)
+ end
+
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filters items with contains filter_operator with values being an array' do
@@ -184,10 +256,10 @@ describe Conversations::FilterService do
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where(
+ conversations = account.conversations.where(
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
)
- expect(result[:count][:all_count]).to be conversations.count
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by tags' do
@@ -413,8 +485,8 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
- expect(result[:conversations].length).to be expected_count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expect(result[:conversations].length).to eq expected_count
end
it 'binds created_at comparison values as dates' do
@@ -470,10 +542,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
- 'platinum').count
+ expected_count = account.conversations.where("created_at > ? AND custom_attributes->>'conversation_type' = ?",
+ DateTime.parse('2022-01-20'), 'platinum').count
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
context 'with x_days_before filter' do
@@ -502,11 +574,11 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
- 'platinum').count
+ expected_count = account.conversations.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?",
+ (Time.zone.today - 3.days), 'platinum').count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
it 'filter by last_activity_at 2_days_before' do
@@ -520,10 +592,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
+ expected_count = account.conversations.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
@@ -548,10 +620,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
expect(Current.account).to be_nil
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
diff --git a/spec/services/conversations/unread_counts/counter_spec.rb b/spec/services/conversations/unread_counts/counter_spec.rb
index bfd10436e..723f2d437 100644
--- a/spec/services/conversations/unread_counts/counter_spec.rb
+++ b/spec/services/conversations/unread_counts/counter_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
+ all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: { label.id.to_s => 1 },
teams: { visible_team.id.to_s => 1 }
@@ -75,6 +76,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: admin).perform
expect(result).to eq(
+ all_count: 2,
inboxes: { visible_inbox.id.to_s => 1, hidden_inbox.id.to_s => 1 },
labels: { label.id.to_s => 2 },
teams: { visible_team.id.to_s => 2 }
@@ -87,6 +89,7 @@ RSpec.describe Conversations::UnreadCounts::Counter do
result = described_class.new(account: account, user: agent).perform
expect(result).to eq(
+ all_count: 1,
inboxes: { visible_inbox.id.to_s => 1 },
labels: {},
teams: { visible_team.id.to_s => 1 }
diff --git a/spec/services/facebook/send_on_facebook_service_spec.rb b/spec/services/facebook/send_on_facebook_service_spec.rb
index f99b1c469..0a2b6407c 100644
--- a/spec/services/facebook/send_on_facebook_service_spec.rb
+++ b/spec/services/facebook/send_on_facebook_service_spec.rb
@@ -73,8 +73,7 @@ describe Facebook::SendOnFacebookService do
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
message: { text: message.content },
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
@@ -86,17 +85,26 @@ describe Facebook::SendOnFacebookService do
}
}
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
+ it 'sends as a standard RESPONSE without a tag by default' do
+ message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
+ described_class.new(message: message).perform
+ expect(bot).to have_received(:deliver).with(
+ hash_including(messaging_type: 'RESPONSE'),
+ { page_id: facebook_channel.page_id }
+ )
+ expect(bot).not_to have_received(:deliver).with(hash_including(:tag), anything)
+ end
+
it 'sends with HUMAN_AGENT tag when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled' do
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
described_class.new(message: message).perform
expect(bot).to have_received(:deliver).with(
- hash_including(tag: 'HUMAN_AGENT'),
+ hash_including(messaging_type: 'MESSAGE_TAG', tag: 'HUMAN_AGENT'),
{ page_id: facebook_channel.page_id }
)
end
@@ -201,8 +209,7 @@ describe Facebook::SendOnFacebookService do
{ content_type: 'text', payload: 'text 2', title: 'text 2' }
]
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
end
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
index a20e36ac8..560b1993e 100644
--- a/spec/services/whatsapp/embedded_signup_service_spec.rb
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -36,11 +36,6 @@ describe Whatsapp::EmbeddedSignupService do
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
allow(phone_service).to receive(:perform).and_return(phone_info)
- validation_service = instance_double(Whatsapp::TokenValidationService)
- allow(Whatsapp::TokenValidationService).to receive(:new)
- .with(access_token, params[:waba_id]).and_return(validation_service)
- allow(validation_service).to receive(:perform)
-
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
diff --git a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
index 70c29c092..1bbd6d8f4 100644
--- a/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_whatsapp_cloud_service_spec.rb
@@ -59,6 +59,41 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
+ context 'when document attachment includes an accented filename' do
+ let(:document_params) do
+ {
+ phone_number: whatsapp_channel.phone_number,
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ contacts: [{ profile: { name: 'Sojan Jose' }, wa_id: '2423423243' }],
+ messages: [{
+ from: '2423423243',
+ document: {
+ id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
+ mime_type: 'application/pdf',
+ filename: 'Currículum café.pdf',
+ caption: 'My résumé'
+ },
+ timestamp: '1664799904', type: 'document'
+ }]
+ }
+ }]
+ }]
+ }.with_indifferent_access
+ end
+
+ it 'preserves the original filename from the payload' do
+ stub_media_url_request
+ stub_sample_png_request
+ described_class.new(inbox: whatsapp_channel.inbox, params: document_params).perform
+
+ attachment = whatsapp_channel.inbox.messages.first.attachments.first
+ expect(attachment.file.filename.to_s).to eq('Currículum café.pdf')
+ end
+ end
+
context 'when invalid attachment message params' do
let(:error_params) do
{
diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb
deleted file mode 100644
index d9cf40257..000000000
--- a/spec/services/whatsapp/token_validation_service_spec.rb
+++ /dev/null
@@ -1,99 +0,0 @@
-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