diff --git a/app/javascript/v3/views/login/Index.vue b/app/javascript/v3/views/login/Index.vue
index 5faaa3e02..04fb939c8 100644
--- a/app/javascript/v3/views/login/Index.vue
+++ b/app/javascript/v3/views/login/Index.vue
@@ -15,12 +15,15 @@ import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue';
+import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
const ERROR_MESSAGES = {
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
+ 'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
+ 'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
};
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
@@ -33,6 +36,7 @@ export default {
NextButton,
SimpleDivider,
MfaVerification,
+ Icon,
},
props: {
ssoAuthToken: { type: String, default: '' },
@@ -260,9 +264,12 @@ export default {
-
+
{{ $t('LOGIN.SAML.LABEL') }}
diff --git a/app/javascript/v3/views/login/Saml.vue b/app/javascript/v3/views/login/Saml.vue
index bbb61914d..95749d7e2 100644
--- a/app/javascript/v3/views/login/Saml.vue
+++ b/app/javascript/v3/views/login/Saml.vue
@@ -15,6 +15,10 @@ const props = defineProps({
type: String,
default: '',
},
+ target: {
+ type: String,
+ default: 'web',
+ },
});
const store = useStore();
@@ -107,6 +111,7 @@ onMounted(async () => {
name="authenticity_token"
:value="csrfToken"
/>
+
({
authError: route.query.error,
+ target: route.query.target,
}),
},
{
diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb
index 54e58b4d9..c25aba47f 100644
--- a/app/models/concerns/activity_message_handler.rb
+++ b/app/models/concerns/activity_message_handler.rb
@@ -106,7 +106,7 @@ module ActivityMessageHandler
end
def generate_assignee_change_activity_content(user_name)
- params = { assignee_name: assignee&.name, user_name: user_name }.compact
+ params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
diff --git a/app/models/contact.rb b/app/models/contact.rb
index a3570b2af..0dc92b51e 100644
--- a/app/models/contact.rb
+++ b/app/models/contact.rb
@@ -21,6 +21,7 @@
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
+# company_id :bigint
#
# Indexes
#
@@ -28,6 +29,7 @@
# index_contacts_on_account_id_and_contact_type (account_id,contact_type)
# index_contacts_on_account_id_and_last_activity_at (account_id,last_activity_at DESC NULLS LAST)
# index_contacts_on_blocked (blocked)
+# index_contacts_on_company_id (company_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_nonempty_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))
@@ -244,3 +246,4 @@ class Contact < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end
+Contact.include_mod_with('Concerns::Contact')
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index b3d58fd17..6d64c0447 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -17,7 +17,6 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
- MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
@@ -25,7 +24,7 @@ class CustomFilter < ApplicationRecord
validate :validate_number_of_filters
def validate_number_of_filters
- return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
+ return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
diff --git a/app/models/message.rb b/app/models/message.rb
index dbab19df3..2079e31a9 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -39,7 +39,7 @@
#
class Message < ApplicationRecord
- searchkick callbacks: :async if ChatwootApp.advanced_search_allowed?
+ searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
include MessageFilterHelpers
include Liquidable
@@ -135,6 +135,7 @@ class Message < ApplicationRecord
after_create_commit :execute_after_create_commit_callbacks
after_update_commit :dispatch_update_event
+ after_commit :reindex_for_search, if: :should_index?, on: [:create, :update]
def channel_token
@token ||= inbox.channel.try(:page_access_token)
@@ -436,6 +437,10 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
+
+ def reindex_for_search
+ reindex(mode: :async)
+ end
end
Message.prepend_mod_with('Message')
diff --git a/app/models/super_admin.rb b/app/models/super_admin.rb
index 316d60c7b..9bcee9b8a 100644
--- a/app/models/super_admin.rb
+++ b/app/models/super_admin.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE), not null
+# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/models/user.rb b/app/models/user.rb
index 4923d0a35..cc25357f6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
-# otp_required_for_login :boolean default(FALSE), not null
+# otp_required_for_login :boolean default(FALSE)
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index 8a6f81484..cace85e5e 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -61,4 +61,8 @@ class InboxPolicy < ApplicationPolicy
def sync_templates?
@account_user.administrator?
end
+
+ def health?
+ @account_user.administrator?
+ end
end
diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb
index 1b882b1f1..4379d0b74 100644
--- a/app/services/whatsapp/embedded_signup_service.rb
+++ b/app/services/whatsapp/embedded_signup_service.rb
@@ -17,6 +17,7 @@ class Whatsapp::EmbeddedSignupService
channel = create_or_reauthorize_channel(access_token, phone_info)
channel.setup_webhooks
+ check_channel_health_and_prompt_reauth(channel)
channel
rescue StandardError => e
@@ -52,6 +53,24 @@ class Whatsapp::EmbeddedSignupService
end
end
+ def check_channel_health_and_prompt_reauth(channel)
+ health_data = Whatsapp::HealthService.new(channel).fetch_health_status
+ return unless health_data
+
+ if channel_in_pending_state?(health_data)
+ channel.prompt_reauthorization!
+ else
+ Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed"
+ end
+ rescue StandardError => e
+ Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}"
+ end
+
+ def channel_in_pending_state?(health_data)
+ health_data[:platform_type] == 'NOT_APPLICABLE' ||
+ health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
+ end
+
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
diff --git a/app/services/whatsapp/health_service.rb b/app/services/whatsapp/health_service.rb
new file mode 100644
index 000000000..94789ef79
--- /dev/null
+++ b/app/services/whatsapp/health_service.rb
@@ -0,0 +1,84 @@
+class Whatsapp::HealthService
+ BASE_URI = 'https://graph.facebook.com'.freeze
+
+ def initialize(channel)
+ @channel = channel
+ @access_token = channel.provider_config['api_key']
+ @api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
+ end
+
+ def fetch_health_status
+ validate_channel!
+ fetch_phone_health_data
+ end
+
+ private
+
+ def validate_channel!
+ raise ArgumentError, 'Channel is required' if @channel.blank?
+ raise ArgumentError, 'API key is missing' if @access_token.blank?
+ raise ArgumentError, 'Phone number ID is missing' if @channel.provider_config['phone_number_id'].blank?
+ end
+
+ def fetch_phone_health_data
+ phone_number_id = @channel.provider_config['phone_number_id']
+
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{phone_number_id}",
+ query: {
+ fields: health_fields,
+ access_token: @access_token
+ }
+ )
+
+ handle_response(response)
+ rescue StandardError => e
+ Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}"
+ raise e
+ end
+
+ def health_fields
+ %w[
+ quality_rating
+ messaging_limit_tier
+ code_verification_status
+ account_mode
+ id
+ display_phone_number
+ name_status
+ verified_name
+ webhook_configuration
+ throughput
+ last_onboarded_time
+ platform_type
+ certificate
+ ].join(',')
+ end
+
+ def handle_response(response)
+ unless response.success?
+ error_message = "WhatsApp API request failed: #{response.code} - #{response.body}"
+ Rails.logger.error "[WHATSAPP HEALTH] #{error_message}"
+ raise error_message
+ end
+
+ data = response.parsed_response
+ format_health_response(data)
+ end
+
+ def format_health_response(response)
+ {
+ display_phone_number: response['display_phone_number'],
+ verified_name: response['verified_name'],
+ name_status: response['name_status'],
+ quality_rating: response['quality_rating'],
+ messaging_limit_tier: response['messaging_limit_tier'],
+ account_mode: response['account_mode'],
+ code_verification_status: response['code_verification_status'],
+ throughput: response['throughput'],
+ last_onboarded_time: response['last_onboarded_time'],
+ platform_type: response['platform_type'],
+ business_id: @channel.provider_config['business_account_id']
+ }
+ end
+end
diff --git a/app/services/whatsapp/incoming_message_service_helpers.rb b/app/services/whatsapp/incoming_message_service_helpers.rb
index e40dc408f..705babbba 100644
--- a/app/services/whatsapp/incoming_message_service_helpers.rb
+++ b/app/services/whatsapp/incoming_message_service_helpers.rb
@@ -47,6 +47,15 @@ module Whatsapp::IncomingMessageServiceHelpers
%w[reaction ephemeral unsupported request_welcome].include?(message_type)
end
+ def argentina_phone_number?(phone_number)
+ phone_number.match(/^54/)
+ end
+
+ def normalised_argentina_mobil_number(phone_number)
+ # Remove 9 before country code
+ phone_number.sub(/^549/, '54')
+ end
+
def processed_waid(waid)
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
end
diff --git a/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb
new file mode 100644
index 000000000..109a0683f
--- /dev/null
+++ b/app/services/whatsapp/phone_normalizers/argentina_phone_normalizer.rb
@@ -0,0 +1,18 @@
+# Handles Argentina phone number normalization
+#
+# Argentina phone numbers can appear with or without "9" after country code
+# This normalizer removes the "9" when present to create consistent format: 54 + area + number
+class Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer < Whatsapp::PhoneNormalizers::BasePhoneNormalizer
+ def normalize(waid)
+ return waid unless handles_country?(waid)
+
+ # Remove "9" after country code if present (549 → 54)
+ waid.sub(/^549/, '54')
+ end
+
+ private
+
+ def country_code_pattern
+ /^54/
+ end
+end
diff --git a/app/services/whatsapp/phone_number_normalization_service.rb b/app/services/whatsapp/phone_number_normalization_service.rb
index b8e416794..cd10db0d0 100644
--- a/app/services/whatsapp/phone_number_normalization_service.rb
+++ b/app/services/whatsapp/phone_number_normalization_service.rb
@@ -1,5 +1,5 @@
# Service to handle phone number normalization for WhatsApp messages
-# Currently supports Brazil phone number format variations
+# Currently supports Brazil and Argentina phone number format variations
# Designed to be extensible for additional countries in future PRs
#
# Usage: Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact(waid)
@@ -34,6 +34,7 @@ class Whatsapp::PhoneNumberNormalizationService
end
NORMALIZERS = [
- Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer
+ Whatsapp::PhoneNormalizers::BrazilPhoneNormalizer,
+ Whatsapp::PhoneNormalizers::ArgentinaPhoneNormalizer
].freeze
end
diff --git a/app/services/whatsapp/populate_template_parameters_service.rb b/app/services/whatsapp/populate_template_parameters_service.rb
index 3f9f64b91..6ea3e6e05 100644
--- a/app/services/whatsapp/populate_template_parameters_service.rb
+++ b/app/services/whatsapp/populate_template_parameters_service.rb
@@ -34,8 +34,9 @@ class Whatsapp::PopulateTemplateParametersService
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
- validate_url(sanitized_url)
- build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
+ normalized_url = normalize_url(sanitized_url)
+ validate_url(normalized_url)
+ build_media_type_parameter(normalized_url, media_type.downcase, media_name)
end
def build_named_parameter(parameter_name, value)
@@ -138,9 +139,20 @@ class Whatsapp::PopulateTemplateParametersService
sanitized[0...1000] # Limit length to prevent DoS
end
+ def normalize_url(url)
+ # Use Addressable::URI for better URL normalization
+ # It handles spaces, special characters, and encoding automatically
+ Addressable::URI.parse(url).normalize.to_s
+ rescue Addressable::URI::InvalidURIError
+ # Fallback: simple space encoding if Addressable fails
+ url.gsub(' ', '%20')
+ end
+
def validate_url(url)
return if url.blank?
+ # url is already normalized by the caller
+
uri = URI.parse(url)
raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme)
raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000
diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb
index a3faaaa56..63fed1e93 100644
--- a/app/services/whatsapp/webhook_setup_service.rb
+++ b/app/services/whatsapp/webhook_setup_service.rb
@@ -8,8 +8,12 @@ class Whatsapp::WebhookSetupService
def perform
validate_parameters!
- # Since coexistence method does not need to register, we check it
- register_phone_number unless phone_number_verified?
+
+ # Register phone number if either condition is met:
+ # 1. Phone number is not verified (code_verification_status != 'VERIFIED')
+ # 2. Phone number needs registration (pending provisioning state)
+ register_phone_number if !phone_number_verified? || phone_number_needs_registration?
+
setup_webhook
end
@@ -69,9 +73,44 @@ class Whatsapp::WebhookSetupService
def phone_number_verified?
phone_number_id = @channel.provider_config['phone_number_id']
- @api_client.phone_number_verified?(phone_number_id)
+ # Check with WhatsApp API if the phone number code verification is complete
+ # This checks code_verification_status == 'VERIFIED'
+ verified = @api_client.phone_number_verified?(phone_number_id)
+ Rails.logger.info("[WHATSAPP] Phone number #{phone_number_id} code verification status: #{verified}")
+
+ verified
rescue StandardError => e
- Rails.logger.error("[WHATSAPP] Phone registration status check failed, but continuing: #{e.message}")
+ # If verification check fails, assume not verified to be safe
+ Rails.logger.error("[WHATSAPP] Phone verification status check failed: #{e.message}")
+ false
+ end
+
+ def phone_number_needs_registration?
+ # Check if phone is in pending provisioning state based on health data
+ # This is a separate check from phone_number_verified? which only checks code verification
+
+ phone_number_in_pending_state?
+
+ rescue StandardError => e
+ Rails.logger.error("[WHATSAPP] Phone registration check failed: #{e.message}")
+ # Conservative approach: don't register if we can't determine the state
+ false
+ end
+
+ def phone_number_in_pending_state?
+ health_service = Whatsapp::HealthService.new(@channel)
+ health_data = health_service.fetch_health_status
+
+ # Check if phone number is in "not provisioned" state based on health indicators
+ # These conditions indicate the number is pending and needs registration:
+ # - platform_type: "NOT_APPLICABLE" means not fully set up
+ # - throughput.level: "NOT_APPLICABLE" means no messaging capacity assigned
+ health_data[:platform_type] == 'NOT_APPLICABLE' ||
+ health_data.dig(:throughput, :level) == 'NOT_APPLICABLE'
+
+ rescue StandardError => e
+ Rails.logger.error("[WHATSAPP] Health status check failed: #{e.message}")
+ # If health check fails, assume registration is not needed to avoid errors
false
end
end
diff --git a/config/locales/am.yml b/config/locales/am.yml
index c91f19922..28b6f375a 100644
--- a/config/locales/am.yml
+++ b/config/locales/am.yml
@@ -86,7 +86,7 @@ am:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ am:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 777216dd5..369e73507 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -86,7 +86,7 @@ ar:
validations:
name: لا ينبغي أن تبدأ أو تنتهي بالرموز، ولا ينبغي أن يكون أقل من > / \ أحرف @ .
custom_filters:
- number_of_records: تم الوصول إلى الحد الأقصى. الحد الأقصى لعدد عوامل التصفية المخصصة المسموح به للمستخدم لكل حساب هو 50.
+ number_of_records: تم الوصول إلى الحد الأقصى. الحد الأقصى لعدد عوامل التصفية المخصصة المسموح به للمستخدم لكل حساب هو 1000.
invalid_attribute: مفتاح السمة غير صالح - [%{key}]. يجب أن يكون المفتاح واحد من [%{allowed_keys}] أو سمة مخصصة محددة في الحساب.
invalid_operator: مشغل غير صالح. المشغل المسموح به لـ %{attribute_name} هو [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ar:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: البحث عن مقالة حسب العنوان أو الجسم...
diff --git a/config/locales/az.yml b/config/locales/az.yml
index 582e5d233..3b04a2a82 100644
--- a/config/locales/az.yml
+++ b/config/locales/az.yml
@@ -86,7 +86,7 @@ az:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ az:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index be1ebaf87..0b65acf7e 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -86,7 +86,7 @@ bg:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ bg:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index e678aa96d..62537fbb2 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -86,7 +86,7 @@ ca:
validations:
name: no hauria de començar ni acabar amb símbols, i no hauria de tenir caràcters < > / \ @.
custom_filters:
- number_of_records: S'ha arribat al límit. El nombre màxim de filtres personalitzats permesos per a un usuari per compte és de 50.
+ number_of_records: S'ha arribat al límit. El nombre màxim de filtres personalitzats permesos per a un usuari per compte és de 1000.
invalid_attribute: 'Clau d''atribut no vàlida: [%{key}]. La clau hauria de ser una de [%{allowed_keys}] o un atribut personalitzat definit al compte.'
invalid_operator: Operador no vàlid. Els operadors permesos per a %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ca:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Cerca l'article per títol o cos...
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 270a955c6..39d56b284 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -86,7 +86,7 @@ cs:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ cs:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/da.yml b/config/locales/da.yml
index d449604c7..b15244766 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -86,7 +86,7 @@ da:
validations:
name: bør ikke starte eller slutte med symboler, og det skal ikke have < > / \ @ tegn.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ da:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/de.yml b/config/locales/de.yml
index d9d6fb23d..44863a78f 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -86,7 +86,7 @@ de:
validations:
name: Sollte nicht mit Symbolen beginnen oder enden, und es sollte keine < > / \ @ Zeichen enthalten.
custom_filters:
- number_of_records: Limit erreicht. Die maximale Anzahl an benutzerdefinierten Filtern pro Benutzerkonto beträgt 50.
+ number_of_records: Limit erreicht. Die maximale Anzahl an benutzerdefinierten Filtern pro Benutzerkonto beträgt 1000.
invalid_attribute: Ungültiger Attribut schlüssel - [%{key}]. Der Schlüssel sollte einer von [%{allowed_keys}] oder ein benutzerdefiniertes Attribut sein, das im Konto definiert ist.
invalid_operator: Ungültiger Operator. Die erlaubten Operatoren für %{attribute_name} sind [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ de:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Artikel nach Titel oder Text suchen...
diff --git a/config/locales/el.yml b/config/locales/el.yml
index 034462107..c9578bbbf 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -86,7 +86,7 @@ el:
validations:
name: δεν πρέπει να ξεκινά ή να τελειώνει με σύμβολα, και δεν πρέπει να περιέχει τους χαρακτήρες < > / \ @
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ el:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Αναζήτηση άρθρου με τίτλο ή περιεχόμενο...
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ca3a9e950..6afab9253 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -76,6 +76,9 @@ en:
invalid: Invalid email
phone_number:
invalid: should be in e164 format
+ companies:
+ domain:
+ invalid: must be a valid domain name
categories:
locale:
unique: should be unique in the category and portal
@@ -100,7 +103,7 @@ en:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -336,6 +339,8 @@ en:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 6f81f1d67..6e679273d 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -86,7 +86,7 @@ es:
validations:
name: no debe comenzar ni terminar con símbolos, y no debe tener caracteres < > / \ @.
custom_filters:
- number_of_records: Límite alcanzado. El número máximo de filtros personalizados permitidos para un usuario por cuenta es de 50.
+ number_of_records: Límite alcanzado. El número máximo de filtros personalizados permitidos para un usuario por cuenta es de 1000.
invalid_attribute: Clave de atributo no válida - [%{key}]. La clave debe ser una de [%{allowed_keys}] o un atributo personalizado definido en la cuenta.
invalid_operator: Operador no válido. Los operadores permitidos para %{attribute_name} son [%{allowed_keys}].
invalid_query_operator: El operador de consulta debe ser "Y" o "O".
@@ -322,6 +322,8 @@ es:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Buscar artículo por título o cuerpo...
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 232396757..bbe2fc8cf 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -86,7 +86,7 @@ fa:
validations:
name: نباید با نمادها شروع یا ختم شود و نباید دارای کاراکترهای < > / \ @ باشد.
custom_filters:
- number_of_records: سررسید محدودیت. حداکثر تعداد قابل قبول فیلترها برای یک کاربر در هر اکانت 50 می باشند.
+ number_of_records: سررسید محدودیت. حداکثر تعداد قابل قبول فیلترها برای یک کاربر در هر اکانت 1000 می باشند.
invalid_attribute: کلید ویژگی معتبر نیست (%{key}). کلید باید یکی از %{allowed_keys} باشد یا یک ویژگی سفارشی ایجاد شده در حساب.
invalid_operator: این عملیات مجاز نیست. عملیات های مجاز برای %{attribute_name} شامل %{allowed_keys} می باشد.
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ fa:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: جستجوی مقاله براساس عنوان یا متن...
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 6e7db9ad7..09d6451d3 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -86,7 +86,7 @@ fi:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ fi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index edab001cb..284a87ac0 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -86,7 +86,7 @@ fr:
validations:
name: 'ne doit pas commencer ou se terminer par des symboles, et ne doit pas comporter les caractères suivants : "< > / \ @".'
custom_filters:
- number_of_records: Limite atteinte. Le nombre maximum de filtres personnalisés autorisés pour un utilisateur par compte est de 50.
+ number_of_records: Limite atteinte. Le nombre maximum de filtres personnalisés autorisés pour un utilisateur par compte est de 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ fr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Rechercher un article par titre ou contenu...
diff --git a/config/locales/he.yml b/config/locales/he.yml
index a2f309a3a..f006ed806 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -86,7 +86,7 @@ he:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ he:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hi.yml b/config/locales/hi.yml
index 82f962f92..98bbda2ec 100644
--- a/config/locales/hi.yml
+++ b/config/locales/hi.yml
@@ -86,7 +86,7 @@ hi:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ hi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hr.yml b/config/locales/hr.yml
index 3828ebd40..2eb2b7756 100644
--- a/config/locales/hr.yml
+++ b/config/locales/hr.yml
@@ -86,7 +86,7 @@ hr:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ hr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 8ded57a47..901148f83 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -86,7 +86,7 @@ hu:
validations:
name: nem kezdődhet vagy végződhet szimbólummal, és nem tartalmazhat < > / \ @ karaktereket.
custom_filters:
- number_of_records: Limit túllépve. Maximum 50 speciális szűrőt használhat egy fiók.
+ number_of_records: Limit túllépve. Maximum 1000 speciális szűrőt használhat egy fiók.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ hu:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Keress a bejegyzések címében és tartalmában...
diff --git a/config/locales/hy.yml b/config/locales/hy.yml
index 3119df585..b1a0ca023 100644
--- a/config/locales/hy.yml
+++ b/config/locales/hy.yml
@@ -86,7 +86,7 @@ hy:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ hy:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/id.yml b/config/locales/id.yml
index ffe340f74..3b4527420 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -86,7 +86,7 @@ id:
validations:
name: tidak boleh dimulai atau diakhiri dengan simbol, dan tidak boleh memiliki karakter < > / \ @.
custom_filters:
- number_of_records: Batas tercapai. Jumlah maksimum filter ubahsuaian yang diizinkan untuk satu pengguna per akun adalah 50.
+ number_of_records: Batas tercapai. Jumlah maksimum filter ubahsuaian yang diizinkan untuk satu pengguna per akun adalah 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ id:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Telusuri artikel menurut judul atau isi...
diff --git a/config/locales/is.yml b/config/locales/is.yml
index c9b96fd74..24c01f849 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -86,7 +86,7 @@ is:
validations:
name: ætti ekki að byrja eða enda á táknum, og það ætti ekki að hafa < > / \ @ táknin.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ is:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/it.yml b/config/locales/it.yml
index 2d2720acd..806c3e0a9 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -86,7 +86,7 @@ it:
validations:
name: non dovrebbe iniziare o terminare con i simboli, e non dovrebbe avere < > / \ @ caratteri.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ it:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 5e44bda34..336f70b91 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -86,7 +86,7 @@ ja:
validations:
name: 記号で開始または終了しないでください。< > / \ @ を使用しないでください。
custom_filters:
- number_of_records: 制限に達しました。1つのアカウントにつき、ユーザーごとに許可されるカスタムフィルターの最大数は 50 です。
+ number_of_records: 制限に達しました。1つのアカウントにつき、ユーザーごとに許可されるカスタムフィルターの最大数は 1000 です。
invalid_attribute: 無効な属性キー - [%{key}]。キーは[%{allowed_keys}]のいずれかである必要があります。または、アカウント内で定義されたカスタム属性でなければなりません。
invalid_operator: 無効な演算子です。%{attribute_name} に許可されている演算子は [%{allowed_keys}] です。
invalid_query_operator: クエリ演算子は "AND" または "OR" でなければなりません。
@@ -322,6 +322,8 @@ ja:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: タイトルまたは本文で記事を検索...
diff --git a/config/locales/ka.yml b/config/locales/ka.yml
index cb3d5637b..c8f9d200d 100644
--- a/config/locales/ka.yml
+++ b/config/locales/ka.yml
@@ -86,7 +86,7 @@ ka:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ka:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 2a655fcce..042e2fae4 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -86,7 +86,7 @@ ko:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ko:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: 게시물을 제목이나 내용으로 검색하세요...
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index 51018d3aa..63b508066 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -86,7 +86,7 @@ lt:
validations:
name: neturėtų prasidėti ar baigtis simboliais ir jame neturėtų būti simbolių < > / \ @.
custom_filters:
- number_of_records: Pasiekta riba. Didžiausias leistinas personalizuotų filtrų skaičius vienam vartotojui yra 50.
+ number_of_records: Pasiekta riba. Didžiausias leistinas personalizuotų filtrų skaičius vienam vartotojui yra 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ lt:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Ieškokite straipsnio pagal pavadinimą arba turinį...
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 639194239..12b1d85a7 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -86,7 +86,7 @@ lv:
validations:
name: nevajadzētu sākties vai beigties ar simboliem, un nevajadzētu saturēt <> / \ @ rakstzīmes.
custom_filters:
- number_of_records: Sasniegts limits. Maksimālais atļauto pielāgoto filtru skaits vienam lietotājam ir 50.
+ number_of_records: Sasniegts limits. Maksimālais atļauto pielāgoto filtru skaits vienam lietotājam ir 1000.
invalid_attribute: Nederīga atribūta atslēga - [%{key}]. Atslēgai ir jābūt vienai no [%{allowed_keys}] vai pielāgotam atribūtam, kas definēts kontā.
invalid_operator: Nederīgs operators. Atļautie operatori priekš %{attribute_name} ir [%{allowed_keys}].
invalid_query_operator: Vaicājuma operatoram ir jābūt "UN" vai "VAI".
@@ -322,6 +322,8 @@ lv:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Meklēt rakstu pēc nosaukuma vai pamatteksta...
diff --git a/config/locales/ml.yml b/config/locales/ml.yml
index 2f411ea56..80bd12a50 100644
--- a/config/locales/ml.yml
+++ b/config/locales/ml.yml
@@ -86,7 +86,7 @@ ml:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ml:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ms.yml b/config/locales/ms.yml
index 7a1902b8e..5d6963421 100644
--- a/config/locales/ms.yml
+++ b/config/locales/ms.yml
@@ -86,7 +86,7 @@ ms:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ms:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ne.yml b/config/locales/ne.yml
index 2690a7d47..b4477805a 100644
--- a/config/locales/ne.yml
+++ b/config/locales/ne.yml
@@ -86,7 +86,7 @@ ne:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ne:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 641516129..1d361f807 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -86,7 +86,7 @@ nl:
validations:
name: mag niet beginnen of eindigen met symbolen, en mag geen < > / \ @ karakters hebben.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ nl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/no.yml b/config/locales/no.yml
index c1b736fb3..ebb23976d 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -86,7 +86,7 @@
validations:
name: ikke kan starte eller slutte med symboler, og den kan ikke ha < > / \ @ tegn.
custom_filters:
- number_of_records: Grense nådd. Maksimalt antall tillatte filtre for en bruker per konto er 50.
+ number_of_records: Grense nådd. Maksimalt antall tillatte filtre for en bruker per konto er 1000.
invalid_attribute: Ugyldig attributtnøkkel - [%{key}]. Nøkkelen bør være en av [%{allowed_keys}] eller en egendefinert attributt definert på kontoen.
invalid_operator: Ugyldig operatør. De tillatte operatørene for %{attribute_name} er [%{allowed_keys}].
invalid_query_operator: Spørrings-operatør må være enten "AND" eller "OR".
@@ -322,6 +322,8 @@
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index c21d42d0f..9ade3104a 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -86,7 +86,7 @@ pl:
validations:
name: nie powinno zaczynać się ani kończyć symbolami i nie powinno zawierać znaków < > / \ @.
custom_filters:
- number_of_records: Osiągnięto limit. Maksymalna liczba dozwolonych filtrów niestandardowych dla użytkownika na konto wynosi 50.
+ number_of_records: Osiągnięto limit. Maksymalna liczba dozwolonych filtrów niestandardowych dla użytkownika na konto wynosi 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ pl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Wyszukaj artykuł według tytułu lub treści...
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 8b78ee036..cf5b51e07 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -86,7 +86,7 @@ pt:
validations:
name: não deve iniciar ou terminar com símbolos, nem deve ter < > / \ @ caracteres.
custom_filters:
- number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um utilizador por conta é de 50.
+ number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um utilizador por conta é de 1000.
invalid_attribute: Chave de atributo inválida - [%{key}]. A chave deve ser uma das [%{allowed_keys}] ou um atributo personalizado definido na conta.
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ pt:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Pesquisar artigo por título ou corpo...
diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml
index 80c4eb574..01e532aad 100644
--- a/config/locales/pt_BR.yml
+++ b/config/locales/pt_BR.yml
@@ -86,7 +86,7 @@ pt_BR:
validations:
name: 'não deve iniciar ou terminar com símbolos e não deve ter os caracteres: < > / \ @.'
custom_filters:
- number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um usuário por conta é de 50.
+ number_of_records: Limite atingido. O número máximo de filtros personalizados permitidos para um usuário por conta é de 1000.
invalid_attribute: Chave de atributo inválido - [%{key}]. A chave deve ser uma das [%{allowed_keys}] ou um atributo personalizado definido na conta.
invalid_operator: Operador inválido. Os operadores permitidos para %{attribute_name} são [%{allowed_keys}].
invalid_query_operator: Operador de consulta deve ser "E" ou "OU".
@@ -322,6 +322,8 @@ pt_BR:
processing_pages: 'Processando páginas %{start}-%{end} (iteração %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Erro ao processar as páginas %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Pesquisar por artigo por título ou corpo...
diff --git a/config/locales/ro.yml b/config/locales/ro.yml
index bdf8f6237..ae7ca5605 100644
--- a/config/locales/ro.yml
+++ b/config/locales/ro.yml
@@ -86,7 +86,7 @@ ro:
validations:
name: nu ar trebui să înceapă sau să se termine cu simboluri și nu ar trebui să aibă < > / \ @ caractere.
custom_filters:
- number_of_records: Limita atinsă. Numărul maxim de filtre personalizate permise pentru un utilizator per cont este de 50.
+ number_of_records: Limita atinsă. Numărul maxim de filtre personalizate permise pentru un utilizator per cont este de 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ro:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Căutați articol după titlu sau corp...
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index ee104a126..3dc011305 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -86,7 +86,7 @@ ru:
validations:
name: Не должен начинаться или заканчиваться символами, и у него Не должно быть < > / \ @ символов.
custom_filters:
- number_of_records: Достигнут лимит. Максимальное количество разрешенных пользовательских фильтров для каждого пользователя - 50.
+ number_of_records: Достигнут лимит. Максимальное количество разрешенных пользовательских фильтров для каждого пользователя - 1000.
invalid_attribute: Недопустимый ключ атрибута - [%{key}]. Ключ должен быть одним из [%{allowed_keys}] или пользовательским атрибутом, указанным в учетной записи.
invalid_operator: Неверный оператор. Допустимыми операторами для %{attribute_name} являются [%{allowed_keys}].
invalid_query_operator: Оператор запроса должен быть "AND" или "OR".
@@ -322,6 +322,8 @@ ru:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Поиск статьи по названию или содержанию...
diff --git a/config/locales/sh.yml b/config/locales/sh.yml
index e4598bccf..b52dd4af5 100644
--- a/config/locales/sh.yml
+++ b/config/locales/sh.yml
@@ -86,7 +86,7 @@ sh:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sh:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 09067eed7..9c0b5c0a3 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -86,7 +86,7 @@ sk:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sk:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sl.yml b/config/locales/sl.yml
index 1e4c32133..76b21fb83 100644
--- a/config/locales/sl.yml
+++ b/config/locales/sl.yml
@@ -86,7 +86,7 @@ sl:
validations:
name: se ne sme začeti ali končati s simboli in ne sme vsebovati znakov < > / \ @.
custom_filters:
- number_of_records: Omejitev dosežena. Največje dovoljeno število filtrov po meri za uporabnika na račun je 50.
+ number_of_records: Omejitev dosežena. Največje dovoljeno število filtrov po meri za uporabnika na račun je 1000.
invalid_attribute: Neveljaven ključ atributa - [%{key}]. Ključ mora biti eden od [%{allowed_keys}] ali atribut po meri, določen v računu.
invalid_operator: Neveljaven operater. Dovoljeni operaterji za %{attribute_name} so [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Iskanje članka po naslovu ali telesu ...
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 8717b39fa..d87ab44e2 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -86,7 +86,7 @@ sq:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sq:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 99140fa02..a8f9b23a8 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -86,7 +86,7 @@ sr-Latn:
validations:
name: ne treba početi ili se završiti sa simbolima i ne treba da sadrži < > / \ @ karaktere.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sr-Latn:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index fc0170c3a..77a3a1e84 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -86,7 +86,7 @@ sv:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ sv:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Sök efter artikel baserat på rubrik eller brödtext...
diff --git a/config/locales/ta.yml b/config/locales/ta.yml
index 2323ab68e..2af7ba7bb 100644
--- a/config/locales/ta.yml
+++ b/config/locales/ta.yml
@@ -86,7 +86,7 @@ ta:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ta:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/th.yml b/config/locales/th.yml
index 137cfd4fd..287f6d4f2 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -86,7 +86,7 @@ th:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ th:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/tl.yml b/config/locales/tl.yml
index 947ca15f9..fb6de4e8c 100644
--- a/config/locales/tl.yml
+++ b/config/locales/tl.yml
@@ -86,7 +86,7 @@ tl:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ tl:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index ab3fce25d..f046ee82e 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -86,7 +86,7 @@ tr:
validations:
name: sembollerle başlamamalı veya bitmemeli, < > / \ @ karakterlerini içermemeli.
custom_filters:
- number_of_records: Limit aşıldı. Bir kullanıcının bir hesap için izin verilen özel filtre sayısı 50'dir.
+ number_of_records: Limit aşıldı. Bir kullanıcının bir hesap için izin verilen özel filtre sayısı 1000'dir.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ tr:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Başlık veya içerikle makale arayın...
diff --git a/config/locales/uk.yml b/config/locales/uk.yml
index da1f328ac..29006859a 100644
--- a/config/locales/uk.yml
+++ b/config/locales/uk.yml
@@ -86,7 +86,7 @@ uk:
validations:
name: не повинно починатись або закінчуватися символами, і він не повинен мати < > / \ @ символів.
custom_filters:
- number_of_records: Досягнуто ліміту. Максимальна кількість дозволених користувацьких фільтрів для користувача на рахунок становить 50.
+ number_of_records: Досягнуто ліміту. Максимальна кількість дозволених користувацьких фільтрів для користувача на рахунок становить 1000.
invalid_attribute: Некоректний ключ атрибута - [%{key}]. Ключ повинен бути одним з [%{allowed_keys}] або налаштованим атрибутом, визначеним в обліковому записі.
invalid_operator: Некоректний оператор. Дозволені оператори для %{attribute_name} є [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ uk:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Пошук статті за заголовком або змістом...
diff --git a/config/locales/ur.yml b/config/locales/ur.yml
index 5fd822348..1f033f2a6 100644
--- a/config/locales/ur.yml
+++ b/config/locales/ur.yml
@@ -86,7 +86,7 @@ ur:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ur:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml
index 3ed18d377..304c8f405 100644
--- a/config/locales/ur_IN.yml
+++ b/config/locales/ur_IN.yml
@@ -86,7 +86,7 @@ ur:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ ur:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 9f2597037..ba84f6bbd 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -86,7 +86,7 @@ vi:
validations:
name: không nên bắt đầu hoặc kết thúc bằng các ký hiệu và không nên có kí tự < > / \ @.
custom_filters:
- number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 50.
+ number_of_records: Đã đạt giới hạn. Số lượng tuỳ chọn lọc tối đa cho mỗi mỗi người dùng mỗi tài khoản là 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ vi:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Tìm bài viết theo tiêu đề hoặc nội dung...
diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml
index 4702af460..234c3758d 100644
--- a/config/locales/zh_CN.yml
+++ b/config/locales/zh_CN.yml
@@ -86,7 +86,7 @@ zh_CN:
validations:
name: 不应该以符号开头或结尾,它不应该有 < > / \ @ 字符。
custom_filters:
- number_of_records: 已达到上限。每个账户允许用户自定义过滤器的最大数目为50个。
+ number_of_records: 已达到上限。每个账户允许用户自定义过滤器的最大数目为1000个。
invalid_attribute: 无效的属性键 - [%{key}]。键应为 [%{allowed_keys}] 之一或帐户中定义的自定义属性。
invalid_operator: 无效的操作符。%{attribute_name} 允许的操作符为 [%{allowed_keys}]。
invalid_query_operator: 查询操作符必须为 "AND" 或 "OR"。
@@ -322,6 +322,8 @@ zh_CN:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: 搜索文章的标题或正文...
diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml
index ad47d8333..572271664 100644
--- a/config/locales/zh_TW.yml
+++ b/config/locales/zh_TW.yml
@@ -86,7 +86,7 @@ zh_TW:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
- number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
+ number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -322,6 +322,8 @@ zh_TW:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
+ custom_tool:
+ slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...
diff --git a/config/routes.rb b/config/routes.rb
index 6a484b380..639c51da7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
+ resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
@@ -152,6 +153,7 @@ Rails.application.routes.draw do
end
end
+ resources :companies, only: [:index, :show, :create, :update, :destroy]
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
collection do
get :active
@@ -193,6 +195,7 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member
delete :avatar, on: :member
post :sync_templates, on: :member
+ get :health, on: :member
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
collection do
diff --git a/db/migrate/20250929105219_create_companies.rb b/db/migrate/20250929105219_create_companies.rb
new file mode 100644
index 000000000..10fa415c1
--- /dev/null
+++ b/db/migrate/20250929105219_create_companies.rb
@@ -0,0 +1,14 @@
+class CreateCompanies < ActiveRecord::Migration[7.1]
+ def change
+ create_table :companies do |t|
+ t.string :name, null: false
+ t.string :domain
+ t.text :description
+ t.references :account, null: false
+
+ t.timestamps
+ end
+ add_index :companies, [:name, :account_id]
+ add_index :companies, [:domain, :account_id]
+ end
+end
diff --git a/db/migrate/20250929132305_add_company_to_contacts.rb b/db/migrate/20250929132305_add_company_to_contacts.rb
new file mode 100644
index 000000000..e79de34b8
--- /dev/null
+++ b/db/migrate/20250929132305_add_company_to_contacts.rb
@@ -0,0 +1,5 @@
+class AddCompanyToContacts < ActiveRecord::Migration[7.1]
+ def change
+ add_reference :contacts, :company, null: true
+ end
+end
diff --git a/db/migrate/20251003091242_create_captain_custom_tools.rb b/db/migrate/20251003091242_create_captain_custom_tools.rb
new file mode 100644
index 000000000..8f63d826e
--- /dev/null
+++ b/db/migrate/20251003091242_create_captain_custom_tools.rb
@@ -0,0 +1,22 @@
+class CreateCaptainCustomTools < ActiveRecord::Migration[7.1]
+ def change
+ create_table :captain_custom_tools do |t|
+ t.references :account, null: false, index: true
+ t.string :slug, null: false
+ t.string :title, null: false
+ t.text :description
+ t.string :http_method, null: false, default: 'GET'
+ t.text :endpoint_url, null: false
+ t.text :request_template
+ t.text :response_template
+ t.string :auth_type, default: 'none'
+ t.jsonb :auth_config, default: {}
+ t.jsonb :param_schema, default: []
+ t.boolean :enabled, default: true, null: false
+
+ t.timestamps
+ end
+
+ add_index :captain_custom_tools, [:account_id, :slug], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d5f0c244c..c0d539f6a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
+ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
+ create_table "captain_custom_tools", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "slug", null: false
+ t.string "title", null: false
+ t.text "description"
+ t.string "http_method", default: "GET", null: false
+ t.text "endpoint_url", null: false
+ t.text "request_template"
+ t.text "response_template"
+ t.string "auth_type", default: "none"
+ t.jsonb "auth_config", default: {}
+ t.jsonb "param_schema", default: []
+ t.boolean "enabled", default: true, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true
+ t.index ["account_id"], name: "index_captain_custom_tools_on_account_id"
+ end
+
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false
@@ -551,6 +570,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
+ create_table "companies", force: :cascade do |t|
+ t.string "name", null: false
+ t.string "domain"
+ t.text "description"
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_companies_on_account_id"
+ t.index ["domain", "account_id"], name: "index_companies_on_domain_and_account_id"
+ t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
+ end
+
create_table "contact_inboxes", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "inbox_id"
@@ -583,6 +614,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.string "location", default: ""
t.string "country_code", default: ""
t.boolean "blocked", default: false, null: false
+ t.bigint "company_id"
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
@@ -590,6 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_contacts_on_account_id"
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
t.index ["blocked"], name: "index_contacts_on_blocked"
+ t.index ["company_id"], name: "index_contacts_on_company_id"
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index 21675bad0..ebeaaf67f 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def tools
- @tools = Captain::Assistant.available_agent_tools
+ assistant = Captain::Assistant.new(account: Current.account)
+ @tools = assistant.available_agent_tools
end
private
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
new file mode 100644
index 000000000..3137ded09
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb
@@ -0,0 +1,49 @@
+class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::CustomTool) }
+ before_action :set_custom_tool, only: [:show, :update, :destroy]
+
+ def index
+ @custom_tools = account_custom_tools.enabled
+ end
+
+ def show; end
+
+ def create
+ @custom_tool = account_custom_tools.create!(custom_tool_params)
+ end
+
+ def update
+ @custom_tool.update!(custom_tool_params)
+ end
+
+ def destroy
+ @custom_tool.destroy
+ head :no_content
+ end
+
+ private
+
+ def set_custom_tool
+ @custom_tool = account_custom_tools.find(params[:id])
+ end
+
+ def account_custom_tools
+ @account_custom_tools ||= Current.account.captain_custom_tools
+ end
+
+ def custom_tool_params
+ params.require(:custom_tool).permit(
+ :title,
+ :description,
+ :endpoint_url,
+ :http_method,
+ :request_template,
+ :response_template,
+ :auth_type,
+ :enabled,
+ auth_config: {},
+ param_schema: [:name, :type, :description, :required]
+ )
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/accounts/companies_controller.rb b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
new file mode 100644
index 000000000..a33e4c6b2
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/companies_controller.rb
@@ -0,0 +1,40 @@
+class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
+ before_action :check_authorization
+ before_action :fetch_company, only: [:show, :update, :destroy]
+
+ def index
+ @companies = Current.account.companies.ordered_by_name
+ end
+
+ def show; end
+
+ def create
+ @company = Current.account.companies.build(company_params)
+ @company.save!
+ end
+
+ def update
+ @company.update!(company_params)
+ end
+
+ def destroy
+ @company.destroy!
+ head :ok
+ end
+
+ private
+
+ def check_authorization
+ raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
+
+ authorize(Company)
+ end
+
+ def fetch_company
+ @company = Current.account.companies.find(params[:id])
+ end
+
+ def company_params
+ params.require(:company).permit(:name, :domain, :description, :avatar)
+ end
+end
diff --git a/enterprise/app/controllers/api/v1/auth_controller.rb b/enterprise/app/controllers/api/v1/auth_controller.rb
index 091d4f8f8..b0d8e1366 100644
--- a/enterprise/app/controllers/api/v1/auth_controller.rb
+++ b/enterprise/app/controllers/api/v1/auth_controller.rb
@@ -5,7 +5,9 @@ class Api::V1::AuthController < Api::BaseController
def saml_login
return if @account.nil?
- saml_initiation_url = "/auth/saml?account_id=#{@account.id}"
+ relay_state = params[:target] || 'web'
+
+ saml_initiation_url = "/auth/saml?account_id=#{@account.id}&RelayState=#{relay_state}"
redirect_to saml_initiation_url, status: :temporary_redirect
end
@@ -44,7 +46,18 @@ class Api::V1::AuthController < Api::BaseController
end
def render_saml_error
- redirect_to sso_login_page_url(error: 'saml-authentication-failed')
+ error = 'saml-authentication-failed'
+
+ if mobile_target?
+ mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
+ redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
+ else
+ redirect_to sso_login_page_url(error: error)
+ end
+ end
+
+ def mobile_target?
+ params[:target]&.casecmp('mobile')&.zero?
end
def sso_login_page_url(error: nil)
diff --git a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
index 973f26650..4856ca443 100644
--- a/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
+++ b/enterprise/app/controllers/enterprise/devise_overrides/omniauth_callbacks_controller.rb
@@ -32,17 +32,40 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
end
end
+ def omniauth_failure
+ return super unless params[:provider] == 'saml'
+
+ relay_state = saml_relay_state
+ error = params[:message] || 'authentication-failed'
+
+ if for_mobile?(relay_state)
+ redirect_to_mobile_error(error, relay_state)
+ else
+ redirect_to login_page_url(error: "saml-#{error}")
+ end
+ end
+
private
def handle_saml_auth
account_id = extract_saml_account_id
- return redirect_to login_page_url(error: 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
+ relay_state = saml_relay_state
+
+ unless saml_enabled_for_account?(account_id)
+ return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
+
+ return redirect_to login_page_url(error: 'saml-not-enabled')
+ end
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
+ return sign_in_user_on_mobile if for_mobile?(relay_state)
+
sign_in_user
else
+ return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
+
redirect_to login_page_url(error: 'saml-authentication-failed')
end
end
@@ -51,6 +74,19 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
params[:account_id] || session[:saml_account_id] || request.env['omniauth.params']&.dig('account_id')
end
+ def saml_relay_state
+ session[:saml_relay_state] || request.env['omniauth.params']&.dig('RelayState')
+ end
+
+ def for_mobile?(relay_state)
+ relay_state.to_s.casecmp('mobile').zero?
+ end
+
+ def redirect_to_mobile_error(error)
+ mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
+ redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
+ end
+
def saml_enabled_for_account?(account_id)
return false if account_id.blank?
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 7ede1201d..15f2ace56 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -49,10 +49,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
- {
+ message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
+
+ # Include agent_name if present in additional_attributes
+ message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
+
+ message_hash
end
end
@@ -79,25 +84,31 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def create_handoff_message
- create_outgoing_message(@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'))
+ create_outgoing_message(
+ @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
+ )
end
def create_messages
validate_message_content!(@response['response'])
- create_outgoing_message(@response['response'])
+ create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
- def create_outgoing_message(message_content)
+ def create_outgoing_message(message_content, agent_name: nil)
+ additional_attrs = {}
+ additional_attrs[:agent_name] = agent_name if agent_name.present?
+
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
sender: @assistant,
- content: message_content
+ content: message_content,
+ additional_attributes: additional_attrs
)
end
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index 0423abf67..771360659 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord
name
end
+ def available_agent_tools
+ tools = self.class.built_in_agent_tools.dup
+
+ custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
+ tools.concat(custom_tools)
+
+ tools
+ end
+
+ def available_tool_ids
+ available_agent_tools.pluck(:id)
+ end
+
def push_event_data
{
id: id,
@@ -92,6 +105,7 @@ class Captain::Assistant < ApplicationRecord
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
+ title: scenario.title,
key: scenario.title.parameterize.underscore,
description: scenario.description
}
diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb
new file mode 100644
index 000000000..bf3f351dd
--- /dev/null
+++ b/enterprise/app/models/captain/custom_tool.rb
@@ -0,0 +1,100 @@
+# == Schema Information
+#
+# Table name: captain_custom_tools
+#
+# id :bigint not null, primary key
+# auth_config :jsonb
+# auth_type :string default("none")
+# description :text
+# enabled :boolean default(TRUE), not null
+# endpoint_url :text not null
+# http_method :string default("GET"), not null
+# param_schema :jsonb
+# request_template :text
+# response_template :text
+# slug :string not null
+# title :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+#
+# Indexes
+#
+# index_captain_custom_tools_on_account_id (account_id)
+# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
+#
+class Captain::CustomTool < ApplicationRecord
+ include Concerns::Toolable
+ include Concerns::SafeEndpointValidatable
+
+ self.table_name = 'captain_custom_tools'
+
+ NAME_PREFIX = 'custom'.freeze
+ NAME_SEPARATOR = '_'.freeze
+ PARAM_SCHEMA_VALIDATION = {
+ 'type': 'array',
+ 'items': {
+ 'type': 'object',
+ 'properties': {
+ 'name': { 'type': 'string' },
+ 'type': { 'type': 'string' },
+ 'description': { 'type': 'string' },
+ 'required': { 'type': 'boolean' }
+ },
+ 'required': %w[name type description],
+ 'additionalProperties': false
+ }
+ }.to_json.freeze
+
+ belongs_to :account
+
+ enum :http_method, %w[GET POST].index_by(&:itself), validate: true
+ enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
+
+ before_validation :generate_slug
+
+ validates :slug, presence: true, uniqueness: { scope: :account_id }
+ validates :title, presence: true
+ validates :endpoint_url, presence: true
+ validates_with JsonSchemaValidator,
+ schema: PARAM_SCHEMA_VALIDATION,
+ attribute_resolver: ->(record) { record.param_schema }
+
+ scope :enabled, -> { where(enabled: true) }
+
+ def to_tool_metadata
+ {
+ id: slug,
+ title: title,
+ description: description,
+ custom: true
+ }
+ end
+
+ private
+
+ def generate_slug
+ return if slug.present?
+ return if title.blank?
+
+ paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
+
+ base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
+ self.slug = find_unique_slug(base_slug)
+ end
+
+ def find_unique_slug(base_slug)
+ return base_slug unless slug_exists?(base_slug)
+
+ 5.times do
+ slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
+ return slug_candidate unless slug_exists?(slug_candidate)
+ end
+
+ raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
+ end
+
+ def slug_exists?(candidate)
+ self.class.exists?(account_id: account_id, slug: candidate)
+ end
+end
diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb
index aac7e2411..d876a7127 100644
--- a/enterprise/app/models/captain/scenario.rb
+++ b/enterprise/app/models/captain/scenario.rb
@@ -38,7 +38,7 @@ class Captain::Scenario < ApplicationRecord
scope :enabled, -> { where(enabled: true) }
- delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
+ delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
before_save :resolve_tool_references
@@ -46,7 +46,10 @@ class Captain::Scenario < ApplicationRecord
{
title: title,
instructions: resolved_instructions,
- tools: resolved_tools
+ tools: resolved_tools,
+ assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
+ response_guidelines: response_guidelines || [],
+ guardrails: guardrails || []
}
end
@@ -57,24 +60,34 @@ class Captain::Scenario < ApplicationRecord
end
def agent_tools
- resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
+ resolved_tools.map { |tool| resolve_tool_instance(tool) }
end
def resolved_instructions
- instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
- "#{match} tool "
- end
+ instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
end
def resolved_tools
return [] if tools.blank?
- available_tools = self.class.available_agent_tools
+ available_tools = assistant.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
+ def resolve_tool_instance(tool_metadata)
+ tool_id = tool_metadata[:id]
+
+ if tool_metadata[:custom]
+ custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
+ custom_tool&.tool(assistant)
+ else
+ tool_class = self.class.resolve_tool_class(tool_id)
+ tool_class&.new(assistant)
+ end
+ end
+
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
@@ -95,8 +108,8 @@ class Captain::Scenario < ApplicationRecord
tool_ids = extract_tool_ids_from_text(instruction)
return if tool_ids.empty?
- available_tool_ids = self.class.available_tool_ids
- invalid_tools = tool_ids - available_tool_ids
+ all_available_tool_ids = assistant.available_tool_ids
+ invalid_tools = tool_ids - all_available_tool_ids
return unless invalid_tools.any?
diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb
new file mode 100644
index 000000000..764cb2a9c
--- /dev/null
+++ b/enterprise/app/models/company.rb
@@ -0,0 +1,33 @@
+# == Schema Information
+#
+# Table name: companies
+#
+# id :bigint not null, primary key
+# description :text
+# domain :string
+# name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+#
+# Indexes
+#
+# index_companies_on_account_id (account_id)
+# index_companies_on_domain_and_account_id (domain,account_id)
+# index_companies_on_name_and_account_id (name,account_id)
+#
+class Company < ApplicationRecord
+ include Avatarable
+ validates :account_id, presence: true
+ validates :name, presence: true, length: { maximum: Limits::COMPANY_NAME_LENGTH_LIMIT }
+ validates :domain, allow_blank: true, format: {
+ with: /\A[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+\z/,
+ message: I18n.t('errors.companies.domain.invalid')
+ }
+ validates :description, length: { maximum: Limits::COMPANY_DESCRIPTION_LENGTH_LIMIT }
+
+ belongs_to :account
+ has_many :contacts, dependent: :nullify
+
+ scope :ordered_by_name, -> { order(:name) }
+end
diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb
index 5a660310c..34133aac2 100644
--- a/enterprise/app/models/concerns/captain_tools_helpers.rb
+++ b/enterprise/app/models/concerns/captain_tools_helpers.rb
@@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
class_methods do
- # Returns all available agent tools with their metadata.
+ # Returns all built-in agent tools with their metadata.
# Only includes tools that have corresponding class files and can be resolved.
#
# @return [Array] Array of tool hashes with :id, :title, :description, :icon
- def available_agent_tools
- @available_agent_tools ||= load_agent_tools
+ def built_in_agent_tools
+ @built_in_agent_tools ||= load_agent_tools
end
# Resolves a tool class from a tool ID.
@@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers
class_name.safe_constantize
end
- # Returns an array of all available tool IDs.
- # Convenience method that extracts just the IDs from available_agent_tools.
+ # Returns an array of all built-in tool IDs.
+ # Convenience method that extracts just the IDs from built_in_agent_tools.
#
- # @return [Array] Array of available tool IDs
- def available_tool_ids
- @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] }
+ # @return [Array] Array of built-in tool IDs
+ def built_in_tool_ids
+ @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] }
end
private
diff --git a/enterprise/app/models/concerns/safe_endpoint_validatable.rb b/enterprise/app/models/concerns/safe_endpoint_validatable.rb
new file mode 100644
index 000000000..b151b10e7
--- /dev/null
+++ b/enterprise/app/models/concerns/safe_endpoint_validatable.rb
@@ -0,0 +1,84 @@
+module Concerns::SafeEndpointValidatable
+ extend ActiveSupport::Concern
+
+ FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze
+ DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze
+
+ included do
+ validate :validate_safe_endpoint_url
+ end
+
+ private
+
+ def validate_safe_endpoint_url
+ return if endpoint_url.blank?
+
+ uri = parse_endpoint_uri
+ return errors.add(:endpoint_url, 'must be a valid URL') unless uri
+
+ validate_endpoint_scheme(uri)
+ validate_endpoint_host(uri)
+ validate_not_ip_address(uri)
+ validate_no_unicode_chars(uri)
+ end
+
+ def parse_endpoint_uri
+ # Strip Liquid template syntax for validation
+ # Replace {{ variable }} with a placeholder value
+ sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder')
+ URI.parse(sanitized_url)
+ rescue URI::InvalidURIError
+ nil
+ end
+
+ def validate_endpoint_scheme(uri)
+ return if uri.scheme == 'https'
+
+ errors.add(:endpoint_url, 'must use HTTPS protocol')
+ end
+
+ def validate_endpoint_host(uri)
+ if uri.host.blank?
+ errors.add(:endpoint_url, 'must have a valid hostname')
+ return
+ end
+
+ if uri.host == FRONTEND_HOST
+ errors.add(:endpoint_url, 'cannot point to the application itself')
+ return
+ end
+
+ DISALLOWED_HOSTS.each do |pattern|
+ matched = if pattern.is_a?(Regexp)
+ uri.host =~ pattern
+ else
+ uri.host.downcase == pattern
+ end
+
+ next unless matched
+
+ errors.add(:endpoint_url, 'cannot use disallowed hostname')
+ break
+ end
+ end
+
+ def validate_not_ip_address(uri)
+ # Check for IPv4
+ if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host)
+ errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
+ return
+ end
+
+ # Check for IPv6
+ return unless uri.host.include?(':')
+
+ errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
+ end
+
+ def validate_no_unicode_chars(uri)
+ return unless uri.host
+ return if /\A[\x00-\x7F]+\z/.match?(uri.host)
+
+ errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters')
+ end
+end
diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb
new file mode 100644
index 000000000..ad047e8f8
--- /dev/null
+++ b/enterprise/app/models/concerns/toolable.rb
@@ -0,0 +1,91 @@
+module Concerns::Toolable
+ extend ActiveSupport::Concern
+
+ def tool(assistant)
+ custom_tool_record = self
+ # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
+ class_name = custom_tool_record.slug.underscore.camelize
+
+ # Always create a fresh class to reflect current metadata
+ tool_class = Class.new(Captain::Tools::HttpTool) do
+ description custom_tool_record.description
+
+ custom_tool_record.param_schema.each do |param_def|
+ param param_def['name'].to_sym,
+ type: param_def['type'],
+ desc: param_def['description'],
+ required: param_def.fetch('required', true)
+ end
+ end
+
+ # Register the dynamically created class as a constant in the Captain::Tools namespace.
+ # This is required because RubyLLM's Tool base class derives the tool name from the class name
+ # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
+ # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
+ # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
+ # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
+ # We refresh the constant on each call to ensure tool metadata changes are reflected.
+ Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
+ Captain::Tools.const_set(class_name, tool_class)
+
+ tool_class.new(assistant, self)
+ end
+
+ def build_request_url(params)
+ return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{')
+
+ render_template(endpoint_url, params)
+ end
+
+ def build_request_body(params)
+ return nil if request_template.blank?
+
+ render_template(request_template, params)
+ end
+
+ def build_auth_headers
+ return {} if auth_none?
+
+ case auth_type
+ when 'bearer'
+ { 'Authorization' => "Bearer #{auth_config['token']}" }
+ when 'api_key'
+ if auth_config['location'] == 'header'
+ { auth_config['name'] => auth_config['key'] }
+ else
+ {}
+ end
+ else
+ {}
+ end
+ end
+
+ def build_basic_auth_credentials
+ return nil unless auth_type == 'basic'
+
+ [auth_config['username'], auth_config['password']]
+ end
+
+ def format_response(raw_response_body)
+ return raw_response_body if response_template.blank?
+
+ response_data = parse_response_body(raw_response_body)
+ render_template(response_template, { 'response' => response_data, 'r' => response_data })
+ end
+
+ private
+
+ def render_template(template, context)
+ liquid_template = Liquid::Template.parse(template, error_mode: :strict)
+ liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true)
+ rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e
+ Rails.logger.error("Liquid template error: #{e.message}")
+ raise "Template rendering failed: #{e.message}"
+ end
+
+ def parse_response_body(body)
+ JSON.parse(body)
+ rescue JSON::ParserError
+ body
+ end
+end
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index b52ac4b3e..cae32e86c 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -10,8 +10,10 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
+ has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
+ has_many :companies, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
diff --git a/enterprise/app/models/enterprise/concerns/contact.rb b/enterprise/app/models/enterprise/concerns/contact.rb
new file mode 100644
index 000000000..9139fc67e
--- /dev/null
+++ b/enterprise/app/models/enterprise/concerns/contact.rb
@@ -0,0 +1,6 @@
+module Enterprise::Concerns::Contact
+ extend ActiveSupport::Concern
+ included do
+ belongs_to :company, optional: true
+ end
+end
diff --git a/enterprise/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb
new file mode 100644
index 000000000..b88a23860
--- /dev/null
+++ b/enterprise/app/policies/captain/custom_tool_policy.rb
@@ -0,0 +1,21 @@
+class Captain::CustomToolPolicy < ApplicationPolicy
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ @account_user.administrator?
+ end
+
+ def update?
+ @account_user.administrator?
+ end
+
+ def destroy?
+ @account_user.administrator?
+ end
+end
diff --git a/enterprise/app/policies/company_policy.rb b/enterprise/app/policies/company_policy.rb
new file mode 100644
index 000000000..1c252967c
--- /dev/null
+++ b/enterprise/app/policies/company_policy.rb
@@ -0,0 +1,21 @@
+class CompanyPolicy < ApplicationPolicy
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ true
+ end
+
+ def update?
+ true
+ end
+
+ def destroy?
+ @account_user.administrator?
+ end
+end
diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb
index 7a35e6d07..9c4e56841 100644
--- a/enterprise/app/services/captain/assistant/agent_runner_service.rb
+++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb
@@ -23,7 +23,7 @@ class Captain::Assistant::AgentRunnerService
message_to_process = extract_last_user_message(message_history)
runner = Agents::Runner.with_agents(*agents)
runner = add_callbacks_to_runner(runner) if @callbacks.any?
- result = runner.run(message_to_process, context: context)
+ result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
rescue StandardError => e
@@ -74,7 +74,12 @@ class Captain::Assistant::AgentRunnerService
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
- format_response(result.output)
+ response = format_response(result.output)
+
+ # Extract agent name from context
+ response['agent_name'] = result.context&.dig(:current_agent)
+
+ response
end
def format_response(output)
diff --git a/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder
index 35b723807..014884cf1 100644
--- a/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder
+++ b/enterprise/app/views/api/v1/accounts/applied_slas/index.json.jbuilder
@@ -3,7 +3,7 @@ json.payload do
json.applied_sla applied_sla.push_event_data
json.conversation do
conversation = applied_sla.conversation
- json.id conversation.id
+ json.id conversation.display_id
json.contact do
json.name conversation.contact.name if conversation.contact
end
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder
new file mode 100644
index 000000000..c57a92261
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder
@@ -0,0 +1,10 @@
+json.payload do
+ json.array! @custom_tools do |custom_tool|
+ json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool
+ end
+end
+
+json.meta do
+ json.total_count @custom_tools.count
+ json.page 1
+end
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder
new file mode 100644
index 000000000..baf3cb3ac
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
diff --git a/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
new file mode 100644
index 000000000..71c4d3b9b
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/_company.json.jbuilder
@@ -0,0 +1,7 @@
+json.id company.id
+json.name company.name
+json.domain company.domain
+json.description company.description
+json.avatar_url company.avatar_url
+json.created_at company.created_at
+json.updated_at company.updated_at
diff --git a/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/create.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
new file mode 100644
index 000000000..e68bd8543
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/index.json.jbuilder
@@ -0,0 +1,5 @@
+json.payload do
+ json.array! @companies do |company|
+ json.partial! 'company', company: company
+ end
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/show.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
new file mode 100644
index 000000000..b3bc80cfd
--- /dev/null
+++ b/enterprise/app/views/api/v1/accounts/companies/update.json.jbuilder
@@ -0,0 +1,3 @@
+json.payload do
+ json.partial! 'company', company: @company
+end
diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
new file mode 100644
index 000000000..778b30061
--- /dev/null
+++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder
@@ -0,0 +1,15 @@
+json.id custom_tool.id
+json.slug custom_tool.slug
+json.title custom_tool.title
+json.description custom_tool.description
+json.endpoint_url custom_tool.endpoint_url
+json.http_method custom_tool.http_method
+json.request_template custom_tool.request_template
+json.response_template custom_tool.response_template
+json.auth_type custom_tool.auth_type
+json.auth_config custom_tool.auth_config
+json.param_schema custom_tool.param_schema
+json.enabled custom_tool.enabled
+json.account_id custom_tool.account_id
+json.created_at custom_tool.created_at.to_i
+json.updated_at custom_tool.updated_at.to_i
diff --git a/enterprise/config/initializers/omniauth_saml.rb b/enterprise/config/initializers/omniauth_saml.rb
index f73e3a109..f39e9d511 100644
--- a/enterprise/config/initializers/omniauth_saml.rb
+++ b/enterprise/config/initializers/omniauth_saml.rb
@@ -9,18 +9,22 @@ SAML_SETUP_PROC = proc do |env|
account_id = request.params['account_id'] ||
request.session[:saml_account_id] ||
env['omniauth.params']&.dig('account_id')
+ relay_state = request.params['RelayState'] || ''
if account_id
# Store in session and omniauth params for callback
request.session[:saml_account_id] = account_id
+ request.session[:saml_relay_state] = relay_state
env['omniauth.params'] ||= {}
env['omniauth.params']['account_id'] = account_id
+ env['omniauth.params']['RelayState'] = relay_state
# Find SAML settings for this account
settings = AccountSamlSettings.find_by(account_id: account_id)
if settings
# Configure the strategy options dynamically
+ env['omniauth.strategy'].options[:idp_sso_service_url_runtime_params] = { RelayState: :RelayState }
env['omniauth.strategy'].options[:assertion_consumer_service_url] = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/omniauth/saml/callback?account_id=#{account_id}"
env['omniauth.strategy'].options[:sp_entity_id] = settings.sp_entity_id
env['omniauth.strategy'].options[:idp_entity_id] = settings.idp_entity_id
diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid
index 69c967d73..0dc7d8577 100644
--- a/enterprise/lib/captain/prompts/assistant.liquid
+++ b/enterprise/lib/captain/prompts/assistant.liquid
@@ -2,12 +2,13 @@
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
# Your Identity
-You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need.
+You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need.
{{ description }}
-Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this.
+Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
+{% if conversation || contact -%}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -19,12 +20,16 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if contact -%}
{% render 'contact' %}
{% endif -%}
+{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%}
- {{ guideline }}
+- Be conversational but professional
+- Provide actionable information
+- Include relevant details from tool responses
{% endfor %}
{% endif -%}
@@ -45,30 +50,26 @@ First, understand what the user is asking:
- **Complexity**: Can you handle it or does it need specialized expertise?
## 2. Check for Specialized Scenarios First
-Before using any tools, check if the request matches any of these scenarios. If unclear, ask clarifying questions to determine if a scenario applies:
+
+Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you.
{% for scenario in scenarios -%}
-### handoff_to_{{ scenario.key }}
-{{ scenario.description }}
-{% endfor -%}
+- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent.
+{% endfor %}
+If unclear, ask clarifying questions to determine if a scenario applies:
## 3. Handle the Request
-If no specialized scenario clearly matches, handle it yourself:
+If no specialized scenario clearly matches, handle it yourself in the following way
### For Questions and Information Requests
-1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information
-2. **If not found in FAQs**: Provide your best answer based on available context
-3. **If unable to answer**: Use `handoff` tool to transfer to a human expert
+1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
+2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
+3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
2. **Break down complex tasks**: Handle step by step or hand off if too complex
-3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities
-
-## Response Best Practices
-- Be conversational but professional
-- Provide actionable information
-- Include relevant details from tool responses
+3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities
# Human Handoff Protocol
Transfer to a human agent when:
@@ -77,4 +78,4 @@ Transfer to a human agent when:
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
-When using the `handoff` tool, provide a clear reason that helps the human agent understand the context.
+When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context.
diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid
index 339820b83..1148a7c3a 100644
--- a/enterprise/lib/captain/prompts/scenario.liquid
+++ b/enterprise/lib/captain/prompts/scenario.liquid
@@ -1,20 +1,44 @@
# System context
-You are part of a multi-agent system where you've been handed off a conversation to handle a specific task.
-The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
+You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
# Your Role
-You are a specialized agent called {{ title }}, your task is to handle the following scenario:
+You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
{{ instructions }}
+If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
+
+{% if conversation || contact %}
+# Current Context
+
+Here's the metadata we have about the current conversation and the contact associated with it:
+
{% if conversation -%}
{% render 'conversation' %}
+{% endif -%}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
+
+{% if response_guidelines.size > 0 -%}
+# Response Guidelines
+Your responses should follow these guidelines:
+{% for guideline in response_guidelines -%}
+- {{ guideline }}
+{% endfor %}
+{% endif -%}
+
+{% if guardrails.size > 0 -%}
+# Guardrails
+Always respect these boundaries:
+{% for guardrail in guardrails -%}
+- {{ guardrail }}
+{% endfor %}
+{% endif -%}
+
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools:
diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb
new file mode 100644
index 000000000..b634de04e
--- /dev/null
+++ b/enterprise/lib/captain/tools/http_tool.rb
@@ -0,0 +1,105 @@
+require 'agents'
+
+class Captain::Tools::HttpTool < Agents::Tool
+ def initialize(assistant, custom_tool)
+ @assistant = assistant
+ @custom_tool = custom_tool
+ super()
+ end
+
+ def active?
+ @custom_tool.enabled?
+ end
+
+ def perform(_tool_context, **params)
+ url = @custom_tool.build_request_url(params)
+ body = @custom_tool.build_request_body(params)
+
+ response = execute_http_request(url, body)
+ @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'
+ end
+
+ 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
+
+ def execute_http_request(url, body)
+ uri = URI.parse(url)
+
+ # 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)
+
+ response = http.request(request)
+
+ raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
+
+ validate_response!(response)
+
+ response
+ 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)
+ 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
+end
diff --git a/lib/integrations/slack/slack_message_helper.rb b/lib/integrations/slack/slack_message_helper.rb
index 52ec4caad..0ee328fb3 100644
--- a/lib/integrations/slack/slack_message_helper.rb
+++ b/lib/integrations/slack/slack_message_helper.rb
@@ -70,7 +70,9 @@ module Integrations::Slack::SlackMessageHelper
case attachment[:filetype]
when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg'
:image
- when 'pdf'
+ when 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'
+ :video
+ else
:file
end
end
diff --git a/lib/limits.rb b/lib/limits.rb
index 7a2371207..c0fc03806 100644
--- a/lib/limits.rb
+++ b/lib/limits.rb
@@ -6,6 +6,9 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
+ COMPANY_NAME_LENGTH_LIMIT = 100
+ COMPANY_DESCRIPTION_LENGTH_LIMIT = 1000
+ MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
diff --git a/lib/tasks/captain_chat.rake b/lib/tasks/captain_chat.rake
index cfe257196..6dfb37211 100644
--- a/lib/tasks/captain_chat.rake
+++ b/lib/tasks/captain_chat.rake
@@ -118,7 +118,7 @@ class CaptainChatSession
end
def show_available_tools
- available_tools = Captain::Assistant.available_tool_ids
+ available_tools = @assistant.available_tool_ids
if available_tools.any?
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
else
diff --git a/package.json b/package.json
index 66dd84aa2..38053b2c3 100644
--- a/package.json
+++ b/package.json
@@ -110,7 +110,7 @@
"@egoist/tailwindcss-icons": "^1.8.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/logos": "^1.2.3",
- "@iconify-json/lucide": "^1.2.11",
+ "@iconify-json/lucide": "^1.2.68",
"@iconify-json/ph": "^1.2.1",
"@iconify-json/ri": "^1.2.3",
"@iconify-json/teenyicons": "^1.2.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 71279b7e5..f4ae568c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -246,8 +246,8 @@ importers:
specifier: ^1.2.3
version: 1.2.3
'@iconify-json/lucide':
- specifier: ^1.2.11
- version: 1.2.11
+ specifier: ^1.2.68
+ version: 1.2.68
'@iconify-json/ph':
specifier: ^1.2.1
version: 1.2.1
@@ -884,8 +884,8 @@ packages:
'@iconify-json/logos@1.2.3':
resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==}
- '@iconify-json/lucide@1.2.11':
- resolution: {integrity: sha512-dqpbV7+g1qqxtZOHCZKwdKhtYYqEUjFhYiOg/+PcADbjtapoL+bwa1Brn12gAHq5r2K7Mf29xRHOTmZ3UHHOrw==}
+ '@iconify-json/lucide@1.2.68':
+ resolution: {integrity: sha512-lR5xNJdn2CT0iR7lM25G4SewBO4G2hbr3fTWOc3AE9BspflEcneh02E3l9TBaCU/JOHozTJevWLrxBGypD7Tng==}
'@iconify-json/material-symbols@1.2.10':
resolution: {integrity: sha512-GcZxhOFStM7Dk/oZvJSaW0tR/k6NwTq+KDzYgCNBDg52ktZuRa/gkjRiYooJm/8PAe9NBYxIx8XjS/wi4sasdQ==}
@@ -5265,7 +5265,7 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
- '@iconify-json/lucide@1.2.11':
+ '@iconify-json/lucide@1.2.68':
dependencies:
'@iconify/types': 2.0.0
diff --git a/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
index 760100cf3..9c9469090 100644
--- a/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb
@@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(json_response['name']).to eq 'vip-customers'
end
- it 'gives the error for 51st record' do
+ it 'gives the error for 1001st record' do
CustomFilter.delete_all
- CustomFilter::MAX_FILTER_PER_USER.times do
+ Limits::MAX_CUSTOM_FILTERS_PER_USER.times do
create(:custom_filter, user: user, account: account)
end
@@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include(
- 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
+ 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 1000.'
)
end
end
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index cc235cada..a44f60391 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -980,4 +980,153 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/health' do
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
+ end
+ let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
+ let(:non_whatsapp_inbox) { create(:inbox, account: account) }
+ let(:health_service) { instance_double(Whatsapp::HealthService) }
+ let(:health_data) do
+ {
+ display_phone_number: '+1234567890',
+ verified_name: 'Test Business',
+ name_status: 'APPROVED',
+ quality_rating: 'GREEN',
+ messaging_limit_tier: 'TIER_1000',
+ account_mode: 'LIVE',
+ business_id: 'business123'
+ }
+ end
+
+ before do
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return(health_data)
+ end
+
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ context 'with WhatsApp inbox' do
+ it 'returns health data for administrator' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response).to include(
+ 'display_phone_number' => '+1234567890',
+ 'verified_name' => 'Test Business',
+ 'name_status' => 'APPROVED',
+ 'quality_rating' => 'GREEN',
+ 'messaging_limit_tier' => 'TIER_1000',
+ 'account_mode' => 'LIVE',
+ 'business_id' => 'business123'
+ )
+ end
+
+ it 'returns health data for agent with inbox access' do
+ create(:inbox_member, user: agent, inbox: whatsapp_inbox)
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ json_response = response.parsed_body
+ expect(json_response['display_phone_number']).to eq('+1234567890')
+ end
+
+ it 'returns unauthorized for agent without inbox access' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'calls the health service with correct channel' do
+ expect(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
+ expect(health_service).to receive(:fetch_health_status)
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'handles service errors gracefully' do
+ allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API Error')
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ json_response = response.parsed_body
+ expect(json_response['error']).to include('API Error')
+ end
+ end
+
+ context 'with non-WhatsApp inbox' do
+ it 'returns bad request error for administrator' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
+ end
+
+ it 'returns bad request error for agent' do
+ create(:inbox_member, user: agent, inbox: non_whatsapp_inbox)
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
+ end
+ end
+
+ context 'with WhatsApp non-cloud inbox' do
+ let(:whatsapp_default_channel) do
+ create(:channel_whatsapp, account: account, provider: 'default', sync_templates: false, validate_provider_config: false)
+ end
+ let(:whatsapp_default_inbox) { create(:inbox, account: account, channel: whatsapp_default_channel) }
+
+ it 'returns bad request error for non-cloud provider' do
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_default_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ json_response = response.parsed_body
+ expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
+ end
+ end
+
+ context 'with non-existent inbox' do
+ it 'returns not found error' do
+ get "/api/v1/accounts/#{account.id}/inboxes/999999/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
index 423f65711..299b8ac7a 100644
--- a/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/applied_slas_controller_spec.rb
@@ -152,7 +152,7 @@ RSpec.describe 'Applied SLAs API', type: :request do
body = JSON.parse(response.body)
expect(body['payload'].size).to eq(1)
expect(body['payload'].first).to include('applied_sla')
- expect(body['payload'].first['conversation']['id']).to eq(conversation2.id)
+ expect(body['payload'].first['conversation']['id']).to eq(conversation2.display_id)
expect(body['meta']).to include('count' => 1)
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
new file mode 100644
index 000000000..7a1526995
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb
@@ -0,0 +1,281 @@
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
+ let(:account) { create(:account) }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ def json_response
+ JSON.parse(response.body, symbolize_names: true)
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status' do
+ create_list(:captain_custom_tool, 3, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(3)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'returns success status and custom tools' do
+ create_list(:captain_custom_tool, 5, account: account)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(5)
+ end
+
+ it 'returns only enabled custom tools' do
+ create(:captain_custom_tool, account: account, enabled: true)
+ create(:captain_custom_tool, account: account, enabled: false)
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:payload].length).to eq(1)
+ expect(json_response[:payload].first[:enabled]).to be(true)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns success status and custom tool' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:id]).to eq(custom_tool.id)
+ expect(json_response[:title]).to eq(custom_tool.title)
+ end
+ end
+
+ context 'when custom tool does not exist' do
+ it 'returns not found status' do
+ get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
+ headers: agent.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do
+ let(:valid_attributes) do
+ {
+ custom_tool: {
+ title: 'Fetch Order Status',
+ description: 'Fetches order status from external API',
+ endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
+ http_method: 'GET',
+ enabled: true,
+ param_schema: [
+ { name: 'order_id', type: 'string', description: 'The order ID', required: true }
+ ]
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'creates a new custom tool and returns success status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: valid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Fetch Order Status')
+ expect(json_response[:description]).to eq('Fetches order status from external API')
+ expect(json_response[:enabled]).to be(true)
+ expect(json_response[:slug]).to eq('custom_fetch_order_status')
+ expect(json_response[:param_schema]).to eq([
+ { name: 'order_id', type: 'string', description: 'The order ID', required: true }
+ ])
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ custom_tool: {
+ title: '',
+ endpoint_url: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ context 'with invalid endpoint URL' do
+ let(:invalid_url_attributes) do
+ {
+ custom_tool: {
+ title: 'Test Tool',
+ endpoint_url: 'http://localhost/api',
+ http_method: 'GET'
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ post "/api/v1/accounts/#{account.id}/captain/custom_tools",
+ params: invalid_url_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+ let(:update_attributes) do
+ {
+ custom_tool: {
+ title: 'Updated Tool Title',
+ enabled: false
+ }
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes,
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'updates the custom tool and returns success status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: update_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(json_response[:title]).to eq('Updated Tool Title')
+ expect(json_response[:enabled]).to be(false)
+ end
+
+ context 'with invalid parameters' do
+ let(:invalid_attributes) do
+ {
+ custom_tool: {
+ title: ''
+ }
+ }
+ end
+
+ it 'returns unprocessable entity status' do
+ patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ params: invalid_attributes,
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
+ let!(:custom_tool) { create(:captain_custom_tool, account: account) }
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'returns unauthorized status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: agent.create_new_auth_token
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an admin' do
+ it 'deletes the custom tool and returns no content status' do
+ expect do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
+ headers: admin.create_new_auth_token
+ end.to change(Captain::CustomTool, :count).by(-1)
+
+ expect(response).to have_http_status(:no_content)
+ end
+
+ context 'when custom tool does not exist' do
+ it 'returns not found status' do
+ delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
+ headers: admin.create_new_auth_token
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
new file mode 100644
index 000000000..f62991ad1
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb
@@ -0,0 +1,141 @@
+require 'rails_helper'
+
+RSpec.describe 'Companies API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'GET /api/v1/accounts/{account.id}/companies' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/companies"
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let!(:company1) { create(:company, name: 'Company 1', account: account) }
+ let!(:company2) { create(:company, account: account) }
+
+ it 'returns all companies' do
+ get "/api/v1/accounts/#{account.id}/companies",
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload'].size).to eq(2)
+ expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+
+ it 'returns the company' do
+ get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq(company.name)
+ expect(response_body['payload']['id']).to eq(company.id)
+ end
+ end
+ end
+
+ describe 'POST /api/v1/accounts/{account.id}/companies' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:valid_params) do
+ {
+ company: {
+ name: 'New Company',
+ domain: 'newcompany.com',
+ description: 'A new company'
+ }
+ }
+ end
+
+ it 'creates a new company' do
+ expect do
+ post "/api/v1/accounts/#{account.id}/companies",
+ params: valid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Company, :count).by(1)
+
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq('New Company')
+ expect(response_body['payload']['domain']).to eq('newcompany.com')
+ end
+
+ it 'returns error for invalid params' do
+ invalid_params = { company: { name: '' } }
+
+ post "/api/v1/accounts/#{account.id}/companies",
+ params: invalid_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated user' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+ let(:update_params) do
+ {
+ company: {
+ name: 'Updated Company Name',
+ domain: 'updated.com'
+ }
+ }
+ end
+
+ it 'updates the company' do
+ patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ params: update_params,
+ headers: admin.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:success)
+ response_body = response.parsed_body
+ expect(response_body['payload']['name']).to eq('Updated Company Name')
+ expect(response_body['payload']['domain']).to eq('updated.com')
+ end
+ end
+ end
+
+ describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
+ context 'when it is an authenticated administrator' do
+ let(:admin) { create(:user, account: account, role: :administrator) }
+ let(:company) { create(:company, account: account) }
+
+ it 'deletes the company' do
+ company
+ expect do
+ delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+ end.to change(Company, :count).by(-1)
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ context 'when it is a regular agent' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:company) { create(:company, account: account) }
+
+ it 'returns unauthorized' do
+ delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
+ headers: agent.create_new_auth_token,
+ as: :json
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
index f1e2ef1c7..767011a50 100644
--- a/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/auth_controller_spec.rb
@@ -36,6 +36,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user exists but has no SAML enabled accounts' do
@@ -48,6 +54,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user has account without SAML feature enabled' do
@@ -65,6 +77,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end
+
+ it 'redirects to mobile deep link with error when target is mobile' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
+ end
end
context 'when user has valid SAML configuration' do
@@ -82,6 +100,12 @@ RSpec.describe 'Api::V1::Auth', type: :request do
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
+
+ it 'redirects to SAML initiation URL with mobile relay state' do
+ post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
+
+ expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile")
+ end
end
context 'when user has multiple accounts with SAML' do
diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb
new file mode 100644
index 000000000..d48af2752
--- /dev/null
+++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb
@@ -0,0 +1,241 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Tools::HttpTool, type: :model do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:custom_tool) { create(:captain_custom_tool, account: account) }
+ let(:tool) { described_class.new(assistant, custom_tool) }
+ let(:tool_context) { Struct.new(:state).new({}) }
+
+ describe '#active?' do
+ it 'returns true when custom tool is enabled' do
+ custom_tool.update!(enabled: true)
+
+ expect(tool.active?).to be true
+ end
+
+ it 'returns false when custom tool is disabled' do
+ custom_tool.update!(enabled: false)
+
+ expect(tool.active?).to be false
+ end
+ end
+
+ describe '#perform' do
+ context 'with GET request' do
+ before do
+ custom_tool.update!(
+ http_method: 'GET',
+ endpoint_url: 'https://example.com/orders/123',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/orders/123')
+ .to_return(status: 200, body: '{"status": "success"}')
+ end
+
+ it 'executes GET request and returns response body' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"status": "success"}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
+ end
+ end
+
+ context 'with POST request' do
+ before do
+ custom_tool.update!(
+ http_method: 'POST',
+ endpoint_url: 'https://example.com/orders',
+ request_template: '{"order_id": "{{ order_id }}"}',
+ response_template: nil
+ )
+ stub_request(:post, 'https://example.com/orders')
+ .with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
+ .to_return(status: 200, body: '{"created": true}')
+ end
+
+ it 'executes POST request with rendered body' do
+ result = tool.perform(tool_context, order_id: '123')
+
+ expect(result).to eq('{"created": true}')
+ expect(WebMock).to have_requested(:post, 'https://example.com/orders')
+ .with(body: '{"order_id": "123"}')
+ end
+ end
+
+ context 'with template variables in URL' do
+ before do
+ custom_tool.update!(
+ endpoint_url: 'https://example.com/orders/{{ order_id }}',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/orders/456')
+ .to_return(status: 200, body: '{"order_id": "456"}')
+ end
+
+ it 'renders URL template with params' do
+ result = tool.perform(tool_context, order_id: '456')
+
+ expect(result).to eq('{"order_id": "456"}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
+ end
+ end
+
+ context 'with bearer token authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'bearer',
+ auth_config: { 'token' => 'secret_bearer_token' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds Authorization header with bearer token' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
+ end
+ end
+
+ context 'with basic authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'basic',
+ auth_config: { 'username' => 'user123', 'password' => 'pass456' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(basic_auth: %w[user123 pass456])
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds basic auth credentials' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(basic_auth: %w[user123 pass456])
+ end
+ end
+
+ context 'with API key authentication' do
+ before do
+ custom_tool.update!(
+ auth_type: 'api_key',
+ auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
+ endpoint_url: 'https://example.com/data',
+ response_template: nil
+ )
+ stub_request(:get, 'https://example.com/data')
+ .with(headers: { 'X-API-Key' => 'api_key_123' })
+ .to_return(status: 200, body: '{"authenticated": true}')
+ end
+
+ it 'adds API key header' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('{"authenticated": true}')
+ expect(WebMock).to have_requested(:get, 'https://example.com/data')
+ .with(headers: { 'X-API-Key' => 'api_key_123' })
+ end
+ end
+
+ context 'with response template' do
+ before do
+ custom_tool.update!(
+ endpoint_url: 'https://example.com/orders/123',
+ response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
+ )
+ stub_request(:get, 'https://example.com/orders/123')
+ .to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
+ end
+
+ it 'formats response using template' do
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('Order status: shipped, ID: 123')
+ end
+ end
+
+ context 'when handling errors' do
+ it 'returns generic error message on network failure' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on timeout' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_timeout
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on HTTP 404' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'returns generic error message on HTTP 500' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
+
+ result = tool.perform(tool_context)
+
+ expect(result).to eq('An error occurred while executing the request')
+ end
+
+ it 'logs error details' do
+ custom_tool.update!(endpoint_url: 'https://example.com/data')
+ stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
+
+ expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
+
+ tool.perform(tool_context)
+ end
+ end
+
+ context 'when integrating with Toolable methods' do
+ it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
+ custom_tool.update!(
+ http_method: 'POST',
+ endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
+ request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
+ auth_type: 'bearer',
+ auth_config: { 'token' => 'integration_token' },
+ response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
+ )
+
+ stub_request(:post, 'https://example.com/users/42/orders')
+ .with(
+ body: '{"product": "Widget", "quantity": 5}',
+ headers: {
+ 'Authorization' => 'Bearer integration_token',
+ 'Content-Type' => 'application/json'
+ }
+ )
+ .to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
+
+ result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
+
+ expect(result).to eq('Created order #ORD-789 for Widget')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb
new file mode 100644
index 000000000..5f6c7b19a
--- /dev/null
+++ b/spec/enterprise/models/captain/custom_tool_spec.rb
@@ -0,0 +1,388 @@
+require 'rails_helper'
+
+RSpec.describe Captain::CustomTool, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:account) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:title) }
+ it { is_expected.to validate_presence_of(:endpoint_url) }
+ it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
+
+ it {
+ expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
+ 'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
+ }
+
+ describe 'slug uniqueness' do
+ let(:account) { create(:account) }
+
+ it 'validates uniqueness of slug scoped to account' do
+ create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+ duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:slug]).to include('has already been taken')
+ end
+
+ it 'allows same slug across different accounts' do
+ account2 = create(:account)
+ create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
+ different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
+
+ expect(different_account_tool).to be_valid
+ end
+ end
+
+ describe 'param_schema validation' do
+ let(:account) { create(:account) }
+
+ it 'is valid with proper param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
+ ])
+
+ expect(tool).to be_valid
+ end
+
+ it 'is valid with empty param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [])
+
+ expect(tool).to be_valid
+ end
+
+ it 'is invalid when param_schema is missing name' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'type' => 'string', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid when param_schema is missing type' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid when param_schema is missing description' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is invalid with additional properties in param_schema' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
+ ])
+
+ expect(tool).not_to be_valid
+ end
+
+ it 'is valid when required field is omitted (defaults to optional param)' do
+ tool = build(:captain_custom_tool, account: account, param_schema: [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
+ ])
+
+ expect(tool).to be_valid
+ end
+ end
+ end
+
+ describe 'scopes' do
+ let(:account) { create(:account) }
+
+ describe '.enabled' do
+ it 'returns only enabled custom tools' do
+ enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
+ disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
+
+ expect(described_class.enabled).to include(enabled_tool)
+ expect(described_class.enabled).not_to include(disabled_tool)
+ end
+ end
+ end
+
+ describe 'slug generation' do
+ let(:account) { create(:account) }
+
+ it 'generates slug from title on creation' do
+ tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
+
+ expect(tool.slug).to eq('custom_fetch_order_status')
+ end
+
+ it 'adds custom_ prefix to generated slug' do
+ tool = create(:captain_custom_tool, account: account, title: 'My Tool')
+
+ expect(tool.slug).to start_with('custom_')
+ end
+
+ it 'does not override manually set slug' do
+ tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
+
+ expect(tool.slug).to eq('custom_manual_slug')
+ end
+
+ it 'handles slug collisions by appending random suffix' do
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
+ tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
+
+ expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
+ end
+
+ it 'handles multiple slug collisions' do
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
+ create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
+ tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
+
+ expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
+ expect(tool3.slug).not_to eq('custom_test_tool')
+ expect(tool3.slug).not_to eq('custom_test_tool_abc123')
+ end
+
+ it 'does not generate slug when title is blank' do
+ tool = build(:captain_custom_tool, account: account, title: nil)
+
+ expect(tool).not_to be_valid
+ expect(tool.errors[:title]).to include("can't be blank")
+ end
+
+ it 'parameterizes title correctly' do
+ tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
+
+ expect(tool.slug).to eq('custom_fetch_order_status_details')
+ end
+ end
+
+ describe 'factory' do
+ it 'creates a valid custom tool with default attributes' do
+ tool = create(:captain_custom_tool)
+
+ expect(tool).to be_valid
+ expect(tool.title).to be_present
+ expect(tool.slug).to be_present
+ expect(tool.endpoint_url).to be_present
+ expect(tool.http_method).to eq('GET')
+ expect(tool.auth_type).to eq('none')
+ expect(tool.enabled).to be true
+ end
+
+ it 'creates valid tool with POST trait' do
+ tool = create(:captain_custom_tool, :with_post)
+
+ expect(tool.http_method).to eq('POST')
+ expect(tool.request_template).to be_present
+ end
+
+ it 'creates valid tool with bearer auth trait' do
+ tool = create(:captain_custom_tool, :with_bearer_auth)
+
+ expect(tool.auth_type).to eq('bearer')
+ expect(tool.auth_config['token']).to eq('test_bearer_token_123')
+ end
+
+ it 'creates valid tool with basic auth trait' do
+ tool = create(:captain_custom_tool, :with_basic_auth)
+
+ expect(tool.auth_type).to eq('basic')
+ expect(tool.auth_config['username']).to eq('test_user')
+ expect(tool.auth_config['password']).to eq('test_pass')
+ end
+
+ it 'creates valid tool with api key trait' do
+ tool = create(:captain_custom_tool, :with_api_key)
+
+ expect(tool.auth_type).to eq('api_key')
+ expect(tool.auth_config['key']).to eq('test_api_key')
+ expect(tool.auth_config['location']).to eq('header')
+ end
+ end
+
+ describe 'Toolable concern' do
+ let(:account) { create(:account) }
+
+ describe '#build_request_url' do
+ it 'returns static URL when no template variables present' do
+ tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
+
+ expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
+ end
+
+ it 'renders URL template with params' do
+ tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
+
+ expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
+ end
+
+ it 'handles multiple template variables' do
+ tool = create(:captain_custom_tool, account: account,
+ endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
+
+ result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
+ expect(result).to eq('https://api.example.com/orders/123?details=true')
+ end
+ end
+
+ describe '#build_request_body' do
+ it 'returns nil when request_template is blank' do
+ tool = create(:captain_custom_tool, account: account, request_template: nil)
+
+ expect(tool.build_request_body({})).to be_nil
+ end
+
+ it 'renders request body template with params' do
+ tool = create(:captain_custom_tool, account: account,
+ request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
+
+ result = tool.build_request_body({ order_id: '12345' })
+ expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
+ end
+ end
+
+ describe '#build_auth_headers' do
+ it 'returns empty hash for none auth type' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'none')
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+
+ it 'returns bearer token header' do
+ tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
+
+ expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
+ end
+
+ it 'returns API key header when location is header' do
+ tool = create(:captain_custom_tool, :with_api_key, account: account)
+
+ expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
+ end
+
+ it 'returns empty hash for API key when location is not header' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
+ auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+
+ it 'returns empty hash for basic auth' do
+ tool = create(:captain_custom_tool, :with_basic_auth, account: account)
+
+ expect(tool.build_auth_headers).to eq({})
+ end
+ end
+
+ describe '#build_basic_auth_credentials' do
+ it 'returns nil for non-basic auth types' do
+ tool = create(:captain_custom_tool, account: account, auth_type: 'none')
+
+ expect(tool.build_basic_auth_credentials).to be_nil
+ end
+
+ it 'returns username and password array for basic auth' do
+ tool = create(:captain_custom_tool, :with_basic_auth, account: account)
+
+ expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
+ end
+ end
+
+ describe '#format_response' do
+ it 'returns raw response when no response_template' do
+ tool = create(:captain_custom_tool, account: account, response_template: nil)
+
+ expect(tool.format_response('raw response')).to eq('raw response')
+ end
+
+ it 'renders response template with JSON response' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Order status: {{ response.status }}')
+ raw_response = '{"status": "shipped", "tracking": "123ABC"}'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Order status: shipped')
+ end
+
+ it 'handles response template with multiple fields' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
+ raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
+ end
+
+ it 'handles non-JSON response' do
+ tool = create(:captain_custom_tool, account: account,
+ response_template: 'Response: {{ response }}')
+ raw_response = 'plain text response'
+
+ result = tool.format_response(raw_response)
+ expect(result).to eq('Response: plain text response')
+ end
+ end
+
+ describe '#to_tool_metadata' do
+ it 'returns tool metadata hash with custom flag' do
+ tool = create(:captain_custom_tool, account: account,
+ slug: 'custom_test-tool',
+ title: 'Test Tool',
+ description: 'A test tool')
+
+ metadata = tool.to_tool_metadata
+ expect(metadata).to eq({
+ id: 'custom_test-tool',
+ title: 'Test Tool',
+ description: 'A test tool',
+ custom: true
+ })
+ end
+ end
+
+ describe '#tool' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ it 'returns HttpTool instance' do
+ tool = create(:captain_custom_tool, account: account)
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'sets description on the tool class' do
+ tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance.description).to eq('Fetches order data')
+ end
+
+ it 'sets parameters on the tool class' do
+ tool = create(:captain_custom_tool, :with_params, account: account)
+
+ tool_instance = tool.tool(assistant)
+ params = tool_instance.parameters
+
+ expect(params.keys).to contain_exactly(:order_id, :include_details)
+ expect(params[:order_id].name).to eq(:order_id)
+ expect(params[:order_id].type).to eq('string')
+ expect(params[:order_id].description).to eq('The order ID')
+ expect(params[:order_id].required).to be true
+
+ expect(params[:include_details].name).to eq(:include_details)
+ expect(params[:include_details].required).to be false
+ end
+
+ it 'works with empty param_schema' do
+ tool = create(:captain_custom_tool, account: account, param_schema: [])
+
+ tool_instance = tool.tool(assistant)
+ expect(tool_instance.parameters).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb
index 7a39559c3..45009a3b0 100644
--- a/spec/enterprise/models/captain/scenario_spec.rb
+++ b/spec/enterprise/models/captain/scenario_spec.rb
@@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do
before do
# Mock available tools
- allow(described_class).to receive(:available_tool_ids).and_return(%w[
- add_contact_note add_private_note update_priority
- ])
+ allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
+ add_contact_note add_private_note update_priority
+ ])
end
describe 'validate_instruction_tools' do
@@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
+
+ it 'is valid with custom tool references' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).to be_valid
+ end
+
+ it 'is invalid with custom tool from different account' do
+ other_account = create(:account)
+ create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).not_to be_valid
+ expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
+ end
+
+ it 'is invalid with disabled custom tool' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
+
+ expect(scenario).not_to be_valid
+ expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
+ end
+
+ it 'is valid with mixed static and custom tool references' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = build(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ expect(scenario).to be_valid
+ end
end
describe 'resolve_tool_references' do
@@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
+ describe 'custom tool integration' do
+ let(:account) { create(:account) }
+ let(:assistant) { create(:captain_assistant, account: account) }
+
+ before do
+ allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
+ allow(described_class).to receive(:built_in_agent_tools).and_return([
+ { id: 'add_contact_note', title: 'Add Contact Note',
+ description: 'Add a note' }
+ ])
+ end
+
+ describe '#resolved_tools' do
+ it 'includes custom tool metadata' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
+ title: 'Fetch Order', description: 'Gets order details')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved.length).to eq(1)
+ expect(resolved.first[:id]).to eq('custom_fetch-order')
+ expect(resolved.first[:title]).to eq('Fetch Order')
+ expect(resolved.first[:description]).to eq('Gets order details')
+ end
+
+ it 'includes both static and custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved.length).to eq(2)
+ expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
+ end
+
+ it 'excludes disabled custom tools' do
+ custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ custom_tool.update!(enabled: false)
+
+ resolved = scenario.send(:resolved_tools)
+ expect(resolved).to be_empty
+ end
+ end
+
+ describe '#resolve_tool_instance' do
+ it 'returns HttpTool instance for custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+
+ tool_metadata = { id: 'custom_fetch-order', custom: true }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'returns nil for disabled custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+
+ tool_metadata = { id: 'custom_fetch-order', custom: true }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).to be_nil
+ end
+
+ it 'returns static tool instance for non-custom tools' do
+ scenario = create(:captain_scenario, assistant: assistant, account: account)
+ allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
+ Class.new do
+ def initialize(_assistant); end
+ end
+ )
+
+ tool_metadata = { id: 'add_contact_note' }
+ tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
+ expect(tool_instance).not_to be_nil
+ expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
+ end
+ end
+
+ describe '#agent_tools' do
+ it 'returns array of tool instances including custom tools' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ tools = scenario.send(:agent_tools)
+ expect(tools.length).to eq(1)
+ expect(tools.first).to be_a(Captain::Tools::HttpTool)
+ end
+
+ it 'excludes disabled custom tools from execution' do
+ custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
+
+ custom_tool.update!(enabled: false)
+
+ tools = scenario.send(:agent_tools)
+ expect(tools).to be_empty
+ end
+
+ it 'returns mixed static and custom tool instances' do
+ create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
+ scenario = create(:captain_scenario,
+ assistant: assistant,
+ account: account,
+ instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
+
+ allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
+ Class.new do
+ def initialize(_assistant); end
+ end
+ )
+
+ tools = scenario.send(:agent_tools)
+ expect(tools.length).to eq(2)
+ expect(tools.last).to be_a(Captain::Tools::HttpTool)
+ end
+ end
+ end
+
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)
diff --git a/spec/enterprise/models/company_spec.rb b/spec/enterprise/models/company_spec.rb
new file mode 100644
index 000000000..820e6ee7d
--- /dev/null
+++ b/spec/enterprise/models/company_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe Company, type: :model do
+ context 'with validations' do
+ it { is_expected.to validate_presence_of(:account_id) }
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(100) }
+ it { is_expected.to validate_length_of(:description).is_at_most(1000) }
+
+ describe 'domain validation' do
+ it { is_expected.to allow_value('example.com').for(:domain) }
+ it { is_expected.to allow_value('sub.example.com').for(:domain) }
+ it { is_expected.to allow_value('').for(:domain) }
+ it { is_expected.to allow_value(nil).for(:domain) }
+ it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
+ it { is_expected.not_to allow_value('.example.com').for(:domain) }
+ end
+ end
+
+ context 'with associations' do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to have_many(:contacts).dependent(:nullify) }
+ end
+
+ describe 'scopes' do
+ let(:account) { create(:account) }
+ let!(:company_b) { create(:company, name: 'B Company', account: account) }
+ let!(:company_a) { create(:company, name: 'A Company', account: account) }
+ let!(:company_c) { create(:company, name: 'C Company', account: account) }
+
+ describe '.ordered_by_name' do
+ it 'orders companies by name alphabetically' do
+ companies = described_class.where(account: account).ordered_by_name
+ expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
index afe482385..7e36e9006 100644
--- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
+++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
- describe '.available_agent_tools' do
- before do
- # Mock the YAML file loading
- allow(YAML).to receive(:load_file).and_return([
- {
- 'id' => 'add_contact_note',
- 'title' => 'Add Contact Note',
- 'description' => 'Add a note to a contact',
- 'icon' => 'note-add'
- },
- {
- 'id' => 'invalid_tool',
- 'title' => 'Invalid Tool',
- 'description' => 'This tool does not exist',
- 'icon' => 'invalid'
- }
- ])
-
- # Mock class resolution - only add_contact_note exists
- allow(test_class).to receive(:resolve_tool_class) do |tool_id|
- case tool_id
- when 'add_contact_note'
- Captain::Tools::AddContactNoteTool
- end
- end
- end
-
- it 'returns only resolvable tools' do
- tools = test_class.available_agent_tools
-
- expect(tools.length).to eq(1)
- expect(tools.first).to eq({
- id: 'add_contact_note',
- title: 'Add Contact Note',
- description: 'Add a note to a contact',
- icon: 'note-add'
- })
- end
-
- it 'logs warnings for unresolvable tools' do
- expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
-
- test_class.available_agent_tools
- end
-
- it 'memoizes the result' do
- expect(YAML).to receive(:load_file).once.and_return([])
-
- 2.times { test_class.available_agent_tools }
- end
- end
-
describe '.resolve_tool_class' do
it 'resolves valid tool classes' do
# Mock the constantize to return a class
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
- describe '.available_tool_ids' do
- before do
- allow(test_class).to receive(:available_agent_tools).and_return([
- { id: 'add_contact_note', title: 'Add Contact Note', description: '...',
- icon: 'note' },
- { id: 'update_priority', title: 'Update Priority', description: '...',
- icon: 'priority' }
- ])
- end
-
- it 'returns array of tool IDs' do
- ids = test_class.available_tool_ids
- expect(ids).to eq(%w[add_contact_note update_priority])
- end
-
- it 'memoizes the result' do
- expect(test_class).to receive(:available_agent_tools).once.and_return([])
-
- 2.times { test_class.available_tool_ids }
- end
- end
-
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
diff --git a/spec/enterprise/policies/company_policy_spec.rb b/spec/enterprise/policies/company_policy_spec.rb
new file mode 100644
index 000000000..9f7d9f0cc
--- /dev/null
+++ b/spec/enterprise/policies/company_policy_spec.rb
@@ -0,0 +1,33 @@
+require 'rails_helper'
+
+RSpec.describe CompanyPolicy, type: :policy do
+ subject(:company_policy) { described_class }
+
+ let(:account) { create(:account) }
+ let(:administrator) { create(:user, :administrator, account: account) }
+ let(:agent) { create(:user, account: account) }
+ let(:company) { create(:company, account: account) }
+
+ let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
+ let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
+
+ permissions :index?, :show?, :create?, :update? do
+ context 'when administrator' do
+ it { expect(company_policy).to permit(administrator_context, company) }
+ end
+
+ context 'when agent' do
+ it { expect(company_policy).to permit(agent_context, company) }
+ end
+ end
+
+ permissions :destroy? do
+ context 'when administrator' do
+ it { expect(company_policy).to permit(administrator_context, company) }
+ end
+
+ context 'when agent' do
+ it { expect(company_policy).not_to permit(agent_context, company) }
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
index f31177fc2..2c05860e2 100644
--- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
+++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
let(:mock_runner) { instance_double(Agents::Runner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
- let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) }
+ let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
@@ -90,7 +90,8 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(mock_runner).to receive(:run).with(
'I need help with my account',
- context: expected_context
+ context: expected_context,
+ max_turns: 100
)
service.generate_response(message_history: message_history)
@@ -99,7 +100,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
- expect(result).to eq({ 'response' => 'Test response' })
+ expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
end
context 'when no scenarios are enabled' do
@@ -118,14 +119,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'when agent result is a string' do
- let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') }
+ let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
- 'reasoning' => 'Processed by agent'
+ 'reasoning' => 'Processed by agent',
+ 'agent_name' => nil
})
end
end
diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb
new file mode 100644
index 000000000..2bfcbf360
--- /dev/null
+++ b/spec/factories/captain/custom_tool.rb
@@ -0,0 +1,51 @@
+FactoryBot.define do
+ factory :captain_custom_tool, class: 'Captain::CustomTool' do
+ sequence(:title) { |n| "Custom Tool #{n}" }
+ description { 'A custom HTTP tool for external API integration' }
+ endpoint_url { 'https://api.example.com/endpoint' }
+ http_method { 'GET' }
+ auth_type { 'none' }
+ auth_config { {} }
+ param_schema { [] }
+ enabled { true }
+ association :account
+
+ trait :with_post do
+ http_method { 'POST' }
+ request_template { '{ "key": "{{ value }}" }' }
+ end
+
+ trait :with_bearer_auth do
+ auth_type { 'bearer' }
+ auth_config { { token: 'test_bearer_token_123' } }
+ end
+
+ trait :with_basic_auth do
+ auth_type { 'basic' }
+ auth_config { { username: 'test_user', password: 'test_pass' } }
+ end
+
+ trait :with_api_key do
+ auth_type { 'api_key' }
+ auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
+ end
+
+ trait :with_templates do
+ request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' }
+ response_template { 'Order status: {{ response.status }}' }
+ end
+
+ trait :with_params do
+ param_schema do
+ [
+ { 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true },
+ { 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false }
+ ]
+ end
+ end
+
+ trait :disabled do
+ enabled { false }
+ end
+ end
+end
diff --git a/spec/factories/companies.rb b/spec/factories/companies.rb
new file mode 100644
index 000000000..bdf7e9e9f
--- /dev/null
+++ b/spec/factories/companies.rb
@@ -0,0 +1,20 @@
+FactoryBot.define do
+ factory :company do
+ sequence(:name) { |n| "Company #{n}" }
+ sequence(:domain) { |n| "company#{n}.com" }
+ description { 'A sample company description' }
+ account
+
+ trait :without_domain do
+ domain { nil }
+ end
+
+ trait :with_avatar do
+ avatar { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
+ end
+
+ trait :with_long_description do
+ description { 'A' * 500 }
+ end
+ end
+end
diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
index 608324e8f..2ce206489 100644
--- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb
+++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb
@@ -157,6 +157,19 @@ describe Integrations::Slack::IncomingMessageBuilder do
expect(conversation.messages.count).to eql(messages_count)
end
+
+ it 'handles different file types correctly' do
+ expect(hook).not_to be_nil
+ video_attachment_params = message_with_attachments.deep_dup
+ video_attachment_params[:event][:files][0][:filetype] = 'mp4'
+ video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
+
+ builder = described_class.new(video_attachment_params)
+ allow(builder).to receive(:sender).and_return(nil)
+
+ expect { builder.perform }.not_to raise_error
+ expect(conversation.messages.last.attachments).to be_any
+ end
end
context 'when link shared' do
diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb
index a0bd48e39..c5e080677 100644
--- a/spec/models/message_spec.rb
+++ b/spec/models/message_spec.rb
@@ -4,6 +4,12 @@ require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
+ before do
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
+ # rubocop:enable RSpec/AnyInstance
+ end
+
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
@@ -678,4 +684,54 @@ RSpec.describe Message do
end
end
end
+
+ describe '#reindex_for_search callback' do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+
+ before do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
+ account.enable_features('advanced_search_indexing')
+ end
+
+ context 'when message should be indexed' do
+ it 'calls reindex_for_search for incoming message on create' do
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'calls reindex_for_search for outgoing message on update' do
+ # rubocop:disable RSpec/AnyInstance
+ allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
+ # rubocop:enable RSpec/AnyInstance
+ message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
+ expect(message).to receive(:reindex_for_search).and_return(true)
+ message.update!(content: 'Updated content')
+ end
+ end
+
+ context 'when message should not be indexed' do
+ it 'does not call reindex_for_search for activity message' do
+ message = build(:message, conversation: conversation, account: account, message_type: :activity)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'does not call reindex_for_search for unpaid account on cloud' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ account.disable_features('advanced_search_indexing')
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+
+ it 'does not call reindex_for_search when advanced search is not allowed' do
+ allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
+ message = build(:message, conversation: conversation, account: account, message_type: :incoming)
+ expect(message).not_to receive(:reindex_for_search)
+ message.save!
+ end
+ end
+ end
end
diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb
index 1db94928e..a20e36ac8 100644
--- a/spec/services/whatsapp/embedded_signup_service_spec.rb
+++ b/spec/services/whatsapp/embedded_signup_service_spec.rb
@@ -48,6 +48,15 @@ describe Whatsapp::EmbeddedSignupService do
allow(channel_creation).to receive(:perform).and_return(channel)
allow(channel).to receive(:setup_webhooks)
+ allow(channel).to receive(:phone_number).and_return('+1234567890')
+
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'CLOUD_API',
+ throughput: { 'level' => 'STANDARD' },
+ messaging_limit_tier: 'TIER_1000'
+ })
end
it 'creates channel and sets up webhooks' do
@@ -57,6 +66,49 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel)
end
+ it 'checks health status after channel creation' do
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ expect(health_service).to receive(:fetch_health_status)
+
+ service.perform
+ end
+
+ context 'when channel is in pending state' do
+ it 'prompts reauthorization for pending channel' do
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'NOT_APPLICABLE',
+ throughput: { 'level' => 'STANDARD' },
+ messaging_limit_tier: 'TIER_1000'
+ })
+
+ expect(channel).to receive(:prompt_reauthorization!)
+ service.perform
+ end
+
+ it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'CLOUD_API',
+ throughput: { 'level' => 'NOT_APPLICABLE' },
+ messaging_limit_tier: 'TIER_1000'
+ })
+
+ expect(channel).to receive(:prompt_reauthorization!)
+ service.perform
+ end
+ end
+
+ context 'when channel is healthy' do
+ it 'does not prompt reauthorization for healthy channel' do
+ expect(channel).not_to receive(:prompt_reauthorization!)
+ service.perform
+ end
+ end
+
context 'when parameters are invalid' do
it 'raises ArgumentError for missing parameters' do
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
@@ -114,6 +166,16 @@ describe Whatsapp::EmbeddedSignupService do
business_id: params[:business_id]
).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
+
+ allow(channel).to receive(:phone_number).and_return('+1234567890')
+
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'CLOUD_API',
+ throughput: { 'level' => 'STANDARD' },
+ messaging_limit_tier: 'TIER_1000'
+ })
end
it 'uses ReauthorizationService and sets up webhooks' do
@@ -124,36 +186,57 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel)
end
- it 'clears reauthorization flag' do
- inbox = create(:inbox, account: account)
- whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
- validate_provider_config: false, sync_templates: false)
- inbox.update!(channel: whatsapp_channel)
- whatsapp_channel.prompt_reauthorization!
+ context 'with real channel requiring reauthorization' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:whatsapp_channel) do
+ create(:channel_whatsapp, account: account, phone_number: '+1234567890',
+ validate_provider_config: false, sync_templates: false)
+ end
+ let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) }
- service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
+ before do
+ inbox.update!(channel: whatsapp_channel)
+ whatsapp_channel.prompt_reauthorization!
- # Mock the ReauthorizationService to return our test channel
- reauth_service = instance_double(Whatsapp::ReauthorizationService)
- allow(Whatsapp::ReauthorizationService).to receive(:new).with(
- account: account,
- inbox_id: inbox.id,
- phone_number_id: params[:phone_number_id],
- business_id: params[:business_id]
- ).and_return(reauth_service)
-
- # Perform the reauthorization and clear the flag
- allow(reauth_service).to receive(:perform) do
- whatsapp_channel.reauthorized!
- whatsapp_channel
+ setup_reauthorization_mocks
+ setup_health_service_mock
end
- allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
+ it 'clears reauthorization flag when reauthorization completes' do
+ expect(whatsapp_channel.reauthorization_required?).to be true
+ result = service_with_real_inbox.perform
+ expect(result).to eq(whatsapp_channel)
+ expect(whatsapp_channel.reauthorization_required?).to be false
+ end
- expect(whatsapp_channel.reauthorization_required?).to be true
- result = service_with_real_inbox.perform
- expect(result).to eq(whatsapp_channel)
- expect(whatsapp_channel.reauthorization_required?).to be false
+ private
+
+ def setup_reauthorization_mocks
+ reauth_service = instance_double(Whatsapp::ReauthorizationService)
+ allow(Whatsapp::ReauthorizationService).to receive(:new).with(
+ account: account,
+ inbox_id: inbox.id,
+ phone_number_id: params[:phone_number_id],
+ business_id: params[:business_id]
+ ).and_return(reauth_service)
+
+ allow(reauth_service).to receive(:perform) do
+ whatsapp_channel.reauthorized!
+ whatsapp_channel
+ end
+
+ allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
+ end
+
+ def setup_health_service_mock
+ health_service = instance_double(Whatsapp::HealthService)
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'CLOUD_API',
+ throughput: { 'level' => 'STANDARD' },
+ messaging_limit_tier: 'TIER_1000'
+ })
+ end
end
end
end
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index ede1ba824..6c23e9b71 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
end
end
+ describe 'When the incoming waid is an Argentine number with 9 after country code' do
+ let(:wa_id) { '5491123456789' }
+
+ it 'creates appropriate conversations, message and contacts if contact does not exist' do
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
+ expect(Contact.all.first.name).to eq('Sojan Jose')
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
+ end
+
+ it 'appends to existing contact if contact inbox exists with normalized format' do
+ # Normalized format removes the 9 after country code
+ normalized_wa_id = '541123456789'
+ contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ # no new conversation should be created
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ # message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
+ # should use the normalized wa_id from existing contact
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
+ end
+ end
+
+ describe 'When incoming waid is an Argentine number without 9 after country code' do
+ let(:wa_id) { '541123456789' }
+
+ context 'when a contact inbox exists with the same format' do
+ it 'appends to existing contact' do
+ contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
+ last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ # no new conversation should be created
+ expect(whatsapp_channel.inbox.conversations.count).to eq(1)
+ # message appended to the last conversation
+ expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
+ end
+ end
+
+ context 'when a contact inbox does not exist' do
+ it 'creates contact inbox with the incoming waid' do
+ described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
+ expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
+ expect(Contact.all.first.name).to eq('Sojan Jose')
+ expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
+ expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
+ end
+ end
+ end
+
describe 'when message processing is in progress' do
it 'ignores the current message creation request' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
diff --git a/spec/services/whatsapp/populate_template_parameters_service_spec.rb b/spec/services/whatsapp/populate_template_parameters_service_spec.rb
new file mode 100644
index 000000000..05390bd90
--- /dev/null
+++ b/spec/services/whatsapp/populate_template_parameters_service_spec.rb
@@ -0,0 +1,70 @@
+require 'rails_helper'
+
+describe Whatsapp::PopulateTemplateParametersService do
+ let(:service) { described_class.new }
+
+ describe '#normalize_url' do
+ it 'normalizes URLs with spaces' do
+ url_with_spaces = 'https://example.com/path with spaces'
+ normalized = service.send(:normalize_url, url_with_spaces)
+
+ expect(normalized).to eq('https://example.com/path%20with%20spaces')
+ end
+
+ it 'handles URLs with special characters' do
+ url = 'https://example.com/path?query=test value'
+ normalized = service.send(:normalize_url, url)
+
+ expect(normalized).to include('https://example.com/path')
+ expect(normalized).not_to include(' ')
+ end
+
+ it 'returns valid URLs unchanged' do
+ url = 'https://example.com/valid-path'
+ normalized = service.send(:normalize_url, url)
+
+ expect(normalized).to eq(url)
+ end
+ end
+
+ describe '#build_media_parameter' do
+ context 'when URL contains spaces' do
+ it 'normalizes the URL before building media parameter' do
+ url_with_spaces = 'https://example.com/image with spaces.jpg'
+ result = service.build_media_parameter(url_with_spaces, 'IMAGE')
+
+ expect(result[:type]).to eq('image')
+ expect(result[:image][:link]).to eq('https://example.com/image%20with%20spaces.jpg')
+ end
+ end
+
+ context 'when URL contains special characters in query string' do
+ it 'normalizes the URL correctly' do
+ url = 'https://example.com/video.mp4?title=My Video'
+ result = service.build_media_parameter(url, 'VIDEO', 'test_video')
+
+ expect(result[:type]).to eq('video')
+ expect(result[:video][:link]).not_to include(' ')
+ end
+ end
+
+ context 'when URL is already valid' do
+ it 'builds media parameter without changing URL' do
+ url = 'https://example.com/document.pdf'
+ result = service.build_media_parameter(url, 'DOCUMENT', 'test.pdf')
+
+ expect(result[:type]).to eq('document')
+ expect(result[:document][:link]).to eq(url)
+ expect(result[:document][:filename]).to eq('test.pdf')
+ end
+ end
+
+ context 'when URL is blank' do
+ it 'returns nil' do
+ result = service.build_media_parameter('', 'IMAGE')
+
+ expect(result).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb
index e6a246e5d..38856e252 100644
--- a/spec/services/whatsapp/webhook_setup_service_spec.rb
+++ b/spec/services/whatsapp/webhook_setup_service_spec.rb
@@ -16,6 +16,7 @@ describe Whatsapp::WebhookSetupService do
let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(channel, waba_id, access_token) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
+ let(:health_service) { instance_double(Whatsapp::HealthService) }
before do
# Stub webhook teardown to prevent HTTP calls during cleanup
@@ -24,8 +25,14 @@ describe Whatsapp::WebhookSetupService do
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
- # Default stub for phone_number_verified? with any argument
+ allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
+
+ # Default stubs for phone_number_verified? and health service
allow(api_client).to receive(:phone_number_verified?).and_return(false)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
end
describe '#perform' do
@@ -49,9 +56,13 @@ describe Whatsapp::WebhookSetupService do
end
end
- context 'when phone number IS verified (should NOT register)' do
+ context 'when phone number IS verified AND fully provisioned (should NOT register)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end
@@ -66,16 +77,68 @@ describe Whatsapp::WebhookSetupService do
end
end
+ context 'when phone number IS verified BUT needs registration (pending provisioning)' do
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'NOT_APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'registers the phone number due to pending provisioning state' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
+ service.perform
+ end
+ end
+ end
+
+ context 'when phone number needs registration due to throughput level' do
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'NOT_APPLICABLE' }
+ })
+ allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
+ allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ allow(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
+ allow(channel).to receive(:save!)
+ end
+
+ it 'registers the phone number due to throughput not applicable' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ .with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
+ service.perform
+ end
+ end
+ end
+
context 'when phone_number_verified? raises error' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down')
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number)
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
- it 'tries to register phone and proceeds with webhook setup' do
+ it 'tries to register phone (due to verification error) and proceeds with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
@@ -84,6 +147,22 @@ describe Whatsapp::WebhookSetupService do
end
end
+ context 'when health service raises error' do
+ before do
+ allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_raise('Health API down')
+ allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
+ end
+
+ it 'does not register phone (conservative approach) and proceeds with webhook setup' do
+ with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
+ expect(api_client).not_to receive(:register_phone_number)
+ expect(api_client).to receive(:subscribe_waba_webhook)
+ expect { service.perform }.not_to raise_error
+ end
+ end
+ end
+
context 'when phone registration fails (not blocking)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
@@ -193,6 +272,10 @@ describe Whatsapp::WebhookSetupService do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
end
@@ -218,6 +301,10 @@ describe Whatsapp::WebhookSetupService do
context 'when webhook setup is successful in creation flow' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
+ allow(health_service).to receive(:fetch_health_status).and_return({
+ platform_type: 'APPLICABLE',
+ throughput: { level: 'APPLICABLE' }
+ })
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end