diff --git a/Gemfile.lock b/Gemfile.lock index 533c88f1a..0359c37d5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -486,7 +486,7 @@ GEM uri net-http-persistent (4.0.2) connection_pool (~> 2.2) - net-imap (0.4.19) + net-imap (0.4.20) date net-protocol net-pop (0.1.2) diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 0e024b3d8..b4d5e3fc1 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -163,9 +163,16 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController @contact.custom_attributes end + def contact_additional_attributes + return @contact.additional_attributes.merge(permitted_params[:additional_attributes]) if permitted_params[:additional_attributes] + + @contact.additional_attributes + end + def contact_update_params - # we want the merged custom attributes not the original one - permitted_params.except(:custom_attributes, :avatar_url).merge({ custom_attributes: contact_custom_attributes }) + permitted_params.except(:custom_attributes, :avatar_url) + .merge({ custom_attributes: contact_custom_attributes }) + .merge({ additional_attributes: contact_additional_attributes }) end def set_include_contact_inboxes diff --git a/app/controllers/api/v1/accounts/conversations/messages_controller.rb b/app/controllers/api/v1/accounts/conversations/messages_controller.rb index 63226f342..67381a715 100644 --- a/app/controllers/api/v1/accounts/conversations/messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/messages_controller.rb @@ -1,4 +1,6 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::Conversations::BaseController + before_action :ensure_api_inbox, only: :update + def index @messages = message_finder.perform end @@ -11,6 +13,11 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: render_could_not_create_error(e.message) end + def update + Messages::StatusUpdateService.new(message, permitted_params[:status], permitted_params[:external_error]).perform + @message = message + end + def destroy ActiveRecord::Base.transaction do message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true }) @@ -21,7 +28,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: def retry return if message.blank? - message.update!(status: :sent, content_attributes: {}) + service = Messages::StatusUpdateService.new(message, 'sent') + service.perform + message.update!(content_attributes: {}) ::SendReplyJob.perform_later(message.id) rescue StandardError => e render_could_not_create_error(e.message) @@ -56,10 +65,16 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts:: end def permitted_params - params.permit(:id, :target_language) + params.permit(:id, :target_language, :status, :external_error) end def already_translated_content_available? message.translations.present? && message.translations[permitted_params[:target_language]].present? end + + # API inbox check + def ensure_api_inbox + # Only API inboxes can update messages + render json: { error: 'Message status update is only allowed for API inboxes' }, status: :forbidden unless @conversation.inbox.api? + end end diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 5de25f677..27ce587f7 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -66,3 +66,5 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # rubocop:enable Rails/I18nLocaleTexts end end + +SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 0bf4e44ca..9be674f11 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -9,10 +9,17 @@ class AccountDashboard < Administrate::BaseDashboard # on pages throughout the dashboard. enterprise_attribute_types = if ChatwootApp.enterprise? - { - limits: Enterprise::AccountLimitsField, - all_features: Enterprise::AccountFeaturesField + attributes = { + limits: AccountLimitsField } + + # Only show manually managed features in Chatwoot Cloud deployment + attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud? + + # Add all_features last so it appears after manually_managed_features + attributes[:all_features] = AccountFeaturesField + + attributes else {} end @@ -46,7 +53,14 @@ class AccountDashboard < Administrate::BaseDashboard # SHOW_PAGE_ATTRIBUTES # an array of attributes that will be displayed on the model's show page. - enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : [] + enterprise_show_page_attributes = if ChatwootApp.enterprise? + attrs = %i[custom_attributes limits] + attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? + attrs << :all_features + attrs + else + [] + end SHOW_PAGE_ATTRIBUTES = (%i[ id name @@ -61,7 +75,14 @@ class AccountDashboard < Administrate::BaseDashboard # FORM_ATTRIBUTES # an array of attributes that will be displayed # on the model's form (`new` and `edit`) pages. - enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : [] + enterprise_form_attributes = if ChatwootApp.enterprise? + attrs = %i[limits] + attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud? + attrs << :all_features + attrs + else + [] + end FORM_ATTRIBUTES = (%i[ name locale @@ -96,6 +117,11 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) - super + [limits: {}] + attrs = super + [limits: {}] + + # Add manually_managed_features to permitted attributes only for Chatwoot Cloud + attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? + + attrs end end diff --git a/app/fields/enterprise/account_features_field.rb b/app/fields/enterprise/account_features_field.rb deleted file mode 100644 index 7a9de5a59..000000000 --- a/app/fields/enterprise/account_features_field.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'administrate/field/base' - -class Enterprise::AccountFeaturesField < Administrate::Field::Base - def to_s - data - end -end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index c24e65e68..9f02a72da 100644 --- a/app/helpers/super_admin/account_features_helper.rb +++ b/app/helpers/super_admin/account_features_helper.rb @@ -15,7 +15,7 @@ module SuperAdmin::AccountFeaturesHelper end def self.filter_internal_features(features) - return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' + return features if ChatwootApp.chatwoot_cloud? internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name') features.except(*internal_features) diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index ce636e526..157eba74e 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -14,6 +14,13 @@ class CaptainAssistant extends ApiClient { }, }); } + + playground({ assistantId, messageContent, messageHistory }) { + return axios.post(`${this.url}/${assistantId}/playground`, { + message_content: messageContent, + message_history: messageHistory, + }); + } } export default new CaptainAssistant(); diff --git a/app/javascript/dashboard/components-next/Accordion/Accordion.vue b/app/javascript/dashboard/components-next/Accordion/Accordion.vue new file mode 100644 index 000000000..f75a2ef25 --- /dev/null +++ b/app/javascript/dashboard/components-next/Accordion/Accordion.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue index d5586cb12..789aba1c8 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue @@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue index 17367c930..c11fa2d9d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue @@ -1,6 +1,8 @@ diff --git a/app/javascript/dashboard/components-next/captain/PageLayout.vue b/app/javascript/dashboard/components-next/captain/PageLayout.vue index e9fae9ca7..7355ac616 100644 --- a/app/javascript/dashboard/components-next/captain/PageLayout.vue +++ b/app/javascript/dashboard/components-next/captain/PageLayout.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { usePolicy } from 'dashboard/composables/usePolicy'; import Button from 'dashboard/components-next/button/Button.vue'; +import BackButton from 'dashboard/components/widgets/BackButton.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Policy from 'dashboard/components/policy.vue'; @@ -23,6 +24,10 @@ const props = defineProps({ type: String, default: '', }, + backUrl: { + type: [String, Object], + default: '', + }, buttonPolicy: { type: Array, default: () => [], @@ -39,6 +44,10 @@ const props = defineProps({ type: Boolean, default: false, }, + showKnowMore: { + type: Boolean, + default: true, + }, isEmpty: { type: Boolean, default: false, @@ -73,19 +82,23 @@ const handlePageChange = event => { class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" >
+ {{ headerTitle }} -
+
@@ -104,7 +117,7 @@ const handlePageChange = event => {
-
+
{ diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue new file mode 100644 index 000000000..66b6876b1 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactNotes.vue @@ -0,0 +1,51 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue index f704f4553..ec2e25fa5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue @@ -80,7 +80,7 @@ export default { }, {}); this.formItems.forEach(item => { - if (item.validation.includes('JSON')) { + if (item.validation?.includes('JSON')) { hookPayload.settings[item.name] = JSON.parse( hookPayload.settings[item.name] ); @@ -117,7 +117,7 @@ export default {
e + ChatwootExceptionTracker.new(e, account: hook.account).capture_exception + Rails.logger.error "Error in CRM setup for hook ##{hook_id} (#{hook.app_id}): #{e.message}" + end + end + + private + + def create_setup_service(hook) + case hook.app_id + when 'leadsquared' + Crm::Leadsquared::SetupService.new(hook) + # Add cases for future CRMs here + # when 'hubspot' + # Crm::Hubspot::SetupService.new(hook) + # when 'zoho' + # Crm::Zoho::SetupService.new(hook) + else + Rails.logger.error "Unsupported CRM app_id: #{hook.app_id}" + nil + end + end +end diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb index 29c6dccfe..6bbf8355d 100644 --- a/app/jobs/hook_job.rb +++ b/app/jobs/hook_job.rb @@ -1,4 +1,6 @@ -class HookJob < ApplicationJob +class HookJob < MutexApplicationJob + retry_on LockAcquisitionError, wait: 3.seconds, attempts: 3 + queue_as :medium def perform(hook, event_name, event_data = {}) @@ -11,6 +13,8 @@ class HookJob < ApplicationJob process_dialogflow_integration(hook, event_name, event_data) when 'google_translate' google_translate_integration(hook, event_name, event_data) + when 'leadsquared' + process_leadsquared_integration_with_lock(hook, event_name, event_data) end rescue StandardError => e Rails.logger.error e @@ -41,4 +45,39 @@ class HookJob < ApplicationJob message = event_data[:message] Integrations::GoogleTranslate::DetectLanguageService.new(hook: hook, message: message).perform end + + def process_leadsquared_integration_with_lock(hook, event_name, event_data) + # Why do we need a mutex here? glad you asked + # When a new conversation is created. We get a contact created event, immediately followed by + # a contact updated event, and then a conversation created event. + # This all happens within milliseconds of each other. + # Now each of these subsequent event handlers need to have a leadsquared lead created and the contact to have the ID. + # If the lead data is not present, we try to search the API and create a new lead if it doesn't exist. + # This gives us a bad race condition that allows the API to create multiple leads for the same contact. + # + # This would have not been a problem if the email and phone number were unique identifiers for contacts at LeadSquared + # But then this is configurable in the LeadSquared settings, and may or may not be unique. + valid_event_names = ['contact.updated', 'conversation.created', 'conversation.resolved'] + return unless valid_event_names.include?(event_name) + return unless hook.feature_allowed? + + key = format(::Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: hook.id) + with_lock(key) do + process_leadsquared_integration(hook, event_name, event_data) + end + end + + def process_leadsquared_integration(hook, event_name, event_data) + # Process the event with the processor service + processor = Crm::Leadsquared::ProcessorService.new(hook) + + case event_name + when 'contact.updated' + processor.handle_contact(event_data[:contact]) + when 'conversation.created' + processor.handle_conversation_created(event_data[:conversation]) + when 'conversation.resolved' + processor.handle_conversation_resolved(event_data[:conversation]) + end + end end diff --git a/app/jobs/internal/process_stale_contacts_job.rb b/app/jobs/internal/process_stale_contacts_job.rb index eaf53be75..a40e73a01 100644 --- a/app/jobs/internal/process_stale_contacts_job.rb +++ b/app/jobs/internal/process_stale_contacts_job.rb @@ -8,6 +8,8 @@ class Internal::ProcessStaleContactsJob < ApplicationJob queue_as :scheduled_jobs def perform + return unless ChatwootApp.chatwoot_cloud? + Account.find_in_batches(batch_size: 100) do |accounts| accounts.each do |account| Rails.logger.info "Enqueuing RemoveStaleContactsJob for account #{account.id}" diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb index 1c5e91ffb..3360e23da 100644 --- a/app/listeners/hook_listener.rb +++ b/app/listeners/hook_listener.rb @@ -11,6 +11,29 @@ class HookListener < BaseListener execute_hooks(event, message) end + def contact_created(event) + contact = extract_contact_and_account(event)[0] + execute_account_hooks(event, contact.account, contact: contact) + end + + def contact_updated(event) + contact = extract_contact_and_account(event)[0] + execute_account_hooks(event, contact.account, contact: contact) + end + + def conversation_created(event) + conversation = extract_conversation_and_account(event)[0] + execute_account_hooks(event, conversation.account, conversation: conversation) + end + + def conversation_resolved(event) + conversation = extract_conversation_and_account(event)[0] + # Only trigger for status changes is resolved + return unless conversation.status == 'resolved' + + execute_account_hooks(event, conversation.account, conversation: conversation) + end + private def execute_hooks(event, message) @@ -22,4 +45,10 @@ class HookListener < BaseListener HookJob.perform_later(hook, event.name, message: message) end end + + def execute_account_hooks(event, account, event_data = {}) + account.hooks.account_hooks.find_each do |hook| + HookJob.perform_later(hook, event.name, event_data) + end + end end diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb index 1f88cdd4d..d4e563f81 100644 --- a/app/models/integrations/app.rb +++ b/app/models/integrations/app.rb @@ -18,6 +18,10 @@ class Integrations::App I18n.t("integration_apps.#{params[:i18n_key]}.description") end + def short_description + I18n.t("integration_apps.#{params[:i18n_key]}.short_description") + end + def logo params[:logo] end @@ -51,6 +55,8 @@ class Integrations::App GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present? when 'shopify' account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present? + when 'leadsquared' + account.feature_enabled?('crm_integration') else true end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index 61a19874c..e7300b525 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -19,11 +19,13 @@ class Integrations::Hook < ApplicationRecord attr_readonly :app_id, :account_id, :inbox_id, :hook_type before_validation :ensure_hook_type + after_create :trigger_setup_if_crm validates :account_id, presence: true validates :app_id, presence: true validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' } validate :validate_settings_json_schema + validate :ensure_feature_enabled validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } # TODO: This seems to be only used for slack at the moment @@ -36,6 +38,9 @@ class Integrations::Hook < ApplicationRecord enum hook_type: { account: 0, inbox: 1 } + scope :account_hooks, -> { where(hook_type: 'account') } + scope :inbox_hooks, -> { where(hook_type: 'inbox') } + def app @app ||= Integrations::App.find(id: app_id) end @@ -61,8 +66,21 @@ class Integrations::Hook < ApplicationRecord end end + def feature_allowed? + return true if app.blank? + + flag = app.params[:feature_flag] + return true unless flag + + account.feature_enabled?(flag) + end + private + def ensure_feature_enabled + errors.add(:feature_flag, 'Feature not enabled') unless feature_allowed? + end + def ensure_hook_type self.hook_type = app.params[:hook_type] if app.present? end @@ -72,4 +90,17 @@ class Integrations::Hook < ApplicationRecord errors.add(:settings, ': Invalid settings data') unless JSONSchemer.schema(app.params[:settings_json_schema]).valid?(settings) end + + def trigger_setup_if_crm + # we need setup services to create data prerequisite to functioning of the integration + # in case of Leadsquared, we need to create a custom activity type for capturing conversations and transcripts + # https://apidocs.leadsquared.com/create-new-activity-type-api/ + return unless crm_integration? + + ::Crm::SetupJob.perform_later(id) + end + + def crm_integration? + %w[leadsquared].include?(app_id) + end end diff --git a/app/services/crm/base_processor_service.rb b/app/services/crm/base_processor_service.rb new file mode 100644 index 000000000..305a09014 --- /dev/null +++ b/app/services/crm/base_processor_service.rb @@ -0,0 +1,92 @@ +class Crm::BaseProcessorService + def initialize(hook) + @hook = hook + @account = hook.account + end + + # Class method to be overridden by subclasses + def self.crm_name + raise NotImplementedError, 'Subclasses must define self.crm_name' + end + + # Instance method that calls the class method + def crm_name + self.class.crm_name + end + + def process_event(event_name, event_data) + case event_name + when 'contact.created' + handle_contact_created(event_data) + when 'contact.updated' + handle_contact_updated(event_data) + when 'conversation.created' + handle_conversation_created(event_data) + when 'conversation.updated' + handle_conversation_updated(event_data) + else + { success: false, error: "Unsupported event: #{event_name}" } + end + rescue StandardError => e + Rails.logger.error "#{crm_name} Processor Error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + { success: false, error: e.message } + end + + # Abstract methods that subclasses must implement + def handle_contact_created(contact) + raise NotImplementedError, 'Subclasses must implement #handle_contact_created' + end + + def handle_contact_updated(contact) + raise NotImplementedError, 'Subclasses must implement #handle_contact_updated' + end + + def handle_conversation_created(conversation) + raise NotImplementedError, 'Subclasses must implement #handle_conversation_created' + end + + def handle_conversation_resolved(conversation) + raise NotImplementedError, 'Subclasses must implement #handle_conversation_resolved' + end + + # Common helper methods for all CRM processors + + protected + + def identifiable_contact?(contact) + has_social_profile = contact.additional_attributes['social_profiles'].present? + contact.present? && (contact.email.present? || contact.phone_number.present? || has_social_profile) + end + + def get_external_id(contact) + return nil if contact.additional_attributes.blank? + return nil if contact.additional_attributes['external'].blank? + + contact.additional_attributes.dig('external', "#{crm_name}_id") + end + + def store_external_id(contact, external_id) + # Initialize additional_attributes if it's nil + contact.additional_attributes = {} if contact.additional_attributes.nil? + + # Initialize external hash if it doesn't exist + contact.additional_attributes['external'] = {} if contact.additional_attributes['external'].blank? + + # Store the external ID + contact.additional_attributes['external']["#{crm_name}_id"] = external_id + contact.save! + end + + def store_conversation_metadata(conversation, metadata) + # Initialize additional_attributes if it's nil + conversation.additional_attributes = {} if conversation.additional_attributes.nil? + + # Initialize CRM-specific hash in additional_attributes + conversation.additional_attributes[crm_name] = {} if conversation.additional_attributes[crm_name].blank? + + # Store the metadata + conversation.additional_attributes[crm_name].merge!(metadata) + conversation.save! + end +end diff --git a/app/services/crm/leadsquared/api/activity_client.rb b/app/services/crm/leadsquared/api/activity_client.rb new file mode 100644 index 000000000..b9255ca8c --- /dev/null +++ b/app/services/crm/leadsquared/api/activity_client.rb @@ -0,0 +1,36 @@ +class Crm::Leadsquared::Api::ActivityClient < Crm::Leadsquared::Api::BaseClient + # https://apidocs.leadsquared.com/post-an-activity-to-lead/#api + def post_activity(prospect_id, activity_event, activity_note) + raise ArgumentError, 'Prospect ID is required' if prospect_id.blank? + raise ArgumentError, 'Activity event code is required' if activity_event.blank? + + path = 'ProspectActivity.svc/Create' + + body = { + 'RelatedProspectId' => prospect_id, + 'ActivityEvent' => activity_event, + 'ActivityNote' => activity_note + } + + response = post(path, {}, body) + response['Message']['Id'] + end + + def create_activity_type(name:, score:, direction: 0) + raise ArgumentError, 'Activity name is required' if name.blank? + + path = 'ProspectActivity.svc/CreateType' + body = { + 'ActivityEventName' => name, + 'Score' => score.to_i, + 'Direction' => direction.to_i + } + + response = post(path, {}, body) + response['Message']['Id'] + end + + def fetch_activity_types + get('ProspectActivity.svc/ActivityTypes.Get') + end +end diff --git a/app/services/crm/leadsquared/api/base_client.rb b/app/services/crm/leadsquared/api/base_client.rb new file mode 100644 index 000000000..09bf9f202 --- /dev/null +++ b/app/services/crm/leadsquared/api/base_client.rb @@ -0,0 +1,84 @@ +class Crm::Leadsquared::Api::BaseClient + include HTTParty + + class ApiError < StandardError + attr_reader :code, :response + + def initialize(message = nil, code = nil, response = nil) + @code = code + @response = response + super(message) + end + end + + def initialize(access_key, secret_key, endpoint_url) + @access_key = access_key + @secret_key = secret_key + @base_uri = endpoint_url + end + + def get(path, params = {}) + full_url = URI.join(@base_uri, path).to_s + + options = { + query: params, + headers: headers + } + + response = self.class.get(full_url, options) + handle_response(response) + end + + def post(path, params = {}, body = {}) + full_url = URI.join(@base_uri, path).to_s + + options = { + query: params, + headers: headers + } + + options[:body] = body.to_json if body.present? + + response = self.class.post(full_url, options) + handle_response(response) + end + + private + + def headers + { + 'Content-Type': 'application/json', + 'x-LSQ-AccessKey': @access_key, + 'x-LSQ-SecretKey': @secret_key + } + end + + def handle_response(response) + case response.code + when 200..299 + handle_success(response) + else + error_message = "LeadSquared API error: #{response.code} - #{response.body}" + Rails.logger.error error_message + raise ApiError.new(error_message, response.code, response) + end + end + + def handle_success(response) + parse_response(response) + rescue JSON::ParserError, TypeError => e + error_message = "Failed to parse LeadSquared API response: #{e.message}" + raise ApiError.new(error_message, response.code, response) + end + + def parse_response(response) + body = response.parsed_response + + if body.is_a?(Hash) && body['Status'] == 'Error' + error_message = body['ExceptionMessage'] || 'Unknown API error' + raise ApiError.new(error_message, response.code, response) + else + body + end + end +end diff --git a/app/services/crm/leadsquared/api/lead_client.rb b/app/services/crm/leadsquared/api/lead_client.rb new file mode 100644 index 000000000..21e7d6937 --- /dev/null +++ b/app/services/crm/leadsquared/api/lead_client.rb @@ -0,0 +1,50 @@ +class Crm::Leadsquared::Api::LeadClient < Crm::Leadsquared::Api::BaseClient + # https://apidocs.leadsquared.com/quick-search/#api + def search_lead(key) + raise ArgumentError, 'Search key is required' if key.blank? + + path = 'LeadManagement.svc/Leads.GetByQuickSearch' + params = { key: key } + + get(path, params) + end + + # https://apidocs.leadsquared.com/create-or-update/#api + # The email address and phone fields are used as the default search criteria. + # If none of these match with an existing lead, a new lead will be created. + # We can pass the "SearchBy" attribute in the JSON body to search by a particular parameter, however + # we don't need this capability at the moment + def create_or_update_lead(lead_data) + raise ArgumentError, 'Lead data is required' if lead_data.blank? + + path = 'LeadManagement.svc/Lead.CreateOrUpdate' + + formatted_data = format_lead_data(lead_data) + response = post(path, {}, formatted_data) + + response['Message']['Id'] + end + + def update_lead(lead_data, lead_id) + raise ArgumentError, 'Lead ID is required' if lead_id.blank? + raise ArgumentError, 'Lead data is required' if lead_data.blank? + + path = "LeadManagement.svc/Lead.Update?leadId=#{lead_id}" + formatted_data = format_lead_data(lead_data) + + response = post(path, {}, formatted_data) + + response['Message']['AffectedRows'] + end + + private + + def format_lead_data(lead_data) + lead_data.map do |key, value| + { + 'Attribute' => key, + 'Value' => value + } + end + end +end diff --git a/app/services/crm/leadsquared/lead_finder_service.rb b/app/services/crm/leadsquared/lead_finder_service.rb new file mode 100644 index 000000000..cf3014099 --- /dev/null +++ b/app/services/crm/leadsquared/lead_finder_service.rb @@ -0,0 +1,59 @@ +class Crm::Leadsquared::LeadFinderService + def initialize(lead_client) + @lead_client = lead_client + end + + def find_or_create(contact) + lead_id = get_stored_id(contact) + return lead_id if lead_id.present? + + lead_id = find_by_contact(contact) + return lead_id if lead_id.present? + + create_lead(contact) + end + + private + + def find_by_contact(contact) + lead_id = find_by_email(contact) + lead_id = find_by_phone_number(contact) if lead_id.blank? + + lead_id + end + + def find_by_email(contact) + return if contact.email.blank? + + search_by_field(contact.email) + end + + def find_by_phone_number(contact) + return if contact.phone_number.blank? + + search_by_field(contact.phone_number) + end + + def search_by_field(value) + leads = @lead_client.search_lead(value) + return nil unless leads.is_a?(Array) + + leads.first['ProspectID'] if leads.any? + end + + def create_lead(contact) + lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact) + lead_id = @lead_client.create_or_update_lead(lead_data) + + raise StandardError, 'Failed to create lead - no ID returned' if lead_id.blank? + + lead_id + end + + def get_stored_id(contact) + return nil if contact.additional_attributes.blank? + return nil if contact.additional_attributes['external'].blank? + + contact.additional_attributes.dig('external', 'leadsquared_id') + end +end diff --git a/app/services/crm/leadsquared/mappers/contact_mapper.rb b/app/services/crm/leadsquared/mappers/contact_mapper.rb new file mode 100644 index 000000000..0196116bb --- /dev/null +++ b/app/services/crm/leadsquared/mappers/contact_mapper.rb @@ -0,0 +1,35 @@ +class Crm::Leadsquared::Mappers::ContactMapper + def self.map(contact) + new(contact).map + end + + def initialize(contact) + @contact = contact + end + + def map + base_attributes + end + + private + + attr_reader :contact + + def base_attributes + { + 'FirstName' => contact.name.presence, + 'LastName' => contact.last_name.presence, + 'EmailAddress' => contact.email.presence, + 'Mobile' => contact.phone_number.presence, + 'Source' => brand_name + }.compact + end + + def brand_name + ::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot' + end + + def brand_name_without_spaces + brand_name.gsub(/\s+/, '') + end +end diff --git a/app/services/crm/leadsquared/mappers/conversation_mapper.rb b/app/services/crm/leadsquared/mappers/conversation_mapper.rb new file mode 100644 index 000000000..97a148435 --- /dev/null +++ b/app/services/crm/leadsquared/mappers/conversation_mapper.rb @@ -0,0 +1,108 @@ +class Crm::Leadsquared::Mappers::ConversationMapper + include ::Rails.application.routes.url_helpers + + # https://help.leadsquared.com/what-is-the-maximum-character-length-supported-for-lead-and-activity-fields/ + # the rest of the body of the note is around 200 chars + # so this limits it + ACTIVITY_NOTE_MAX_SIZE = 1800 + + def self.map_conversation_activity(conversation) + new(conversation).conversation_activity + end + + def self.map_transcript_activity(conversation, messages = nil) + new(conversation, messages).transcript_activity + end + + def initialize(conversation, messages = nil) + @conversation = conversation + @messages = messages + end + + def conversation_activity + I18n.t('crm.created_activity', + brand_name: brand_name, + channel_info: conversation.inbox.name, + formatted_creation_time: formatted_creation_time, + display_id: conversation.display_id, + url: conversation_url) + end + + def transcript_activity + return I18n.t('crm.no_message') if transcript_messages.empty? + + I18n.t('crm.transcript_activity', + brand_name: brand_name, + channel_info: conversation.inbox.name, + display_id: conversation.display_id, + url: conversation_url, + format_messages: format_messages) + end + + private + + attr_reader :conversation, :messages + + def formatted_creation_time + conversation.created_at.strftime('%Y-%m-%d %H:%M:%S') + end + + def transcript_messages + @transcript_messages ||= messages || conversation.messages.chat.select(&:conversation_transcriptable?) + end + + def format_messages + selected_messages = [] + separator = "\n\n" + current_length = 0 + + # Reverse the messages to have latest on top + transcript_messages.reverse_each do |message| + formatted_message = format_message(message) + required_length = formatted_message.length + separator.length # the last one does not need to account for separator, but we add it anyway + + break unless (current_length + required_length) <= ACTIVITY_NOTE_MAX_SIZE + + selected_messages << formatted_message + current_length += required_length + end + + selected_messages.join(separator) + end + + def format_message(message) + <<~MESSAGE.strip + [#{message_time(message)}] #{sender_name(message)}: #{message_content(message)}#{attachment_info(message)} + MESSAGE + end + + def message_time(message) + # TODO: Figure out what timezone to send the time in + message.created_at.strftime('%Y-%m-%d %H:%M') + end + + def sender_name(message) + return 'System' if message.sender.nil? + + message.sender.name.presence || "#{message.sender_type} #{message.sender_id}" + end + + def message_content(message) + message.content.presence || I18n.t('crm.no_content') + end + + def attachment_info(message) + return '' unless message.attachments.any? + + attachments = message.attachments.map { |a| I18n.t('crm.attachment', type: a.file_type) }.join(', ') + "\n#{attachments}" + end + + def conversation_url + app_account_conversation_url(account_id: conversation.account.id, id: conversation.display_id) + end + + def brand_name + ::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'] || 'Chatwoot' + end +end diff --git a/app/services/crm/leadsquared/processor_service.rb b/app/services/crm/leadsquared/processor_service.rb new file mode 100644 index 000000000..aedea51d7 --- /dev/null +++ b/app/services/crm/leadsquared/processor_service.rb @@ -0,0 +1,121 @@ +class Crm::Leadsquared::ProcessorService < Crm::BaseProcessorService + def self.crm_name + 'leadsquared' + end + + def initialize(hook) + super(hook) + @access_key = hook.settings['access_key'] + @secret_key = hook.settings['secret_key'] + @endpoint_url = hook.settings['endpoint_url'] + + @allow_transcript = hook.settings['enable_transcript_activity'] + @allow_conversation = hook.settings['enable_conversation_activity'] + + # Initialize API clients + @lead_client = Crm::Leadsquared::Api::LeadClient.new(@access_key, @secret_key, @endpoint_url) + @activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, @endpoint_url) + @lead_finder = Crm::Leadsquared::LeadFinderService.new(@lead_client) + end + + def handle_contact(contact) + contact.reload + unless identifiable_contact?(contact) + Rails.logger.info("Contact not identifiable. Skipping handle_contact for ##{contact.id}") + return + end + + stored_lead_id = get_external_id(contact) + create_or_update_lead(contact, stored_lead_id) + end + + def handle_conversation_created(conversation) + return unless @allow_conversation + + create_conversation_activity( + conversation: conversation, + activity_type: 'conversation', + activity_code_key: 'conversation_activity_code', + metadata_key: 'created_activity_id', + activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_conversation_activity(conversation) + ) + end + + def handle_conversation_resolved(conversation) + return unless @allow_transcript + return unless conversation.status == 'resolved' + + create_conversation_activity( + conversation: conversation, + activity_type: 'transcript', + activity_code_key: 'transcript_activity_code', + metadata_key: 'transcript_activity_id', + activity_note: Crm::Leadsquared::Mappers::ConversationMapper.map_transcript_activity(conversation) + ) + end + + private + + def create_or_update_lead(contact, lead_id) + lead_data = Crm::Leadsquared::Mappers::ContactMapper.map(contact) + + # Why can't we use create_or_update_lead here? + # In LeadSquared, it's possible that the email field + # may not be marked as unique, same with the phone number field + # So we just use the update API if we already have a lead ID + if lead_id.present? + @lead_client.update_lead(lead_data, lead_id) + else + new_lead_id = @lead_client.create_or_update_lead(lead_data) + store_external_id(contact, new_lead_id) + end + rescue Crm::Leadsquared::Api::BaseClient::ApiError => e + ChatwootExceptionTracker.new(e, account: @account).capture_exception + Rails.logger.error "LeadSquared API error processing contact: #{e.message}" + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @account).capture_exception + Rails.logger.error "Error processing contact in LeadSquared: #{e.message}" + end + + def create_conversation_activity(conversation:, activity_type:, activity_code_key:, metadata_key:, activity_note:) + lead_id = get_lead_id(conversation.contact) + return if lead_id.blank? + + activity_code = get_activity_code(activity_code_key) + activity_id = @activity_client.post_activity(lead_id, activity_code, activity_note) + return if activity_id.blank? + + metadata = {} + metadata[metadata_key] = activity_id + store_conversation_metadata(conversation, metadata) + rescue Crm::Leadsquared::Api::BaseClient::ApiError => e + ChatwootExceptionTracker.new(e, account: @account).capture_exception + Rails.logger.error "LeadSquared API error in #{activity_type} activity: #{e.message}" + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @account).capture_exception + Rails.logger.error "Error creating #{activity_type} activity in LeadSquared: #{e.message}" + end + + def get_activity_code(key) + activity_code = @hook.settings[key] + raise StandardError, "LeadSquared #{key} activity code not found for hook ##{@hook.id}." if activity_code.blank? + + activity_code + end + + def get_lead_id(contact) + contact.reload # reload to ensure all the attributes are up-to-date + + unless identifiable_contact?(contact) + Rails.logger.info("Contact not identifiable. Skipping activity for ##{contact.id}") + nil + end + + lead_id = @lead_finder.find_or_create(contact) + return nil if lead_id.blank? + + store_external_id(contact, lead_id) unless get_external_id(contact) + + lead_id + end +end diff --git a/app/services/crm/leadsquared/setup_service.rb b/app/services/crm/leadsquared/setup_service.rb new file mode 100644 index 000000000..956ff1a10 --- /dev/null +++ b/app/services/crm/leadsquared/setup_service.rb @@ -0,0 +1,108 @@ +class Crm::Leadsquared::SetupService + def initialize(hook) + @hook = hook + credentials = @hook.settings + + @access_key = credentials['access_key'] + @secret_key = credentials['secret_key'] + + @client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/') + @activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, 'https://api.leadsquared.com/v2/') + end + + def setup + setup_endpoint + setup_activity + rescue Crm::Leadsquared::Api::BaseClient::ApiError => e + ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception + Rails.logger.error "LeadSquared API error in setup: #{e.message}" + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @hook.account).capture_exception + Rails.logger.error "Error during LeadSquared setup: #{e.message}" + end + + def setup_endpoint + response = @client.get('Authentication.svc/UserByAccessKey.Get') + endpoint_host = response['LSQCommonServiceURLs']['api'] + app_host = response['LSQCommonServiceURLs']['app'] + + endpoint_url = "https://#{endpoint_host}/v2/" + app_url = "https://#{app_host}/" + + update_hook_settings({ :endpoint_url => endpoint_url, :app_url => app_url }) + + # replace the clients + @client = Crm::Leadsquared::Api::BaseClient.new(@access_key, @secret_key, endpoint_url) + @activity_client = Crm::Leadsquared::Api::ActivityClient.new(@access_key, @secret_key, endpoint_url) + end + + private + + def setup_activity + existing_types = @activity_client.fetch_activity_types + return if existing_types.blank? + + activity_codes = setup_activity_types(existing_types) + return if activity_codes.blank? + + update_hook_settings(activity_codes) + + activity_codes + end + + def setup_activity_types(existing_types) + activity_codes = {} + + activity_types.each do |activity_type| + activity_id = find_or_create_activity_type(activity_type, existing_types) + + if activity_id.present? + activity_codes[activity_type[:setting_key]] = activity_id.to_i + else + Rails.logger.error "Failed to find or create activity type: #{activity_type[:name]}" + end + end + + activity_codes + end + + def find_or_create_activity_type(activity_type, existing_types) + existing = existing_types.find { |t| t['ActivityEventName'] == activity_type[:name] } + + if existing + existing['ActivityEvent'].to_i + else + @activity_client.create_activity_type( + name: activity_type[:name], + score: activity_type[:score], + direction: activity_type[:direction] + ) + end + end + + def update_hook_settings(params) + @hook.settings = @hook.settings.merge(params) + @hook.save! + end + + def activity_types + [ + { + name: "#{brand_name} Conversation Started", + score: @hook.settings['conversation_activity_score'].to_i || 0, + direction: 0, + setting_key: 'conversation_activity_code' + }, + { + name: "#{brand_name} Conversation Transcript", + score: @hook.settings['transcript_activity_score'].to_i || 0, + direction: 0, + setting_key: 'transcript_activity_code' + } + ].freeze + end + + def brand_name + ::GlobalConfig.get('BRAND_NAME')['BRAND_NAME'].presence || 'Chatwoot' + end +end diff --git a/app/services/facebook/send_on_facebook_service.rb b/app/services/facebook/send_on_facebook_service.rb index 5dde19c5a..5e9212edd 100644 --- a/app/services/facebook/send_on_facebook_service.rb +++ b/app/services/facebook/send_on_facebook_service.rb @@ -16,7 +16,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService rescue Facebook::Messenger::FacebookError => e # TODO : handle specific errors or else page will get disconnected handle_facebook_error(e) - message.update!(status: :failed, external_error: e.message) + Messages::StatusUpdateService.new(message, 'failed', e.message).perform end def send_message_to_facebook(delivery_params) @@ -24,7 +24,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService return if parsed_result.nil? if parsed_result['error'].present? - message.update!(status: :failed, external_error: external_error(parsed_result)) + Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_result)).perform Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{parsed_result}" end @@ -35,11 +35,11 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id) JSON.parse(result) rescue JSON::ParserError - message.update!(status: :failed, external_error: 'Facebook was unable to process this request') + Messages::StatusUpdateService.new(message, 'failed', 'Facebook was unable to process this request').perform Rails.logger.error "Facebook::SendOnFacebookService: Error parsing JSON response from Facebook : Page - #{channel.page_id} : #{result}" nil rescue Net::OpenTimeout - message.update!(status: :failed, external_error: 'Request timed out, please try again later') + Messages::StatusUpdateService.new(message, 'failed', 'Request timed out, please try again later').perform Rails.logger.error "Facebook::SendOnFacebookService: Timeout error sending message to Facebook : Page - #{channel.page_id}" nil end diff --git a/app/services/instagram/base_send_service.rb b/app/services/instagram/base_send_service.rb index dee41c23f..ff5f9216e 100644 --- a/app/services/instagram/base_send_service.rb +++ b/app/services/instagram/base_send_service.rb @@ -61,7 +61,7 @@ class Instagram::BaseSendService < Base::SendOnChannelService else external_error = external_error(parsed_response) Rails.logger.error("Instagram response: #{external_error} : #{message_content}") - message.update!(status: :failed, external_error: external_error) + Messages::StatusUpdateService.new(message, 'failed', external_error).perform nil end end diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb index f8d704128..03ebe0ab7 100644 --- a/app/services/line/send_on_line_service.rb +++ b/app/services/line/send_on_line_service.rb @@ -14,10 +14,10 @@ class Line::SendOnLineService < Base::SendOnChannelService if response.code == '200' # If the request is successful, update the message status to delivered - message.update!(status: :delivered) + Messages::StatusUpdateService.new(message, 'delivered').perform else # If the request is not successful, update the message status to failed and save the external error - message.update!(status: :failed, external_error: external_error(parsed_json)) + Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform end end diff --git a/app/services/messages/status_update_service.rb b/app/services/messages/status_update_service.rb new file mode 100644 index 000000000..4868a201e --- /dev/null +++ b/app/services/messages/status_update_service.rb @@ -0,0 +1,34 @@ +class Messages::StatusUpdateService + attr_reader :message, :status, :external_error + + def initialize(message, status, external_error = nil) + @message = message + @status = status + @external_error = external_error + end + + def perform + return false unless valid_status_transition? + + update_message_status + end + + private + + def update_message_status + # Update status and set external_error only when failed + message.update!( + status: status, + external_error: (status == 'failed' ? external_error : nil) + ) + end + + def valid_status_transition? + return false unless Message.statuses.key?(status) + + # Don't allow changing from 'read' to 'delivered' + return false if message.read? && status == 'delivered' + + true + end +end diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb index 3fc420bb2..5bd262759 100644 --- a/app/services/twilio/send_on_twilio_service.rb +++ b/app/services/twilio/send_on_twilio_service.rb @@ -9,7 +9,7 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService begin twilio_message = channel.send_message(**message_params) rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e - message.update!(status: :failed, external_error: e.message) + Messages::StatusUpdateService.new(message, 'failed', e.message).perform end message.update!(source_id: twilio_message.sid) if twilio_message end diff --git a/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder new file mode 100644 index 000000000..3798b6c1f --- /dev/null +++ b/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/message', message: @message diff --git a/app/views/api/v1/models/_app.json.jbuilder b/app/views/api/v1/models/_app.json.jbuilder index 9933c917a..42539a093 100644 --- a/app/views/api/v1/models/_app.json.jbuilder +++ b/app/views/api/v1/models/_app.json.jbuilder @@ -1,6 +1,7 @@ json.id resource.id json.name resource.name json.description resource.description +json.short_description resource.short_description.presence json.enabled resource.enabled?(@current_account) if Current.account_user&.administrator? diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb index 20cc70d5e..14b668637 100644 --- a/app/workers/email_reply_worker.rb +++ b/app/workers/email_reply_worker.rb @@ -11,6 +11,6 @@ class EmailReplyWorker ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now rescue StandardError => e ChatwootExceptionTracker.new(e, account: message.account).capture_exception - message.update!(status: :failed, external_error: e.message) + Messages::StatusUpdateService.new(message, 'failed', e.message).perform end end diff --git a/config/application.rb b/config/application.rb index fffb740a5..aab7f0e22 100644 --- a/config/application.rb +++ b/config/application.rb @@ -44,6 +44,8 @@ module Chatwoot # rubocop:disable Rails/FilePath config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"] # rubocop:enable Rails/FilePath + # Add enterprise views to the view paths + config.paths['app/views'].unshift('enterprise/app/views') # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers diff --git a/config/features.yml b/config/features.yml index ce26092b6..131456c72 100644 --- a/config/features.yml +++ b/config/features.yml @@ -166,3 +166,6 @@ - name: channel_instagram display_name: Instagram Channel enabled: true +- name: crm_integration + display_name: CRM Integration + enabled: false \ No newline at end of file diff --git a/config/integration/apps.yml b/config/integration/apps.yml index b4d0d8394..10ba2e056 100644 --- a/config/integration/apps.yml +++ b/config/integration/apps.yml @@ -4,6 +4,7 @@ # i18n_key: the key under which translations for the integration is placed in en.yml # action: if integration requires external redirect url # hook_type: ( account / inbox ) +# feature_flag: (string) feature flag to enable/disable the integration # allow_multiple_hooks: whether multiple hooks can be created for the integration # settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/) # settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/) @@ -186,3 +187,79 @@ shopify: i18n_key: shopify hook_type: account allow_multiple_hooks: false + +leadsquared: + id: leadsquared + feature_flag: crm_integration + logo: leadsquared.png + i18n_key: leadsquared + action: /leadsquared + hook_type: account + allow_multiple_hooks: false + settings_json_schema: + { + 'type': 'object', + 'properties': + { + 'access_key': { 'type': 'string' }, + 'secret_key': { 'type': 'string' }, + 'endpoint_url': { 'type': 'string' }, + 'app_url': { 'type': 'string' }, + 'enable_conversation_activity': { 'type': 'boolean' }, + 'enable_transcript_activity': { 'type': 'boolean' }, + 'conversation_activity_score': { 'type': 'string' }, + 'transcript_activity_score': { 'type': 'string' }, + 'conversation_activity_code': { 'type': 'integer' }, + 'transcript_activity_code': { 'type': 'integer' }, + }, + 'required': ['access_key', 'secret_key'], + 'additionalProperties': false, + } + settings_form_schema: + [ + { + 'label': 'Access Key', + 'type': 'text', + 'name': 'access_key', + 'validation': 'required', + }, + { + 'label': 'Secret Key', + 'type': 'text', + 'name': 'secret_key', + 'validation': 'required', + }, + { + 'label': 'Push Conversation Activity', + 'type': 'checkbox', + 'name': 'enable_conversation_activity', + 'help': 'Enable this option to push an activity when a conversation is created', + }, + { + 'label': 'Conversation Activity Score', + 'type': 'number', + 'name': 'conversation_activity_score', + 'help': 'Score to assign to the conversation created activity, default is 0', + }, + { + 'label': 'Push Transcript Activity', + 'type': 'checkbox', + 'name': 'enable_transcript_activity', + 'help': 'Enable this option to push an activity when a transcript is created', + }, + { + 'label': 'Transcript Activity Score', + 'type': 'number', + 'name': 'transcript_activity_score', + 'help': 'Score to assign to the conversation transcript activity, default is 0', + }, + ] + visible_properties: + [ + 'access_key', + 'endpoint_url', + 'enable_conversation_activity', + 'enable_transcript_activity', + 'conversation_activity_score', + 'transcript_activity_score', + ] diff --git a/config/locales/am.yml b/config/locales/am.yml index 905e236d7..289179ac7 100644 --- a/config/locales/am.yml +++ b/config/locales/am.yml @@ -53,8 +53,6 @@ am: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ am: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ am: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ar.yml b/config/locales/ar.yml index f86f6a30d..9418da59e 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -53,8 +53,6 @@ ar: invalid_message_type: 'نوع الرسالة غير صالح. الإجراء غير مسموح به' slack: invalid_channel_id: 'قناة Slack غير صحيحة. الرجاء المحاولة مرة أخرى' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: الرجاء التحقق من اتصال الشبكة وعنوان IMAP ثم حاول مرة أخرى. @@ -222,6 +220,10 @@ ar: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -298,3 +300,25 @@ ar: few: '%{count} ثواني' many: '%{count} ثانية' other: '%{count} ثانية' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[لا يوجد محتوى]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/az.yml b/config/locales/az.yml index 0a178161c..2dbf3f936 100644 --- a/config/locales/az.yml +++ b/config/locales/az.yml @@ -53,8 +53,6 @@ az: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ az: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ az: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/bg.yml b/config/locales/bg.yml index 98ae8f3a9..02752863c 100644 --- a/config/locales/bg.yml +++ b/config/locales/bg.yml @@ -53,8 +53,6 @@ bg: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ bg: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ bg: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ca.yml b/config/locales/ca.yml index f1a1bcfe1..67aa72fa9 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -53,8 +53,6 @@ ca: invalid_message_type: 'Tipus de missatge no vàlid. Acció no permesa' slack: invalid_channel_id: 'Canal slack no vàlid. Torna-ho a provar' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Comprova la connexió de xarxa, l'adreça IMAP i torna-ho a provar. @@ -222,6 +220,10 @@ ca: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ca: seconds: one: '%{count} segon' other: '%{count} segons' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Sense contingut]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/cs.yml b/config/locales/cs.yml index fd687b472..3b563c9f8 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -53,8 +53,6 @@ cs: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ cs: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ cs: few: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/da.yml b/config/locales/da.yml index f7186445a..ae82593cf 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -53,8 +53,6 @@ da: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Tjek venligst netværksforbindelsen, IMAP-adressen og prøv igen. @@ -222,6 +220,10 @@ da: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ da: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/de.yml b/config/locales/de.yml index 465519e5e..9ed63cd17 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -53,8 +53,6 @@ de: invalid_message_type: 'Ungültiger Nachrichtentyp. Aktion nicht erlaubt' slack: invalid_channel_id: 'Ungültiger Slack Channel. Bitte erneut versuchen' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Bitte überprüfen Sie die Netzwerkverbindung, die IMAP-Adresse und versuchen Sie es erneut. @@ -222,6 +220,10 @@ de: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ de: seconds: one: '%{count} Sekunde' other: '%{count} Sekunden' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Kein Inhalt]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/el.yml b/config/locales/el.yml index f41b53c30..7a1efc9b0 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -53,8 +53,6 @@ el: invalid_message_type: 'Μη έγκυρος τύπος μηνύματος. Δεν επιτρέπεται η ενέργεια' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Παρακαλώ ελέγξτε τη σύνδεση δικτύου, τη διεύθυνση IMAP και προσπαθήστε ξανά. @@ -222,6 +220,10 @@ el: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ el: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/en.yml b/config/locales/en.yml index b165f2aa3..14deb0257 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -241,6 +241,10 @@ en: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -303,3 +307,23 @@ en: other: '%{count} seconds' automation: system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/es.yml b/config/locales/es.yml index 711fc238c..ffbe568ea 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -53,8 +53,6 @@ es: invalid_message_type: 'Tipo de mensaje inválido. Acción no permitida' slack: invalid_channel_id: 'Canal de slack inválido. Por favor, inténtalo de nuevo' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Verifique la conexión de red, la dirección IMAP y vuelva a intentarlo. @@ -222,6 +220,10 @@ es: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Conecte un asistente a esta bandeja de entrada para utilizar Copilot' copilot_limit: 'Te quedaste sin créditos de Copilot. Puedes comprar más créditos desde la sección de facturación.' @@ -282,3 +284,25 @@ es: seconds: one: '%{count} segundo' other: '%{count} segundos' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Sin contenido]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/fa.yml b/config/locales/fa.yml index 1e2b1ba44..0dda83588 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -53,8 +53,6 @@ fa: invalid_message_type: 'نوع پیام نامعتبر است. اقدام مجاز نیست' slack: invalid_channel_id: 'کانال اسلک نامعتبر است. لطفا دوباره تلاش کنید' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: لطفا اتصال شبکه، آدرس IMAP را بررسی کنید و دوباره امتحان کنید. @@ -222,6 +220,10 @@ fa: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ fa: seconds: one: '%{count} ثانیه' other: '%{count} ثانیه' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[فاقد محتوا]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/fi.yml b/config/locales/fi.yml index 25e6193bb..4fd442da1 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -53,8 +53,6 @@ fi: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ fi: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ fi: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 1b7846cf5..6fa2443b9 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -53,8 +53,6 @@ fr: invalid_message_type: 'Type de message invalide. Action non autorisée' slack: invalid_channel_id: 'Canal Slack invalide. Veuillez réessayer' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Veuillez vérifier la connexion, l'adresse IMAP et réessayez. @@ -222,6 +220,10 @@ fr: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ fr: seconds: one: '%{count} seconde' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/he.yml b/config/locales/he.yml index dee5aa5e1..6ef731fd1 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -53,8 +53,6 @@ he: invalid_message_type: 'סוג הודעה לא חוקי. פעולה אסורה' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ he: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ he: two: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/hi.yml b/config/locales/hi.yml index 3fb3e1708..abe8dd3d8 100644 --- a/config/locales/hi.yml +++ b/config/locales/hi.yml @@ -53,8 +53,6 @@ hi: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ hi: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ hi: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/hr.yml b/config/locales/hr.yml index b285cda29..8afb9fe78 100644 --- a/config/locales/hr.yml +++ b/config/locales/hr.yml @@ -53,8 +53,6 @@ hr: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ hr: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -286,3 +288,25 @@ hr: one: '%{count} second' few: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/hu.yml b/config/locales/hu.yml index 9d6569f25..444d80f00 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -53,8 +53,6 @@ hu: invalid_message_type: 'Hibás üzenet típus. Kérés elutasítva' slack: invalid_channel_id: 'Érvénytelen Slack csatorna. Kérjük, próbálja újra' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Kérlek ellenőrizd a hálózati kapcsolatot, az IMAP címet, majd próbáld újra. @@ -222,6 +220,10 @@ hu: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ hu: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/hy.yml b/config/locales/hy.yml index 15dbf2328..6dfa38a67 100644 --- a/config/locales/hy.yml +++ b/config/locales/hy.yml @@ -53,8 +53,6 @@ hy: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ hy: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ hy: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/id.yml b/config/locales/id.yml index 12bb26056..9bc01872c 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -53,8 +53,6 @@ id: invalid_message_type: 'Jenis pesan tidak valid. Tindakan tidak diizinkan' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Periksa sambungan jaringan, alamat IMAP, dan coba lagi. @@ -222,6 +220,10 @@ id: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -278,3 +280,25 @@ id: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/is.yml b/config/locales/is.yml index 1f7a4abfb..d36eeca0d 100644 --- a/config/locales/is.yml +++ b/config/locales/is.yml @@ -53,8 +53,6 @@ is: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Athugaðu nettenginguna, IMAP vistfangið og reyndu aftur. @@ -222,6 +220,10 @@ is: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ is: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/it.yml b/config/locales/it.yml index 94f7e09aa..998375e4a 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -53,8 +53,6 @@ it: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Controlla la connessione di rete, l'indirizzo IMAP e riprova. @@ -222,6 +220,10 @@ it: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ it: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 80c424c6d..d3fcc8706 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -53,8 +53,6 @@ ja: invalid_message_type: '無効なメッセージタイプです。アクションは許可されていません' slack: invalid_channel_id: '無効なSlackチャンネルです。もう一度お試しください。' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: ネットワーク接続、IMAPアドレスを確認の上、再度お試しください。 @@ -222,6 +220,10 @@ ja: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'この受信トレイにアシスタントを接続してCopilotを使用してください' copilot_limit: 'Copilot残高がありません。課金セクションからクレジットを追加購入することができます。' @@ -278,3 +280,25 @@ ja: other: '%{count} 分' seconds: other: '%{count} 秒' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[コンテンツなし]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ka.yml b/config/locales/ka.yml index 4ed4e4369..27dce6767 100644 --- a/config/locales/ka.yml +++ b/config/locales/ka.yml @@ -53,8 +53,6 @@ ka: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ka: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ka: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ko.yml b/config/locales/ko.yml index c0a4fe11c..6e7b051e5 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -53,8 +53,6 @@ ko: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ko: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -233,7 +235,7 @@ ko: results_title: 검색 결과 toc_header: 'On this page' hero: - sub_title: 게시물을 여기서 검색하거나 아래에서 카테고리를 탐색해보세요. + sub_title: 게시물을 여기서 검색하거나 아래에서 카테고리를 탐색해보세요. common: home: 홈 last_updated_on: '%{last_updated_on}에 마지막으로 업데이트 됨' @@ -278,3 +280,25 @@ ko: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/lt.yml b/config/locales/lt.yml index 1bf7bb2cd..26c57124d 100644 --- a/config/locales/lt.yml +++ b/config/locales/lt.yml @@ -53,8 +53,6 @@ lt: invalid_message_type: 'Neteisingas pranešimo tipas. Veiksmas neleidžiamas' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Patikrinkite tinklo sujungimus, IMAP adresą ir bandykite dar kartą. @@ -222,6 +220,10 @@ lt: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ lt: few: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/lv.yml b/config/locales/lv.yml index b817997d1..3579952a5 100644 --- a/config/locales/lv.yml +++ b/config/locales/lv.yml @@ -48,13 +48,11 @@ lv: invalid: vajadzētu būt E.164 formātā categories: locale: - unique: vajadzētu būt unikālai, kategorijā un portālā + unique: vajadzētu būt unikālai, kategorijā un portālā dyte: invalid_message_type: 'Nederīgs ziņojuma veids. Darbība nav atļauta' slack: invalid_channel_id: 'Nepareizs Slack kanāls. Lūdzu, mēģiniet vēlreiz' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Lūdzu, pārbaudiet tīkla savienojumu, IMAP adresi un mēģiniet vēlreiz. @@ -222,6 +220,10 @@ lv: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Lai izmantotu Copilot, lūdzu, pievienojiet šai iesūtnei palīgu' copilot_limit: 'Jums ir beigušies Copilot kredīti. Vairāk kredītu varat iegādāties norēķinu sadaļā.' @@ -286,3 +288,25 @@ lv: zero: '%{count} sekundes' one: '%{count} sekunde' other: '%{count} sekundes' + automation: + system_name: 'Automatizācijas Sistēma' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Nav satura]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ml.yml b/config/locales/ml.yml index d97c9c258..6f992f50d 100644 --- a/config/locales/ml.yml +++ b/config/locales/ml.yml @@ -53,8 +53,6 @@ ml: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ml: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ml: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ms.yml b/config/locales/ms.yml index 5bf8726d3..e23420e5c 100644 --- a/config/locales/ms.yml +++ b/config/locales/ms.yml @@ -53,8 +53,6 @@ ms: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ms: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -278,3 +280,25 @@ ms: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ne.yml b/config/locales/ne.yml index 9abde5cfc..76d5440b2 100644 --- a/config/locales/ne.yml +++ b/config/locales/ne.yml @@ -53,8 +53,6 @@ ne: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ne: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ne: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 80aea0ece..6881c0939 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -53,8 +53,6 @@ nl: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Controleer de netwerkverbinding, IMAP-adres en probeer het opnieuw. @@ -222,6 +220,10 @@ nl: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ nl: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/no.yml b/config/locales/no.yml index 44792478f..00b68f9c1 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -53,8 +53,6 @@ invalid_message_type: 'Ugyldig meldingstype. Handlingen er ikke tillatt' slack: invalid_channel_id: 'Ugyldig slack kanal. Vennligst prøv på nytt' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Kontroller nettverkstilkoblingen, IMAP-adressen og prøv på nytt. @@ -222,6 +220,10 @@ shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Ingen innhold]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 564917bcb..87f2ad3b5 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -53,8 +53,6 @@ pl: invalid_message_type: 'Nieprawidłowy typ wiadomości. Niedozwolone działanie.' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Sprawdź połączenie sieciowe, adres IMAP i spróbuj ponownie. @@ -222,6 +220,10 @@ pl: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ pl: few: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 001709b4b..4b630e66a 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -53,8 +53,6 @@ pt: invalid_message_type: 'Tipo de mensagem inválido. Ação não permitida' slack: invalid_channel_id: 'Canal de slack inválido. Por favor, tente novamente' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Por favor, verifique a ligação à rede, endereço IMAP e tente novamente. @@ -222,6 +220,10 @@ pt: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ pt: seconds: one: '%{count} segundo' other: '%{count} segundos' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Sem conteúdo]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml index c853ee4e2..4db80727e 100644 --- a/config/locales/pt_BR.yml +++ b/config/locales/pt_BR.yml @@ -57,8 +57,6 @@ pt_BR: invalid_message_type: 'Tipo de mensagem inválido. Ação não permitida' slack: invalid_channel_id: 'Canal de slack inválido. Por favor, tente novamente' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Por favor, verifique a conexão de rede, endereço IMAP e tente novamente. @@ -101,7 +99,7 @@ pt_BR: avg_first_response_time: Tempo médio de primeira resposta avg_resolution_time: Tempo médio de resolução team_csv: - team_name: Nome da equipe + team_name: Nome do Time conversations_count: Contagem de conversas avg_first_response_time: Tempo médio de primeira resposta avg_resolution_time: Tempo médio de resolução @@ -112,8 +110,8 @@ pt_BR: sla_csv: conversation_id: ID da conversa sla_policy_breached: Política SLA - assignee: Responsável - team: Equipe + assignee: Agente atribuído + team: Time inbox: Caixa de Entrada labels: Etiquetas conversation_link: Link para a Conversa @@ -179,7 +177,7 @@ pt_BR: auto_resolution_message: 'Resolvendo a conversa dado que está inativa por um tempo. Por favor, inicie uma nova conversa se precisar de mais ajuda.' templates: greeting_message_body: '%{account_name} normalmente responde em algumas horas.' - ways_to_reach_you_message_body: 'Informe uma forma para entrarmos em contato com você.' + ways_to_reach_you_message_body: 'Informe à equipe uma forma de contatá-lo.' email_input_box_message_body: 'Seja notificado por e-mail' csat_input_message_body: 'Por favor, classifique a conversa' reply: @@ -210,7 +208,7 @@ pt_BR: meeting_name: '%{agent_name} começou a reunião' slack: name: 'Slack' - description: 'Integre Chatwoot com Slack para manter sua equipe em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack.' + description: 'Integre Chatwoot com Slack para manter seu time em sincronia. Essa integração permite que você receba notificações de novas conversas e as responda diretamente na interface do Slack.' webhooks: name: 'Webhooks' description: 'Eventos webhook fornecem atualizações sobre atividades em tempo real na sua conta Chatwoot. Você pode se inscrever em seus eventos preferidos, e o Chatwoot enviará as chamadas HTTP com as atualizações.' @@ -229,6 +227,10 @@ pt_BR: shopify: name: 'Shopify' description: 'Conecte sua loja Shopify para acessar detalhes de pedidos, informações de clientes e dados de produtos diretamente em suas conversas e ajudar sua equipe de suporte a fornecer um atendimento mais rápido e contextual aos seus clientes.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sincronize seus contatos e conversas com LeadSquared CRM.' + description: 'Sincronize seus contatos e conversas com LeadSquared CRM. Essa integração cria automaticamente leads em LeadSquared quando novos contatos são adicionados, e registra a atividade de conversação para fornecer à sua equipe de vendas um contexto completo.' captain: copilot_error: 'Conecte com um assistente a esta caixa de entrada para usar Copilot' copilot_limit: 'Você está sem créditos de Copilot. Pode comprar mais créditos na seção de faturamento.' @@ -289,3 +291,25 @@ pt_BR: seconds: one: '%{count} segundo' other: '%{count} segundos' + automation: + system_name: 'Sistema de Automação' + crm: + no_message: 'Nenhuma mensagem na conversa' + attachment: '[Anexo: %{type}]' + no_content: '[Sem conteúdo]' + created_activity: | + Nova conversa iniciada em %{brand_name} + + Canal: %{channel_info} + Criada: %{formatted_creation_time} + ID de conversa: %{display_id} + Veja em %{brand_name}: %{url} + transcript_activity: | + Transcrição de conversa de %{brand_name} + + Canal: %{channel_info} + ID da conversa: %{display_id} + Veja em %{brand_name}: %{url} + + Transcrição: + %{format_messages} diff --git a/config/locales/ro.yml b/config/locales/ro.yml index 8dd0ff599..c54f67349 100644 --- a/config/locales/ro.yml +++ b/config/locales/ro.yml @@ -53,8 +53,6 @@ ro: invalid_message_type: 'Tip de mesaj nevalid. Acțiune nepermisă' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Verificați conexiunea la rețea, adresa IMAP și încercați din nou. @@ -222,6 +220,10 @@ ro: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -286,3 +288,25 @@ ro: one: '%{count} second' few: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 40993bbf1..b7117df94 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -53,8 +53,6 @@ ru: invalid_message_type: 'Недопустимый тип сообщения. Действие запрещено' slack: invalid_channel_id: 'Неправильный канал slack - попробуйте еще раз' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Пожалуйста, проверьте сетевое подключение, адрес IMAP и повторите попытку. @@ -222,6 +220,10 @@ ru: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Пожалуйста, подключите ассистента к этому источнику входящих для использования Copilot' copilot_limit: 'У вас закончились кредиты для Copilot. Вы можете купить дополнительные кредиты в разделе биллинга.' @@ -290,3 +292,25 @@ ru: few: '%{count} секунд' many: '%{count} секунд' other: '%{count} секунд' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Нет содержимого]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sh.yml b/config/locales/sh.yml index 5330e99a9..916685cdf 100644 --- a/config/locales/sh.yml +++ b/config/locales/sh.yml @@ -53,8 +53,6 @@ sh: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ sh: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ sh: few: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sk.yml b/config/locales/sk.yml index 21fc7d850..9585c950a 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -53,8 +53,6 @@ sk: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ sk: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ sk: few: '%{count} seconds' many: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sl.yml b/config/locales/sl.yml index 3090c7424..a6529051f 100644 --- a/config/locales/sl.yml +++ b/config/locales/sl.yml @@ -53,8 +53,6 @@ sl: invalid_message_type: 'Neveljavna vrsta sporočila. Dejanje ni dovoljeno' slack: invalid_channel_id: 'Neveljaven slack kanal. Prosimo poskusite ponovno' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Preverite omrežno povezavo, naslov IMAP in poskusite znova. @@ -222,6 +220,10 @@ sl: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ sl: two: '%{count} sekundi' few: '%{count} sekunde' other: '%{count} sekund' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Ni vsebine]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sq.yml b/config/locales/sq.yml index 048acbe86..fccbc17b2 100644 --- a/config/locales/sq.yml +++ b/config/locales/sq.yml @@ -53,8 +53,6 @@ sq: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ sq: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ sq: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sr.yml b/config/locales/sr.yml index fdd689e6e..f927a0b6e 100644 --- a/config/locales/sr.yml +++ b/config/locales/sr.yml @@ -53,8 +53,6 @@ sr-Latn: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Molim vas proverite vezu sa mrežom, IMAP adresu i pokušajte ponovo. @@ -222,6 +220,10 @@ sr-Latn: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -286,3 +288,25 @@ sr-Latn: one: '%{count} second' few: '%{count} seconds' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 3c9d4dcaa..5a1931180 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -53,8 +53,6 @@ sv: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ sv: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ sv: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Inget innehåll]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ta.yml b/config/locales/ta.yml index 521321236..171952e23 100644 --- a/config/locales/ta.yml +++ b/config/locales/ta.yml @@ -53,8 +53,6 @@ ta: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ta: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ta: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/th.yml b/config/locales/th.yml index 28524b441..031f4749b 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -53,8 +53,6 @@ th: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ th: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -278,3 +280,25 @@ th: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/tl.yml b/config/locales/tl.yml index 692baecac..89024bb52 100644 --- a/config/locales/tl.yml +++ b/config/locales/tl.yml @@ -53,8 +53,6 @@ tl: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ tl: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ tl: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/tr.yml b/config/locales/tr.yml index b154a6f21..42e1fff8e 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -53,8 +53,6 @@ tr: invalid_message_type: 'Geçersiz mesaj türü. İşlem izin verilmiyor' slack: invalid_channel_id: 'Geçersiz Slack kanalı. Lütfen tekrar deneyin' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Lütfen ağ bağlantınızı, IMAP adresini kontrol edin ve tekrar deneyin. @@ -222,6 +220,10 @@ tr: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ tr: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 2bf4968a8..bba730a83 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -53,8 +53,6 @@ uk: invalid_message_type: 'Невірний тип повідомлення. Дію не дозволено' slack: invalid_channel_id: 'Недійсний канал slack. Будь ласка, спробуйте ще раз' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Перевірте підключення до мережі, адреса IMAP і повторіть спробу. @@ -222,6 +220,10 @@ uk: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -290,3 +292,25 @@ uk: few: '%{count} секунд' many: '%{count} секунд' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[Немає вмісту]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ur.yml b/config/locales/ur.yml index ffbd664ee..62df9f34f 100644 --- a/config/locales/ur.yml +++ b/config/locales/ur.yml @@ -53,8 +53,6 @@ ur: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ur: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ur: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/ur_IN.yml b/config/locales/ur_IN.yml index 542424bb7..72c9ab3d8 100644 --- a/config/locales/ur_IN.yml +++ b/config/locales/ur_IN.yml @@ -53,8 +53,6 @@ ur: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ ur: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -282,3 +284,25 @@ ur: seconds: one: '%{count} second' other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/vi.yml b/config/locales/vi.yml index aa8344f97..848356911 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -53,8 +53,6 @@ vi: invalid_message_type: 'Loại tin nhắn không hợp lệ. Hành động không được phép' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Vui lòng kiểm tra kết nối mạng, địa chỉ IMAP và thử lại. @@ -222,6 +220,10 @@ vi: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -278,3 +280,25 @@ vi: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml index 87aca8ede..9788c1b26 100644 --- a/config/locales/zh_CN.yml +++ b/config/locales/zh_CN.yml @@ -53,8 +53,6 @@ zh_CN: invalid_message_type: '无效的消息类型。不允许操作' slack: invalid_channel_id: '无效的Slack频道。请重试' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: 请检查网络连接,IMAP地址,然后再试一次。 @@ -222,6 +220,10 @@ zh_CN: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: '请为该收件箱连接一个助手以使用 Copilot' copilot_limit: '您的 Copilot 积分已用完。您可以从计费部分购买更多积分。' @@ -278,3 +280,25 @@ zh_CN: other: '%{count} 分钟' seconds: other: '%{count} 秒' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[无内容]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index 7a038bf5b..3dd001948 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -53,8 +53,6 @@ zh_TW: invalid_message_type: 'Invalid message type. Action not permitted' slack: invalid_channel_id: 'Invalid slack channel. Please try again' - channel_service: - invalid_source_id: "This conversation may have originally belonged to a different contact but is now showing here due to a merge or update. You won't be able to continue this conversation. Please create a new conversation to proceed." inboxes: imap: socket_error: Please check the network connection, IMAP address and try again. @@ -222,6 +220,10 @@ zh_TW: shopify: name: 'Shopify' description: 'Connect your Shopify store to access order details, customer information, and product data directly within your conversations and helps your support team provide faster, more contextual assistance to your customers.' + leadsquared: + name: 'LeadSquared' + short_description: 'Sync your contacts and conversations with LeadSquared CRM.' + description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.' captain: copilot_error: 'Please connect an assistant to this inbox to use Copilot' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.' @@ -278,3 +280,25 @@ zh_TW: other: '%{count} minutes' seconds: other: '%{count} seconds' + automation: + system_name: 'Automation System' + crm: + no_message: 'No messages in conversation' + attachment: '[Attachment: %{type}]' + no_content: '[No content]' + created_activity: | + New conversation started on %{brand_name} + + Channel: %{channel_info} + Created: %{formatted_creation_time} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + transcript_activity: | + Conversation Transcript from %{brand_name} + + Channel: %{channel_info} + Conversation ID: %{display_id} + View in %{brand_name}: %{url} + + Transcript: + %{format_messages} diff --git a/config/routes.rb b/config/routes.rb index f630dc885..85ad050c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,9 @@ Rails.application.routes.draw do end namespace :captain do resources :assistants do + member do + post :playground + end resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id end resources :documents, only: [:index, :show, :create, :destroy] @@ -98,7 +101,7 @@ Rails.application.routes.draw do post :filter end scope module: :conversations do - resources :messages, only: [:index, :create, :destroy] do + resources :messages, only: [:index, :create, :destroy, :update] do member do post :translate post :retry diff --git a/config/schedule.yml b/config/schedule.yml index f86015de6..afb9d98c7 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -33,9 +33,9 @@ remove_stale_redis_keys_job.rb: class: 'Internal::RemoveStaleRedisKeysJob' queue: scheduled_jobs -# executed daily at 2230 UTC -# which is our lowest traffic time -# process_stale_contacts_job: -# cron: '30 22 * * *' -# class: 'Internal::ProcessStaleContactsJob' -# queue: scheduled_jobs +#executed daily at 0430 UTC +# which will be IST 10:00 AM +process_stale_contacts_job: + cron: '30 04 * * *' + class: 'Internal::ProcessStaleContactsJob' + queue: scheduled_jobs 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 3bafd7420..7bb5ac60c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base before_action :current_account before_action -> { check_authorization(Captain::Assistant) } - before_action :set_assistant, only: [:show, :update, :destroy] + before_action :set_assistant, only: [:show, :update, :destroy, :playground] def index @assistants = account_assistants.ordered @@ -23,6 +23,15 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base head :no_content end + def playground + response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( + params[:message_content], + message_history + ) + + render json: response + end + private def set_assistant @@ -34,6 +43,19 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base end def assistant_params - params.require(:assistant).permit(:name, :description, config: [:product_name, :feature_faq, :feature_memory]) + params.require(:assistant).permit(:name, :description, + config: [ + :product_name, :feature_faq, :feature_memory, + :welcome_message, :handoff_message, :resolution_message, + :instructions + ]) + end + + def playground_params + params.require(:assistant).permit(:message_content, message_history: [:role, :content]) + end + + def message_history + (playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } } end end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 3cb668688..99a82a139 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -58,8 +58,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController private def check_cloud_env - installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV') - render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud' + render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud? end def default_limits diff --git a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb index a44a0eceb..40115ae02 100644 --- a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb +++ b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb @@ -1,12 +1,3 @@ module Enterprise::Concerns::ApplicationControllerConcern extend ActiveSupport::Concern - - included do - before_action :prepend_view_paths - end - - # Prepend the view path to the enterprise/app/views won't be available by default - def prepend_view_paths - prepend_view_path 'enterprise/app/views/' - end end diff --git a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb new file mode 100644 index 000000000..305699dcb --- /dev/null +++ b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb @@ -0,0 +1,15 @@ +module Enterprise::SuperAdmin::AccountsController + def update + # Handle manually managed features from form submission + if params[:account] && params[:account][:manually_managed_features].present? + # Update using the service - it will handle array conversion and validation + service = ::Internal::Accounts::InternalAttributesService.new(requested_resource) + service.manually_managed_features = params[:account][:manually_managed_features] + + # Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError + params[:account].delete(:manually_managed_features) + end + + super + end +end diff --git a/enterprise/app/controllers/super_admin/enterprise_base_controller.rb b/enterprise/app/controllers/super_admin/enterprise_base_controller.rb index b2108332a..503331107 100644 --- a/enterprise/app/controllers/super_admin/enterprise_base_controller.rb +++ b/enterprise/app/controllers/super_admin/enterprise_base_controller.rb @@ -1,8 +1,2 @@ class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController - before_action :prepend_view_paths - - # Prepend the view path to the enterprise/app/views won't be available by default - def prepend_view_paths - prepend_view_path 'enterprise/app/views/' - end end diff --git a/enterprise/app/fields/account_features_field.rb b/enterprise/app/fields/account_features_field.rb new file mode 100644 index 000000000..2b933b8a3 --- /dev/null +++ b/enterprise/app/fields/account_features_field.rb @@ -0,0 +1,7 @@ +require 'administrate/field/base' + +class AccountFeaturesField < Administrate::Field::Base + def to_s + data + end +end diff --git a/app/fields/enterprise/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb similarity index 73% rename from app/fields/enterprise/account_limits_field.rb rename to enterprise/app/fields/account_limits_field.rb index b014435c6..b6aecd79f 100644 --- a/app/fields/enterprise/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -1,6 +1,6 @@ require 'administrate/field/base' -class Enterprise::AccountLimitsField < Administrate::Field::Base +class AccountLimitsField < Administrate::Field::Base def to_s data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json end diff --git a/enterprise/app/fields/manually_managed_features_field.rb b/enterprise/app/fields/manually_managed_features_field.rb new file mode 100644 index 000000000..df76f4099 --- /dev/null +++ b/enterprise/app/fields/manually_managed_features_field.rb @@ -0,0 +1,31 @@ +require 'administrate/field/base' + +class ManuallyManagedFeaturesField < Administrate::Field::Base + def data + Internal::Accounts::InternalAttributesService.new(resource).manually_managed_features + end + + def to_s + data.is_a?(Array) ? data.join(', ') : '[]' + end + + def all_features + # Business and Enterprise plan features only + Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + + Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + end + + def selected_features + # If we have direct array data, use it (for rendering after form submission) + return data if data.is_a?(Array) + + # Otherwise, use the service to retrieve the data from internal_attributes + if resource.respond_to?(:internal_attributes) + service = Internal::Accounts::InternalAttributesService.new(resource) + return service.manually_managed_features + end + + # Fallback to empty array if no data available + [] + end +end diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb index 3cb5bf8fe..146e8f813 100644 --- a/enterprise/app/helpers/captain/chat_helper.rb +++ b/enterprise/app/helpers/captain/chat_helper.rb @@ -36,6 +36,7 @@ module Captain::ChatHelper end def handle_response(response) + Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" } message = response.dig('choices', 0, 'message') if message['tool_calls'] process_tool_calls(message['tool_calls']) @@ -46,20 +47,26 @@ module Captain::ChatHelper def process_tool_calls(tool_calls) append_tool_calls(tool_calls) - process_tool_call(tool_calls.first) - end - - def process_tool_call(tool_call) - return unless tool_call['function']['name'] == 'search_documentation' - - tool_call_id = tool_call['id'] - query = JSON.parse(tool_call['function']['arguments'])['search_query'] - sections = fetch_documentation(query) - append_tool_response(sections, tool_call_id) + tool_calls.each do |tool_call| + process_tool_call(tool_call) + end request_chat_completion end + def process_tool_call(tool_call) + tool_call_id = tool_call['id'] + + if tool_call['function']['name'] == 'search_documentation' + query = JSON.parse(tool_call['function']['arguments'])['search_query'] + sections = fetch_documentation(query) + append_tool_response(sections, tool_call_id) + else + append_tool_response('', tool_call_id) + end + end + def fetch_documentation(query) + Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" } @assistant .responses .approved diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index c8c511a57..5bc9defa4 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -60,7 +60,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def create_handoff_message - create_outgoing_message('Transferring to another agent for further assistance.') + create_outgoing_message(@assistant.config['handoff_message'] || 'Transferring to another agent for further assistance.') end def create_messages diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb index 657d31bd3..37a2729ec 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -5,12 +5,13 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob # limiting the number of conversations to be resolved to avoid any performance issues resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) resolvable_conversations.each do |conversation| + resolution_message = conversation.inbox.captain_assistant.config['resolution_message'] conversation.messages.create!( { message_type: :outgoing, account_id: conversation.account_id, inbox_id: conversation.inbox_id, - content: I18n.t('conversations.activity.auto_resolution_message') + content: resolution_message || I18n.t('conversations.activity.auto_resolution_message') } ) conversation.resolved! diff --git a/enterprise/app/jobs/internal/account_analysis_job.rb b/enterprise/app/jobs/internal/account_analysis_job.rb index 49ddaa2b5..76d04aa60 100644 --- a/enterprise/app/jobs/internal/account_analysis_job.rb +++ b/enterprise/app/jobs/internal/account_analysis_job.rb @@ -2,7 +2,7 @@ class Internal::AccountAnalysisJob < ApplicationJob queue_as :low def perform(account) - return if GlobalConfig.get_value('DEPLOYMENT_ENV') != 'cloud' + return unless ChatwootApp.chatwoot_cloud? Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index 37bffc5a6..fae44dab6 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,4 +1,8 @@ module Enterprise::Account + # TODO: Remove this when we upgrade administrate gem to the latest version + # this is a temporary method since current administrate doesn't support virtual attributes + def manually_managed_features; end + def mark_for_deletion(reason = 'manual_deletion') result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb index e70af4ba4..2a03fc0ea 100644 --- a/enterprise/app/policies/captain/assistant_policy.rb +++ b/enterprise/app/policies/captain/assistant_policy.rb @@ -18,4 +18,8 @@ class Captain::AssistantPolicy < ApplicationPolicy def destroy? @account_user.administrator? end + + def playground? + true + end end diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index 3d2b0da44..1e45cb1d8 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -22,7 +22,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService def system_message { role: 'system', - content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name']) + content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'], @assistant.config) } end end diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb index 8a8753f60..b1e627275 100644 --- a/enterprise/app/services/captain/llm/system_prompts_service.rb +++ b/enterprise/app/services/captain/llm/system_prompts_service.rb @@ -103,7 +103,7 @@ class Captain::Llm::SystemPromptsService SYSTEM_PROMPT_MESSAGE end - def assistant_response_generator(product_name) + def assistant_response_generator(product_name, config = {}) <<~SYSTEM_PROMPT_MESSAGE [Identity] You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}. @@ -111,6 +111,7 @@ class Captain::Llm::SystemPromptsService [Response Guideline] - Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps. - Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words). + - Always detect the language from input and reply in the same language. Do not use any other language. - Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation. - Use discourse markers to ease comprehension. Never use the list format. - Do not generate a response more than three sentences. @@ -136,6 +137,7 @@ class Captain::Llm::SystemPromptsService - Do not share anything outside of the context provided. - Add the reasoning why you arrived at the answer - Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format. + #{config['instructions'] || ''} ```json { reasoning: '', diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 220147a89..a21afff3c 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -1,6 +1,31 @@ class Enterprise::Billing::HandleStripeEventService + CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze + + # Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise + # Each higher tier includes all features from the lower tiers + + # Basic features available starting with the Startups plan + STARTUP_PLAN_FEATURES = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + ].freeze + + # Additional features available starting with the Business plan + BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze + + # Additional features available only in the Enterprise plan + ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding].freeze + def perform(event:) - ensure_event_context(event) + @event = event + case @event.type when 'customer.subscription.updated' process_subscription_updated @@ -20,14 +45,12 @@ class Enterprise::Billing::HandleStripeEventService return if plan.blank? || account.blank? update_account_attributes(subscription, plan) - - change_plan_features + update_plan_features reset_captain_usage end def update_account_attributes(subscription, plan) # https://stripe.com/docs/api/subscriptions/object - account.update( custom_attributes: { stripe_customer_id: subscription.customer, @@ -48,25 +71,57 @@ class Enterprise::Billing::HandleStripeEventService Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end - def change_plan_features + def update_plan_features if default_plan? - account.disable_features(*features_to_update) + disable_all_premium_features else - account.enable_features(*features_to_update) + enable_features_for_current_plan end + + # Enable any manually managed features configured in internal_attributes + enable_account_manually_managed_features + account.save! end + def disable_all_premium_features + # Disable all features (for default Hacker plan) + account.disable_features(*STARTUP_PLAN_FEATURES) + account.disable_features(*BUSINESS_PLAN_FEATURES) + account.disable_features(*ENTERPRISE_PLAN_FEATURES) + end + + def enable_features_for_current_plan + # First disable all premium features to handle downgrades + disable_all_premium_features + + # Then enable features based on the current plan + enable_plan_specific_features + end + def reset_captain_usage account.reset_response_usage end - def ensure_event_context(event) - @event = event - end + def enable_plan_specific_features + plan_name = account.custom_attributes['plan_name'] + return if plan_name.blank? - def features_to_update - %w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration] + # Enable features based on plan hierarchy + case plan_name + when 'Startups' + # Startups plan gets the basic features + account.enable_features(*STARTUP_PLAN_FEATURES) + when 'Business' + # Business plan gets Startups features + Business features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + when 'Enterprise' + # Enterprise plan gets all features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + account.enable_features(*ENTERPRISE_PLAN_FEATURES) + end end def subscription @@ -78,13 +133,22 @@ class Enterprise::Billing::HandleStripeEventService end def find_plan(plan_id) - installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') - installation_config.value.find { |config| config['product_id'].include?(plan_id) } + cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] + cloud_plans.find { |config| config['product_id'].include?(plan_id) } end def default_plan? - installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') - default_plan = installation_config.value.first - @account.custom_attributes['plan_name'] == default_plan['name'] + cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || [] + default_plan = cloud_plans.first || {} + account.custom_attributes['plan_name'] == default_plan['name'] + end + + def enable_account_manually_managed_features + # Get manually managed features from internal attributes using the service + service = Internal::Accounts::InternalAttributesService.new(account) + features = service.manually_managed_features + + # Enable each feature + account.enable_features(*features) if features.present? end end diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb new file mode 100644 index 000000000..b97493dce --- /dev/null +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -0,0 +1,68 @@ +class Internal::Accounts::InternalAttributesService + attr_reader :account + + # List of keys that can be managed through this service + # TODO: Add account_notes field in future + # This field can be used to store notes about account on Chatwoot cloud + VALID_KEYS = %w[manually_managed_features].freeze + + def initialize(account) + @account = account + end + + # Get a value from internal_attributes + def get(key) + validate_key!(key) + account.internal_attributes[key] + end + + # Set a value in internal_attributes + def set(key, value) + validate_key!(key) + + # Create a new hash to avoid modifying the original + new_attrs = account.internal_attributes.dup || {} + new_attrs[key] = value + + # Update the account + account.internal_attributes = new_attrs + account.save! + end + + # Get manually managed features + def manually_managed_features + get('manually_managed_features') || [] + end + + # Set manually managed features + def manually_managed_features=(features) + features = [] if features.nil? + features = [features] unless features.is_a?(Array) + + # Clean up the array: remove empty strings, whitespace, and validate against valid features + valid_features = valid_feature_list + features = features.compact + .map(&:strip) + .reject(&:empty?) + .select { |f| valid_features.include?(f) } + .uniq + + set('manually_managed_features', features) + end + + # Get list of valid features that can be manually managed + def valid_feature_list + # Business and Enterprise plan features only + Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + + Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + end + + # Account notes functionality removed for now + # Will be re-implemented when UI is ready + + private + + def validate_key!(key) + raise ArgumentError, "Invalid internal attribute key: #{key}" unless VALID_KEYS.include?(key) + end +end diff --git a/app/views/fields/account_features_field/_form.html.erb b/enterprise/app/views/fields/account_features_field/_form.html.erb similarity index 100% rename from app/views/fields/account_features_field/_form.html.erb rename to enterprise/app/views/fields/account_features_field/_form.html.erb diff --git a/app/views/fields/account_features_field/_show.html.erb b/enterprise/app/views/fields/account_features_field/_show.html.erb similarity index 100% rename from app/views/fields/account_features_field/_show.html.erb rename to enterprise/app/views/fields/account_features_field/_show.html.erb diff --git a/app/views/fields/account_limits_field/_form.html.erb b/enterprise/app/views/fields/account_limits_field/_form.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_form.html.erb rename to enterprise/app/views/fields/account_limits_field/_form.html.erb diff --git a/app/views/fields/account_limits_field/_index.html.erb b/enterprise/app/views/fields/account_limits_field/_index.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_index.html.erb rename to enterprise/app/views/fields/account_limits_field/_index.html.erb diff --git a/app/views/fields/account_limits_field/_show.html.erb b/enterprise/app/views/fields/account_limits_field/_show.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_show.html.erb rename to enterprise/app/views/fields/account_limits_field/_show.html.erb diff --git a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb new file mode 100644 index 000000000..b84da3d5c --- /dev/null +++ b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb @@ -0,0 +1,41 @@ +<% + # Get all feature names and their display names + all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names + + # Business and Enterprise plan features only + premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + + Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + + # Get only premium features with display names + premium_features_with_display = premium_features.map do |feature| + [feature, all_feature_display_names[feature] || feature.humanize] + end.sort_by { |_, display_name| display_name } + + # Get already selected features + selected_features = field.selected_features +%> + +
+ <%= f.label :manually_managed_features %> +
+
+

Features that remain enabled even when account plan is downgraded

+ +
+ <% premium_features_with_display.each do |feature_key, display_name| %> +
+ <%= display_name %> + + <%= check_box_tag "account[manually_managed_features][]", + feature_key, + selected_features.include?(feature_key), + class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" %> + +
+ <% end %> +
+ +
+ + <%= hidden_field_tag "account[manually_managed_features][]", "", id: nil %> +
\ No newline at end of file diff --git a/enterprise/app/views/fields/manually_managed_features_field/_show.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_show.html.erb new file mode 100644 index 000000000..35ba8d957 --- /dev/null +++ b/enterprise/app/views/fields/manually_managed_features_field/_show.html.erb @@ -0,0 +1,31 @@ +<% + selected_features = field.selected_features + + # Get all feature names and their display names + all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names + + # Business and Enterprise plan features only + premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + + Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES +%> + +<% if selected_features.present? %> +
+

Features that remain enabled even when account plan is downgraded

+ +
+ <% selected_features.each do |feature| %> +
+ <%= all_feature_display_names[feature] || feature.humanize %> + + + +
+ <% end %> +
+ +
+
+<% else %> +

No manually managed features configured

+<% end %> \ No newline at end of file diff --git a/lib/chatwoot_app.rb b/lib/chatwoot_app.rb index d4b614615..0e1f15879 100644 --- a/lib/chatwoot_app.rb +++ b/lib/chatwoot_app.rb @@ -17,6 +17,10 @@ module ChatwootApp @enterprise ||= root.join('enterprise').exist? end + def self.chatwoot_cloud? + enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' + end + def self.custom? @custom ||= root.join('custom').exist? end diff --git a/lib/redis/redis_keys.rb b/lib/redis/redis_keys.rb index f8c467c9a..fa64d1043 100644 --- a/lib/redis/redis_keys.rb +++ b/lib/redis/redis_keys.rb @@ -41,4 +41,5 @@ module Redis::RedisKeys IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%s::%s'.freeze SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%s::%s'.freeze EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%s'.freeze + CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%s'.freeze end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index f2e66f997..41b3a415d 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -42,7 +42,7 @@ class Webhooks::Trigger end def update_message_status(error) - message.update!(status: :failed, external_error: error.message) + Messages::StatusUpdateService.new(message, 'failed', error.message).perform end def message diff --git a/public/dashboard/images/integrations/leadsquared-dark.png b/public/dashboard/images/integrations/leadsquared-dark.png new file mode 100644 index 000000000..c6827180e Binary files /dev/null and b/public/dashboard/images/integrations/leadsquared-dark.png differ diff --git a/public/dashboard/images/integrations/leadsquared.png b/public/dashboard/images/integrations/leadsquared.png new file mode 100644 index 000000000..c6827180e Binary files /dev/null and b/public/dashboard/images/integrations/leadsquared.png differ diff --git a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 2594667f0..f43517ab5 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -563,8 +563,11 @@ RSpec.describe 'Contacts API', type: :request do describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do let(:custom_attributes) { { test: 'test', test1: 'test1' } } - let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) } - let(:valid_params) { { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } } + let(:additional_attributes) { { attr1: 'attr1', attr2: 'attr2' } } + let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes, additional_attributes: additional_attributes) } + let(:valid_params) do + { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' }, additional_attributes: { attr2: 'new attr2', attr3: 'attr3' } } + end context 'when it is an unauthenticated user' do it 'returns unauthorized' do @@ -588,6 +591,7 @@ RSpec.describe 'Contacts API', type: :request do expect(contact.reload.name).to eq('Test Blub') # custom attributes are merged properly without overwriting existing ones expect(contact.custom_attributes).to eq({ 'test' => 'new test', 'test1' => 'test1', 'test2' => 'test2' }) + expect(contact.additional_attributes).to eq({ 'attr1' => 'attr1', 'attr2' => 'new attr2', 'attr3' => 'attr3' }) end it 'prevents the update of contact of another account' do diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 94d4cf272..f7ff042e5 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -84,7 +84,6 @@ RSpec.describe 'Conversation Messages API', type: :request do context 'when api inbox' do let(:api_channel) { create(:channel_api, account: account) } let(:api_inbox) { create(:inbox, channel: api_channel, account: account) } - let(:inbox_member) { create(:inbox_member, user: agent, inbox: api_inbox) } let(:conversation) { create(:conversation, inbox: api_inbox, account: account) } it 'reopens the conversation with new incoming message' do @@ -294,4 +293,67 @@ RSpec.describe 'Conversation Messages API', type: :request do end end end + + describe 'PATCH /api/v1/accounts/{account.id}/conversations/:conversation_id/messages/:id' do + let(:api_channel) { create(:channel_api, account: account) } + let(:api_inbox) { create(:inbox, channel: api_channel, account: account) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:conversation) { create(:conversation, inbox: api_inbox, account: account) } + let!(:message) { create(:message, conversation: conversation, account: account, status: :sent) } + + context 'when unauthenticated' do + it 'returns unauthorized' do + patch api_v1_account_conversation_message_url(account_id: account.id, conversation_id: conversation.display_id, id: message.id) + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when authenticated agent' do + context 'when agent has non-API inbox' do + let(:inbox) { create(:inbox, account: account) } + let(:agent) { create(:user, account: account, role: :agent) } + let!(:conversation) { create(:conversation, inbox: inbox, account: account) } + + before { create(:inbox_member, inbox: inbox, user: agent) } + + it 'returns forbidden' do + patch api_v1_account_conversation_message_url( + account_id: account.id, + conversation_id: conversation.display_id, + id: message.id + ), params: { status: 'failed', external_error: 'err' }, headers: agent.create_new_auth_token, as: :json + expect(response).to have_http_status(:forbidden) + end + end + + context 'when agent has API inbox' do + before { create(:inbox_member, inbox: api_inbox, user: agent) } + + it 'uses StatusUpdateService to perform status update' do + service = instance_double(Messages::StatusUpdateService) + expect(Messages::StatusUpdateService).to receive(:new) + .with(message, 'failed', 'err123') + .and_return(service) + expect(service).to receive(:perform) + patch api_v1_account_conversation_message_url( + account_id: account.id, + conversation_id: conversation.display_id, + id: message.id + ), params: { status: 'failed', external_error: 'err123' }, headers: agent.create_new_auth_token, as: :json + end + + it 'updates status to failed with external_error' do + patch api_v1_account_conversation_message_url( + account_id: account.id, + conversation_id: conversation.display_id, + id: message.id + ), params: { status: 'failed', external_error: 'err123' }, headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + expect(message.reload.status).to eq('failed') + expect(message.reload.external_error).to eq('err123') + end + end + end + end end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index c3c83e457..1f6d83d80 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -175,4 +175,67 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do + let(:assistant) { create(:captain_assistant, account: account) } + let(:valid_params) do + { + message_content: 'Hello assistant', + message_history: [ + { role: 'user', content: 'Previous message' }, + { role: 'assistant', content: 'Previous response' } + ] + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", + params: valid_params, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'generates a response' do + chat_service = instance_double(Captain::Llm::AssistantChatService) + allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) + allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) + + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", + params: valid_params, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(chat_service).to have_received(:generate_response).with( + valid_params[:message_content], + valid_params[:message_history] + ) + expect(json_response[:content]).to eq('Assistant response') + end + end + + context 'when message_history is not provided' do + it 'uses empty array as default' do + params_without_history = { message_content: 'Hello assistant' } + chat_service = instance_double(Captain::Llm::AssistantChatService) + allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) + allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) + + post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", + params: params_without_history, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(chat_service).to have_received(:generate_response).with( + params_without_history[:message_content], + [] + ) + end + end + end end diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb index 90e9360d3..09f62272d 100644 --- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb +++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb @@ -4,11 +4,13 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do include ActiveJob::TestHelper let!(:inbox) { create(:inbox) } + let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) } let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) } let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) } before do + create(:captain_inbox, inbox: inbox, captain_assistant: create(:captain_assistant, account: inbox.account)) stub_const('Limits::BULK_ACTIONS_LIMIT', 2) end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index d6d2e187e..67ccd168b 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -9,120 +9,312 @@ describe Enterprise::Billing::HandleStripeEventService do let!(:account) { create(:account, custom_attributes: { stripe_customer_id: 'cus_123' }) } before do + # Create cloud plans configuration + create(:installation_config, { + name: 'CHATWOOT_CLOUD_PLANS', + value: [ + { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, + { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }, + { 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] }, + { 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] } + ] + }) + # Setup common subscription mocks allow(event).to receive(:data).and_return(data) allow(data).to receive(:object).and_return(subscription) - allow(subscription).to receive(:[]).with('plan') - .and_return({ - 'id' => 'test', 'product' => 'plan_id', 'name' => 'plan_name' - }) allow(subscription).to receive(:[]).with('quantity').and_return('10') allow(subscription).to receive(:[]).with('status').and_return('active') allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520) allow(subscription).to receive(:customer).and_return('cus_123') - create(:installation_config, { - name: 'CHATWOOT_CLOUD_PLANS', - value: [ - { - 'name' => 'Hacker', - 'product_id' => ['plan_id'], - 'price_ids' => ['price_1'] - }, - { - 'name' => 'Startups', - 'product_id' => ['plan_id_2'], - 'price_ids' => ['price_2'] - } - ] - }) + allow(event).to receive(:type).and_return('customer.subscription.updated') end - describe '#perform' do - context 'when it gets customer.subscription.updated event' do - it 'updates subscription attributes' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) + describe 'subscription update handling' do + it 'updates account attributes and disables premium features for default plan' do + # Setup for default (Hacker) plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' }) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) - end - - it 'resets captain usage' do - 5.times { account.increment_response_usage } - expect(account.custom_attributes['captain_responses_usage']).to eq(5) - - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) - - expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0) - end - end - - it 'disable features on customer.subscription.updated for default plan' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') stripe_event_service.new.perform(event: event) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) + + # Verify account attributes were updated + expect(account.reload.custom_attributes).to include( + 'plan_name' => 'Hacker', + 'stripe_product_id' => 'plan_id_hacker', + 'subscription_status' => 'active' + ) + + # Verify premium features are disabled for default plan expect(account).not_to be_feature_enabled('channel_email') expect(account).not_to be_feature_enabled('help_center') + expect(account).not_to be_feature_enabled('sla') + expect(account).not_to be_feature_enabled('custom_roles') + expect(account).not_to be_feature_enabled('audit_logs') end - it 'handles customer.subscription.deleted' do - stripe_customer_service = double - allow(event).to receive(:type).and_return('customer.subscription.deleted') - allow(Enterprise::Billing::CreateStripeCustomerService).to receive(:new).and_return(stripe_customer_service) - allow(stripe_customer_service).to receive(:perform) + it 'resets captain usage on subscription update' do + # Prime the account with some usage + 5.times { account.increment_response_usage } + expect(account.custom_attributes['captain_responses_usage']).to eq(5) + + # Setup for any plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' }) + stripe_event_service.new.perform(event: event) - expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) + + # Verify usage was reset + expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0) end end - describe '#perform for Startups plan' do - before do - allow(event).to receive(:data).and_return(data) - allow(data).to receive(:object).and_return(subscription) - allow(subscription).to receive(:[]).with('plan') - .and_return({ - 'id' => 'test', 'product' => 'plan_id_2', 'name' => 'plan_name' - }) - allow(subscription).to receive(:[]).with('quantity').and_return('10') - allow(subscription).to receive(:customer).and_return('cus_123') + describe 'subscription deletion handling' do + it 'calls CreateStripeCustomerService on subscription deletion' do + allow(event).to receive(:type).and_return('customer.subscription.deleted') + + # Create a double for the service + customer_service = double + allow(Enterprise::Billing::CreateStripeCustomerService).to receive(:new) + .with(account: account).and_return(customer_service) + allow(customer_service).to receive(:perform) + + stripe_event_service.new.perform(event: event) + + # Verify the service was called + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new) + .with(account: account) + expect(customer_service).to have_received(:perform) + end + end + + describe 'plan-specific feature management' do + context 'with default plan (Hacker)' do + it 'disables all premium features' do + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' }) + + # Enable features first + described_class::STARTUP_PLAN_FEATURES.each do |feature| + account.enable_features(feature) + end + account.enable_features(*described_class::BUSINESS_PLAN_FEATURES) + account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES) + account.save! + + account.reload + expect(account).to be_feature_enabled(described_class::STARTUP_PLAN_FEATURES.first) + + stripe_event_service.new.perform(event: event) + + account.reload + + all_features = described_class::STARTUP_PLAN_FEATURES + + described_class::BUSINESS_PLAN_FEATURES + + described_class::ENTERPRISE_PLAN_FEATURES + + all_features.each do |feature| + expect(account).not_to be_feature_enabled(feature) + end + end end - it 'enable features on customer.subscription.updated' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id_2', - 'plan_name' => 'Startups', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) - expect(account).to be_feature_enabled('channel_email') - expect(account).to be_feature_enabled('help_center') + context 'with Startups plan' do + it 'enables common features but not premium features' do + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' }) + + stripe_event_service.new.perform(event: event) + + # Verify basic (Startups) features are enabled + account.reload + described_class::STARTUP_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + + # But business and enterprise features should be disabled + described_class::BUSINESS_PLAN_FEATURES.each do |feature| + expect(account).not_to be_feature_enabled(feature) + end + + described_class::ENTERPRISE_PLAN_FEATURES.each do |feature| + expect(account).not_to be_feature_enabled(feature) + end + end + end + + context 'with Business plan' do + it 'enables business-specific features' do + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' }) + + stripe_event_service.new.perform(event: event) + + account.reload + described_class::STARTUP_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + + described_class::BUSINESS_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + + described_class::ENTERPRISE_PLAN_FEATURES.each do |feature| + expect(account).not_to be_feature_enabled(feature) + end + end + end + + context 'with Enterprise plan' do + it 'enables all business and enterprise features' do + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' }) + + stripe_event_service.new.perform(event: event) + + account.reload + described_class::STARTUP_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + + described_class::BUSINESS_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + + described_class::ENTERPRISE_PLAN_FEATURES.each do |feature| + expect(account).to be_feature_enabled(feature) + end + end + end + end + + describe 'manually managed features' do + let(:service) { stripe_event_service.new } + let(:internal_attrs_service) { instance_double(Internal::Accounts::InternalAttributesService) } + + before do + # Mock the internal attributes service + allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service) + end + + context 'when downgrading with manually managed features' do + it 'preserves manually managed features even when downgrading plans' do + # Setup: account has Enterprise plan with manually managed features + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' }) + + # Mock manually managed features + allow(internal_attrs_service).to receive(:manually_managed_features).and_return(%w[audit_logs custom_roles]) + + # First run to apply enterprise plan + service.perform(event: event) + account.reload + + # Verify features are enabled + expect(account).to be_feature_enabled('audit_logs') + expect(account).to be_feature_enabled('custom_roles') + + # Now downgrade to Hacker plan (which normally wouldn't have these features) + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' }) + + service.perform(event: event) + account.reload + + # Manually managed features should still be enabled despite plan downgrade + expect(account).to be_feature_enabled('audit_logs') + expect(account).to be_feature_enabled('custom_roles') + + # But other premium features should be disabled + expect(account).not_to be_feature_enabled('channel_instagram') + expect(account).not_to be_feature_enabled('help_center') + end + end + end + + describe 'downgrade handling' do + let(:service) { stripe_event_service.new } + + before do + # Setup internal attributes service mock to return no manually managed features + internal_attrs_service = instance_double(Internal::Accounts::InternalAttributesService) + allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service) + allow(internal_attrs_service).to receive(:manually_managed_features).and_return([]) + end + + context 'when downgrading from Enterprise to Business plan' do + before do + # Start with Enterprise plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' }) + service.perform(event: event) + account.reload + end + + it 'retains business features but disables enterprise features' do + # Verify enterprise features were enabled + expect(account).to be_feature_enabled('audit_logs') + + # Downgrade to Business plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' }) + service.perform(event: event) + + account.reload + expect(account).to be_feature_enabled('sla') + expect(account).to be_feature_enabled('custom_roles') + expect(account).not_to be_feature_enabled('audit_logs') + end + end + + context 'when downgrading from Business to Startups plan' do + before do + # Start with Business plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' }) + service.perform(event: event) + account.reload + end + + it 'retains startup features but disables business features' do + # Verify business features were enabled + expect(account).to be_feature_enabled('sla') + + # Downgrade to Startups plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' }) + service.perform(event: event) + + account.reload + # Spot check one startup feature + expect(account).to be_feature_enabled('channel_instagram') + expect(account).not_to be_feature_enabled('sla') + expect(account).not_to be_feature_enabled('custom_roles') + end + end + + context 'when downgrading from Startups to Hacker plan' do + before do + # Start with Startups plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' }) + service.perform(event: event) + account.reload + end + + it 'disables all premium features' do + # Verify startup features were enabled + expect(account).to be_feature_enabled('channel_instagram') + + # Downgrade to Hacker (default) plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' }) + service.perform(event: event) + + account.reload + # Spot check that premium features are disabled + expect(account).not_to be_feature_enabled('channel_instagram') + expect(account).not_to be_feature_enabled('help_center') + end end end end diff --git a/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb b/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb new file mode 100644 index 000000000..e2ec89342 --- /dev/null +++ b/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +RSpec.describe Internal::Accounts::InternalAttributesService do + let!(:account) { create(:account, internal_attributes: { 'test_key' => 'test_value' }) } + let(:service) { described_class.new(account) } + let(:business_features) { Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES } + let(:enterprise_features) { Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES } + + describe '#initialize' do + it 'sets the account' do + expect(service.account).to eq(account) + end + end + + describe '#get' do + it 'returns the value for a valid key' do + # Manually set the value first since the key needs to be in VALID_KEYS + allow(service).to receive(:validate_key!).and_return(true) + account.internal_attributes['manually_managed_features'] = ['test'] + + expect(service.get('manually_managed_features')).to eq(['test']) + end + + it 'raises an error for an invalid key' do + expect { service.get('invalid_key') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key') + end + end + + describe '#set' do + it 'sets the value for a valid key' do + # Stub the validation to allow our test key + allow(service).to receive(:validate_key!).and_return(true) + + service.set('manually_managed_features', %w[feature1 feature2]) + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq(%w[feature1 feature2]) + end + + it 'raises an error for an invalid key' do + expect { service.set('invalid_key', 'value') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key') + end + + it 'creates internal_attributes hash if it is empty' do + account.update!(internal_attributes: {}) + + # Stub the validation to allow our test key + allow(service).to receive(:validate_key!).and_return(true) + + service.set('manually_managed_features', ['feature1']) + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq(['feature1']) + end + end + + describe '#manually_managed_features' do + it 'returns an empty array when no features are set' do + expect(service.manually_managed_features).to eq([]) + end + + it 'returns the features when they are set' do + account.update!(internal_attributes: { 'manually_managed_features' => %w[feature1 feature2] }) + + expect(service.manually_managed_features).to eq(%w[feature1 feature2]) + end + end + + describe '#manually_managed_features=' do + # Use a real SLA feature which is in the BUSINESS_PLAN_FEATURES + let(:valid_feature) { 'sla' } + + before do + # Make sure the feature is allowed through validation + allow(service).to receive(:valid_feature_list).and_return([valid_feature, 'custom_roles']) + end + + it 'saves features as an array' do + service.manually_managed_features = valid_feature + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature]) + end + + it 'handles nil input' do + service.manually_managed_features = nil + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([]) + end + + it 'handles array input' do + service.manually_managed_features = [valid_feature, 'custom_roles'] + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature, 'custom_roles']) + end + + it 'filters out invalid features' do + service.manually_managed_features = [valid_feature, 'invalid_feature'] + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature]) + end + + it 'removes duplicates' do + service.manually_managed_features = [valid_feature, valid_feature] + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature]) + end + + it 'removes empty strings' do + service.manually_managed_features = [valid_feature, '', ' '] + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature]) + end + + it 'trims whitespace' do + service.manually_managed_features = [" #{valid_feature} "] + account.reload + + expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature]) + end + end + + describe '#valid_feature_list' do + it 'returns a combination of business and enterprise features' do + expect(service.valid_feature_list).to include(*business_features) + expect(service.valid_feature_list).to include(*enterprise_features) + end + end +end diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb index f154d684d..c2d227e78 100644 --- a/spec/factories/integrations/hooks.rb +++ b/spec/factories/integrations/hooks.rb @@ -37,5 +37,16 @@ FactoryBot.define do access_token { SecureRandom.hex } reference_id { 'test-store.myshopify.com' } end + + trait :leadsquared do + app_id { 'leadsquared' } + settings do + { + 'access_key' => SecureRandom.hex, + 'secret_key' => SecureRandom.hex, + 'endpoint_url' => 'https://api.leadsquared.com/' + } + end + end end end diff --git a/spec/jobs/crm/setup_job_spec.rb b/spec/jobs/crm/setup_job_spec.rb new file mode 100644 index 000000000..f8d7a4e18 --- /dev/null +++ b/spec/jobs/crm/setup_job_spec.rb @@ -0,0 +1,85 @@ +require 'rails_helper' + +RSpec.describe Crm::SetupJob do + subject(:job) { described_class.perform_later(hook.id) } + + let(:account) { create(:account) } + let(:hook) do + create(:integrations_hook, + account: account, + app_id: 'leadsquared', + settings: { + access_key: 'test_key', + secret_key: 'test_token', + endpoint_url: 'https://api.leadsquared.com' + }) + end + + before do + account.enable_features('crm_integration') + end + + it 'enqueues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(hook.id) + .on_queue('default') + end + + describe '#perform' do + context 'when hook is not found' do + it 'returns without processing' do + allow(Integrations::Hook).to receive(:find_by).and_return(nil) + expect(described_class.new.perform(0)).to be_nil + end + end + + context 'when hook is disabled' do + it 'returns without processing' do + disabled_hook = create(:integrations_hook, + account: account, + app_id: 'leadsquared', + status: 'disabled', + settings: { + access_key: 'test_key', + secret_key: 'test_token', + endpoint_url: 'https://api.leadsquared.com' + }) + expect(described_class.new.perform(disabled_hook.id)).to be_nil + end + end + + context 'when hook is not a CRM integration' do + it 'returns without processing' do + non_crm_hook = create(:integrations_hook, + account: account, + app_id: 'slack', + settings: { webhook_url: 'https://slack.com/webhook' }) + expect(described_class.new.perform(non_crm_hook.id)).to be_nil + end + end + + context 'when hook is valid' do + let(:setup_service) { instance_double(Crm::Leadsquared::SetupService) } + + before do + allow(Crm::Leadsquared::SetupService).to receive(:new).with(hook).and_return(setup_service) + end + + context 'when setup raises an error' do + it 'captures exception and logs error' do + error = StandardError.new('Test error') + allow(setup_service).to receive(:setup).and_raise(error) + allow(Rails.logger).to receive(:error) + allow(ChatwootExceptionTracker).to receive(:new) + .with(error, account: hook.account) + .and_return(instance_double(ChatwootExceptionTracker, capture_exception: true)) + + described_class.new.perform(hook.id) + + expect(Rails.logger).to have_received(:error) + .with("Error in CRM setup for hook ##{hook.id} (#{hook.app_id}): Test error") + end + end + end + end +end diff --git a/spec/jobs/hook_job_spec.rb b/spec/jobs/hook_job_spec.rb index d780076ff..aff68e512 100644 --- a/spec/jobs/hook_job_spec.rb +++ b/spec/jobs/hook_job_spec.rb @@ -66,4 +66,110 @@ RSpec.describe HookJob do described_class.perform_now(hook, event_name, event_data) end end + + context 'when processing leadsquared integration' do + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, contact: contact) } + let(:processor_service) { instance_double(Crm::Leadsquared::ProcessorService) } + let(:leadsquared_hook) { instance_double(Integrations::Hook, id: 123, app_id: 'leadsquared', account: account) } + + before do + allow(Crm::Leadsquared::ProcessorService).to receive(:new).with(leadsquared_hook).and_return(processor_service) + end + + context 'when processing contact.updated event' do + let(:event_name) { 'contact.updated' } + let(:event_data) { { contact: contact } } + + it 'uses a lock when processing' do + allow(leadsquared_hook).to receive(:disabled?).and_return(false) + allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true) + allow(processor_service).to receive(:handle_contact).with(contact) + + # Mock the with_lock method directly on the job instance + job_instance = described_class.new + allow(job_instance).to receive(:with_lock).and_yield + allow(described_class).to receive(:new).and_return(job_instance) + + expect(job_instance).to receive(:with_lock).with( + format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id) + ) + + job_instance.perform(leadsquared_hook, event_name, event_data) + end + + it 'does not process when feature is not allowed' do + allow(leadsquared_hook).to receive(:disabled?).and_return(false) + allow(leadsquared_hook).to receive(:feature_allowed?).and_return(false) + + job_instance = described_class.new + allow(job_instance).to receive(:with_lock) + + expect(job_instance).not_to receive(:with_lock) + expect(processor_service).not_to receive(:handle_contact) + + job_instance.perform(leadsquared_hook, event_name, event_data) + end + end + + context 'when processing conversation.created event' do + let(:event_name) { 'conversation.created' } + let(:event_data) { { conversation: conversation } } + + it 'uses a lock when processing' do + allow(leadsquared_hook).to receive(:disabled?).and_return(false) + allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true) + allow(processor_service).to receive(:handle_conversation_created).with(conversation) + + job_instance = described_class.new + allow(job_instance).to receive(:with_lock).and_yield + allow(described_class).to receive(:new).and_return(job_instance) + + expect(job_instance).to receive(:with_lock).with( + format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id) + ) + + job_instance.perform(leadsquared_hook, event_name, event_data) + end + end + + context 'when processing conversation.resolved event' do + let(:event_name) { 'conversation.resolved' } + let(:event_data) { { conversation: conversation } } + + it 'uses a lock when processing' do + allow(leadsquared_hook).to receive(:disabled?).and_return(false) + allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true) + allow(processor_service).to receive(:handle_conversation_resolved).with(conversation) + + job_instance = described_class.new + allow(job_instance).to receive(:with_lock).and_yield + allow(described_class).to receive(:new).and_return(job_instance) + + expect(job_instance).to receive(:with_lock).with( + format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id) + ) + + job_instance.perform(leadsquared_hook, event_name, event_data) + end + end + + context 'when processing invalid event' do + let(:event_name) { 'invalid.event' } + let(:event_data) { { contact: contact } } + + it 'does not process for invalid event names' do + allow(leadsquared_hook).to receive(:disabled?).and_return(false) + allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true) + + job_instance = described_class.new + allow(job_instance).to receive(:with_lock) + + expect(job_instance).not_to receive(:with_lock) + expect(processor_service).not_to receive(:handle_contact) + + job_instance.perform(leadsquared_hook, event_name, event_data) + end + end + end end diff --git a/spec/jobs/internal/process_stale_contacts_job_spec.rb b/spec/jobs/internal/process_stale_contacts_job_spec.rb index 30648ce9a..412ee06cc 100644 --- a/spec/jobs/internal/process_stale_contacts_job_spec.rb +++ b/spec/jobs/internal/process_stale_contacts_job_spec.rb @@ -4,11 +4,13 @@ RSpec.describe Internal::ProcessStaleContactsJob do subject(:job) { described_class.perform_later } it 'enqueues the job' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) expect { job }.to have_enqueued_job(described_class) .on_queue('scheduled_jobs') end it 'enqueues RemoveStaleContactsJob for each account' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) account1 = create(:account) account2 = create(:account) account3 = create(:account) @@ -25,10 +27,20 @@ RSpec.describe Internal::ProcessStaleContactsJob do end it 'processes accounts in batches' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) account = create(:account) allow(Account).to receive(:find_in_batches).with(batch_size: 100).and_yield([account]) expect(Internal::RemoveStaleContactsJob).to receive(:perform_later).with(account) described_class.perform_now end + + it 'does not process accounts when not in cloud environment' do + allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false) + create(:account) + + expect(Account).not_to receive(:find_in_batches) + expect(Internal::RemoveStaleContactsJob).not_to receive(:perform_later) + described_class.perform_now + end end diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb index aecb2981c..098e8b8c3 100644 --- a/spec/models/integrations/hook_spec.rb +++ b/spec/models/integrations/hook_spec.rb @@ -51,4 +51,85 @@ RSpec.describe Integrations::Hook do expect(openai_double).to have_received(:perform) end end + + describe 'scopes' do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let!(:account_hook) { create(:integrations_hook, account: account, app_id: 'webhook') } + let!(:inbox_hook) do + create(:integrations_hook, + account: account, + app_id: 'dialogflow', + inbox: inbox, + settings: { + project_id: 'test-project', + credentials: { type: 'service_account' } + }) + end + + it 'returns account hooks' do + expect(described_class.account_hooks).to include(account_hook) + expect(described_class.account_hooks).not_to include(inbox_hook) + end + + it 'returns inbox hooks' do + expect(described_class.inbox_hooks).to include(inbox_hook) + expect(described_class.inbox_hooks).not_to include(account_hook) + end + end + + describe '#crm_integration?' do + let(:account) { create(:account) } + + before do + account.enable_features('crm_integration') + end + + it 'returns true for leadsquared integration' do + hook = create(:integrations_hook, + account: account, + app_id: 'leadsquared', + settings: { + access_key: 'test', + secret_key: 'test', + endpoint_url: 'https://api.leadsquared.com' + }) + expect(hook.send(:crm_integration?)).to be true + end + + it 'returns false for non-crm integrations' do + hook = create(:integrations_hook, account: account, app_id: 'slack') + expect(hook.send(:crm_integration?)).to be false + end + end + + describe '#trigger_setup_if_crm' do + let(:account) { create(:account) } + + before do + account.enable_features('crm_integration') + allow(Crm::SetupJob).to receive(:perform_later) + end + + context 'when integration is a CRM' do + it 'enqueues setup job' do + create(:integrations_hook, + account: account, + app_id: 'leadsquared', + settings: { + access_key: 'test', + secret_key: 'test', + endpoint_url: 'https://api.leadsquared.com' + }) + expect(Crm::SetupJob).to have_received(:perform_later) + end + end + + context 'when integration is not a CRM' do + it 'does not enqueue setup job' do + create(:integrations_hook, account: account, app_id: 'slack') + expect(Crm::SetupJob).not_to have_received(:perform_later) + end + end + end end diff --git a/spec/services/crm/leadsquared/api/activity_client_spec.rb b/spec/services/crm/leadsquared/api/activity_client_spec.rb new file mode 100644 index 000000000..61c64cc00 --- /dev/null +++ b/spec/services/crm/leadsquared/api/activity_client_spec.rb @@ -0,0 +1,217 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::Api::ActivityClient do + let(:credentials) do + { + access_key: SecureRandom.hex, + secret_key: SecureRandom.hex, + endpoint_url: 'https://api.leadsquared.com/' + } + end + + let(:headers) do + { + 'Content-Type': 'application/json', + 'x-LSQ-AccessKey': credentials[:access_key], + 'x-LSQ-SecretKey': credentials[:secret_key] + } + end + let(:client) { described_class.new(credentials[:access_key], credentials[:secret_key], credentials[:endpoint_url]) } + let(:prospect_id) { SecureRandom.uuid } + let(:activity_event) { 1001 } # Example activity event code + let(:activity_note) { 'Test activity note' } + let(:activity_date_time) { '2025-04-11 14:15:00' } + + describe '#post_activity' do + let(:path) { '/ProspectActivity.svc/Create' } + let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s } + + context 'with missing required parameters' do + it 'raises ArgumentError when prospect_id is missing' do + expect { client.post_activity(nil, activity_event, activity_note) } + .to raise_error(ArgumentError, 'Prospect ID is required') + end + + it 'raises ArgumentError when activity_event is missing' do + expect { client.post_activity(prospect_id, nil, activity_note) } + .to raise_error(ArgumentError, 'Activity event code is required') + end + end + + context 'when request is successful' do + let(:activity_id) { SecureRandom.uuid } + let(:success_response) do + { + 'Status' => 'Success', + 'Message' => { + 'Id' => activity_id, + 'Message' => 'Activity created successfully' + } + } + end + + before do + stub_request(:post, full_url) + .with( + body: { + 'RelatedProspectId' => prospect_id, + 'ActivityEvent' => activity_event, + 'ActivityNote' => activity_note + }.to_json, + headers: headers + ) + .to_return( + status: 200, + body: success_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns activity ID directly' do + response = client.post_activity(prospect_id, activity_event, activity_note) + expect(response).to eq(activity_id) + end + end + + context 'when response indicates failure' do + let(:error_response) do + { + 'Status' => 'Error', + 'ExceptionType' => 'NullReferenceException', + 'ExceptionMessage' => 'There was an error processing the request.' + } + end + + before do + stub_request(:post, full_url) + .with( + body: { + 'RelatedProspectId' => prospect_id, + 'ActivityEvent' => activity_event, + 'ActivityNote' => activity_note + }.to_json, + headers: headers + ) + .to_return( + status: 200, + body: error_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError when activity creation fails' do + expect { client.post_activity(prospect_id, activity_event, activity_note) } + .to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError) + end + end + end + + describe '#create_activity_type' do + let(:path) { 'ProspectActivity.svc/CreateType' } + let(:full_url) { URI.join(credentials[:endpoint_url], path).to_s } + let(:activity_params) do + { + name: 'Test Activity Type', + score: 10, + direction: 0 + } + end + + context 'with missing required parameters' do + it 'raises ArgumentError when name is missing' do + expect { client.create_activity_type(name: nil, score: 10) } + .to raise_error(ArgumentError, 'Activity name is required') + end + end + + context 'when request is successful' do + let(:activity_event_id) { 1001 } + let(:success_response) do + { + 'Status' => 'Success', + 'Message' => { + 'Id' => activity_event_id + } + } + end + + before do + stub_request(:post, full_url) + .with( + body: { + 'ActivityEventName' => activity_params[:name], + 'Score' => activity_params[:score], + 'Direction' => activity_params[:direction] + }.to_json, + headers: headers + ) + .to_return( + status: 200, + body: success_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns activity ID directly' do + response = client.create_activity_type(**activity_params) + expect(response).to eq(activity_event_id) + end + end + + context 'when response indicates failure' do + let(:error_response) do + { + 'Status' => 'Error', + 'ExceptionType' => 'MXInvalidInputException', + 'ExceptionMessage' => 'Invalid Input! Parameter Name: activity' + } + end + + before do + stub_request(:post, full_url) + .with( + body: { + 'ActivityEventName' => activity_params[:name], + 'Score' => activity_params[:score], + 'Direction' => activity_params[:direction] + }.to_json, + headers: headers + ) + .to_return( + status: 200, + body: error_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError when activity type creation fails' do + expect { client.create_activity_type(**activity_params) } + .to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError) + end + end + + context 'when API request fails' do + before do + stub_request(:post, full_url) + .with( + body: { + 'ActivityEventName' => activity_params[:name], + 'Score' => activity_params[:score], + 'Direction' => activity_params[:direction] + }.to_json, + headers: headers + ) + .to_return( + status: 500, + body: 'Internal Server Error', + headers: { 'Content-Type' => 'text/plain' } + ) + end + + it 'raises ApiError when the request fails' do + expect { client.create_activity_type(**activity_params) } + .to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError) + end + end + end +end diff --git a/spec/services/crm/leadsquared/api/base_client_spec.rb b/spec/services/crm/leadsquared/api/base_client_spec.rb new file mode 100644 index 000000000..e88fd04c5 --- /dev/null +++ b/spec/services/crm/leadsquared/api/base_client_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::Api::BaseClient do + let(:access_key) { SecureRandom.hex } + let(:secret_key) { SecureRandom.hex } + let(:headers) do + { + 'Content-Type': 'application/json', + 'x-LSQ-AccessKey': access_key, + 'x-LSQ-SecretKey': secret_key + } + end + + let(:endpoint_url) { 'https://api.leadsquared.com/v2' } + let(:client) { described_class.new(access_key, secret_key, endpoint_url) } + + describe '#initialize' do + it 'creates a client with valid credentials' do + expect(client.instance_variable_get(:@access_key)).to eq(access_key) + expect(client.instance_variable_get(:@secret_key)).to eq(secret_key) + expect(client.instance_variable_get(:@base_uri)).to eq(endpoint_url) + end + end + + describe '#get' do + let(:path) { 'LeadManagement.svc/Leads.Get' } + let(:params) { { leadId: '123' } } + let(:full_url) { URI.join(endpoint_url, path).to_s } + + context 'when request is successful' do + before do + stub_request(:get, full_url) + .with( + query: params, + headers: headers + ) + .to_return( + status: 200, + body: { Message: 'Success', Status: 'Success' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns parsed response data directly' do + response = client.get(path, params) + expect(response).to include('Message' => 'Success') + expect(response).to include('Status' => 'Success') + end + end + + context 'when request returns error status' do + before do + stub_request(:get, full_url) + .with( + query: params, + headers: headers + ) + .to_return( + status: 200, + body: { Status: 'Error', ExceptionMessage: 'Invalid lead ID' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError with error message' do + expect { client.get(path, params) }.to raise_error( + Crm::Leadsquared::Api::BaseClient::ApiError, + 'Invalid lead ID' + ) + end + end + + context 'when request fails with non-200 status' do + before do + stub_request(:get, full_url) + .with( + query: params, + headers: headers + ) + .to_return(status: 404, body: 'Not Found') + end + + it 'raises ApiError with status code' do + expect { client.get(path, params) }.to raise_error do |error| + expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError) + expect(error.message).to include('Not Found') + expect(error.code).to eq(404) + end + end + end + end + + describe '#post' do + let(:path) { 'LeadManagement.svc/Lead.Create' } + let(:params) { {} } + let(:body) { { FirstName: 'John', LastName: 'Doe' } } + let(:full_url) { URI.join(endpoint_url, path).to_s } + + context 'when request is successful' do + before do + stub_request(:post, full_url) + .with( + query: params, + body: body.to_json, + headers: headers + ) + .to_return( + status: 200, + body: { Message: 'Lead created', Status: 'Success' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns parsed response data directly' do + response = client.post(path, params, body) + expect(response).to include('Message' => 'Lead created') + expect(response).to include('Status' => 'Success') + end + end + + context 'when request returns error status' do + before do + stub_request(:post, full_url) + .with( + query: params, + body: body.to_json, + headers: headers + ) + .to_return( + status: 200, + body: { Status: 'Error', ExceptionMessage: 'Invalid data' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError with error message' do + expect { client.post(path, params, body) }.to raise_error( + Crm::Leadsquared::Api::BaseClient::ApiError, + 'Invalid data' + ) + end + end + + context 'when response cannot be parsed' do + before do + stub_request(:post, full_url) + .with( + query: params, + body: body.to_json, + headers: headers + ) + .to_return( + status: 200, + body: 'Invalid JSON', + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError for invalid JSON' do + expect { client.post(path, params, body) }.to raise_error do |error| + expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError) + expect(error.message).to include('Failed to parse') + end + end + end + + context 'when request fails with server error' do + before do + stub_request(:post, full_url) + .with( + query: params, + body: body.to_json, + headers: headers + ) + .to_return(status: 500, body: 'Internal Server Error') + end + + it 'raises ApiError with status code' do + expect { client.post(path, params, body) }.to raise_error do |error| + expect(error).to be_a(Crm::Leadsquared::Api::BaseClient::ApiError) + expect(error.message).to include('Internal Server Error') + expect(error.code).to eq(500) + end + end + end + end +end diff --git a/spec/services/crm/leadsquared/api/lead_client_spec.rb b/spec/services/crm/leadsquared/api/lead_client_spec.rb new file mode 100644 index 000000000..e1007fac5 --- /dev/null +++ b/spec/services/crm/leadsquared/api/lead_client_spec.rb @@ -0,0 +1,231 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::Api::LeadClient do + let(:access_key) { SecureRandom.hex } + let(:secret_key) { SecureRandom.hex } + let(:headers) do + { + 'Content-Type': 'application/json', + 'x-LSQ-AccessKey': access_key, + 'x-LSQ-SecretKey': secret_key + } + end + + let(:endpoint_url) { 'https://api.leadsquared.com/v2' } + let(:client) { described_class.new(access_key, secret_key, endpoint_url) } + + describe '#search_lead' do + let(:path) { 'LeadManagement.svc/Leads.GetByQuickSearch' } + let(:search_key) { 'test@example.com' } + let(:full_url) { URI.join(endpoint_url, path).to_s } + + context 'when search key is missing' do + it 'raises ArgumentError' do + expect { client.search_lead(nil) } + .to raise_error(ArgumentError, 'Search key is required') + end + end + + context 'when no leads are found' do + before do + stub_request(:get, full_url) + .with(query: { key: search_key }, headers: headers) + .to_return( + status: 200, + body: [].to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns empty array directly' do + response = client.search_lead(search_key) + expect(response).to eq([]) + end + end + + context 'when leads are found' do + let(:lead_data) do + [{ + 'ProspectID' => SecureRandom.uuid, + 'FirstName' => 'John', + 'LastName' => 'Doe', + 'EmailAddress' => search_key + }] + end + + before do + stub_request(:get, full_url) + .with(query: { key: search_key }, headers: headers, body: anything) + .to_return( + status: 200, + body: lead_data.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns lead data array directly' do + response = client.search_lead(search_key) + expect(response).to eq(lead_data) + end + end + end + + describe '#create_or_update_lead' do + let(:path) { 'LeadManagement.svc/Lead.CreateOrUpdate' } + let(:full_url) { URI.join(endpoint_url, path).to_s } + let(:lead_data) do + { + 'FirstName' => 'John', + 'LastName' => 'Doe', + 'EmailAddress' => 'john.doe@example.com' + } + end + let(:formatted_lead_data) do + lead_data.map do |key, value| + { + 'Attribute' => key, + 'Value' => value + } + end + end + + context 'when lead data is missing' do + it 'raises ArgumentError' do + expect { client.create_or_update_lead(nil) } + .to raise_error(ArgumentError, 'Lead data is required') + end + end + + context 'when lead is successfully created' do + let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' } + let(:success_response) do + { + 'Status' => 'Success', + 'Message' => { + 'Id' => lead_id + } + } + end + + before do + stub_request(:post, full_url) + .with( + body: formatted_lead_data.to_json, + headers: headers + ) + .to_return( + status: 200, + body: success_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns lead ID directly' do + response = client.create_or_update_lead(lead_data) + expect(response).to eq(lead_id) + end + end + + context 'when request fails' do + let(:error_response) do + { + 'Status' => 'Error', + 'ExceptionMessage' => 'Error message' + } + end + + before do + stub_request(:post, full_url) + .with( + body: formatted_lead_data.to_json, + headers: headers + ) + .to_return( + status: 200, + body: error_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError' do + expect { client.create_or_update_lead(lead_data) } + .to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError) + end + end + + # Add test for update_lead method + describe '#update_lead' do + let(:path) { 'LeadManagement.svc/Lead.Update' } + let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' } + let(:full_url) { URI.join(endpoint_url, "#{path}?leadId=#{lead_id}").to_s } + + context 'with missing parameters' do + it 'raises ArgumentError when lead_id is missing' do + expect { client.update_lead(lead_data, nil) } + .to raise_error(ArgumentError, 'Lead ID is required') + end + + it 'raises ArgumentError when lead_data is missing' do + expect { client.update_lead(nil, lead_id) } + .to raise_error(ArgumentError, 'Lead data is required') + end + end + + context 'when update is successful' do + let(:success_response) do + { + 'Status' => 'Success', + 'Message' => { + 'AffectedRows' => 1 + } + } + end + + before do + stub_request(:post, full_url) + .with( + body: formatted_lead_data.to_json, + headers: headers + ) + .to_return( + status: 200, + body: success_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'returns affected rows directly' do + response = client.update_lead(lead_data, lead_id) + expect(response).to eq(1) + end + end + + context 'when update fails' do + let(:error_response) do + { + 'Status' => 'Error', + 'ExceptionMessage' => 'Invalid lead ID' + } + end + + before do + stub_request(:post, full_url) + .with( + body: formatted_lead_data.to_json, + headers: headers + ) + .to_return( + status: 200, + body: error_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'raises ApiError' do + expect { client.update_lead(lead_data, lead_id) } + .to raise_error(Crm::Leadsquared::Api::BaseClient::ApiError) + end + end + end + end +end diff --git a/spec/services/crm/leadsquared/lead_finder_service_spec.rb b/spec/services/crm/leadsquared/lead_finder_service_spec.rb new file mode 100644 index 000000000..56baf5f9b --- /dev/null +++ b/spec/services/crm/leadsquared/lead_finder_service_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::LeadFinderService do + let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) } + let(:service) { described_class.new(lead_client) } + let(:contact) { create(:contact, email: 'test@example.com', phone_number: '+1234567890') } + + describe '#find_or_create' do + context 'when contact has stored lead ID' do + before do + contact.additional_attributes = { 'external' => { 'leadsquared_id' => '123' } } + contact.save! + end + + it 'returns the stored lead ID' do + result = service.find_or_create(contact) + expect(result).to eq('123') + end + end + + context 'when contact has no stored lead ID' do + context 'when lead is found by email' do + before do + allow(lead_client).to receive(:search_lead) + .with(contact.email) + .and_return([{ 'ProspectID' => '456' }]) + end + + it 'returns the found lead ID' do + result = service.find_or_create(contact) + expect(result).to eq('456') + end + end + + context 'when lead is found by phone' do + before do + allow(lead_client).to receive(:search_lead) + .with(contact.email) + .and_return([]) + + allow(lead_client).to receive(:search_lead) + .with(contact.phone_number) + .and_return([{ 'ProspectID' => '789' }]) + end + + it 'returns the found lead ID' do + result = service.find_or_create(contact) + expect(result).to eq('789') + end + end + + context 'when lead is not found and needs to be created' do + before do + allow(lead_client).to receive(:search_lead) + .with(contact.email) + .and_return([]) + + allow(lead_client).to receive(:search_lead) + .with(contact.phone_number) + .and_return([]) + + allow(lead_client).to receive(:create_or_update_lead) + .with(Crm::Leadsquared::Mappers::ContactMapper.map(contact)) + .and_return('999') + end + + it 'creates a new lead and returns its ID' do + result = service.find_or_create(contact) + expect(result).to eq('999') + end + end + + context 'when lead creation fails' do + before do + allow(lead_client).to receive(:search_lead) + .with(contact.email) + .and_return([]) + + allow(lead_client).to receive(:search_lead) + .with(contact.phone_number) + .and_return([]) + + allow(Crm::Leadsquared::Mappers::ContactMapper).to receive(:map) + .with(contact) + .and_return({}) + + allow(lead_client).to receive(:create_or_update_lead) + .with({}) + .and_raise(StandardError, 'Failed to create lead') + end + + it 'raises an error' do + expect { service.find_or_create(contact) }.to raise_error(StandardError, 'Failed to create lead') + end + end + end + end +end diff --git a/spec/services/crm/leadsquared/mappers/contact_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/contact_mapper_spec.rb new file mode 100644 index 000000000..4e7c06fdb --- /dev/null +++ b/spec/services/crm/leadsquared/mappers/contact_mapper_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::Mappers::ContactMapper do + let(:account) { create(:account) } + let(:contact) { create(:contact, account: account, name: '', last_name: '', country_code: '') } + let(:brand_name) { 'Test Brand' } + + before do + allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => brand_name }) + end + + describe '.map' do + context 'with basic attributes' do + it 'maps basic contact attributes correctly' do + contact.update!( + name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone_number: '+1234567890' + ) + + mapped_data = described_class.map(contact) + + expect(mapped_data).to include( + 'FirstName' => 'John', + 'LastName' => 'Doe', + 'EmailAddress' => 'john@example.com', + 'Mobile' => '+1234567890', + 'Source' => 'Test Brand' + ) + end + end + end +end diff --git a/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb new file mode 100644 index 000000000..85bb08d74 --- /dev/null +++ b/spec/services/crm/leadsquared/mappers/conversation_mapper_spec.rb @@ -0,0 +1,210 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account, name: 'Test Inbox', channel_type: 'Channel') } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:user) { create(:user, name: 'John Doe') } + let(:contact) { create(:contact, name: 'Jane Smith') } + + before do + allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' }) + end + + describe '.map_conversation_activity' do + it 'generates conversation activity note' do + travel_to(Time.zone.parse('2024-01-01 10:00:00')) do + result = described_class.map_conversation_activity(conversation) + + expect(result).to include('New conversation started on TestBrand') + expect(result).to include('Channel: Test Inbox') + expect(result).to include('Created: 2024-01-01 10:00:00') + expect(result).to include("Conversation ID: #{conversation.display_id}") + expect(result).to include('View in TestBrand: http://') + end + end + end + + describe '.map_transcript_activity' do + context 'when conversation has no messages' do + it 'returns no messages message' do + result = described_class.map_transcript_activity(conversation) + expect(result).to eq('No messages in conversation') + end + end + + context 'when conversation has messages' do + let(:message1) do + create(:message, + conversation: conversation, + sender: user, + content: 'Hello', + message_type: :outgoing, + created_at: Time.zone.parse('2024-01-01 10:00:00')) + end + + let(:message2) do + create(:message, + conversation: conversation, + sender: contact, + content: 'Hi there', + message_type: :incoming, + created_at: Time.zone.parse('2024-01-01 10:01:00')) + end + + let(:system_message) do + create(:message, + conversation: conversation, + sender: nil, + content: 'System Message', + message_type: :activity, + created_at: Time.zone.parse('2024-01-01 10:02:00')) + end + + before do + message1 + message2 + system_message + end + + it 'generates transcript with messages in reverse chronological order' do + result = described_class.map_transcript_activity(conversation) + + expect(result).to include('Conversation Transcript from TestBrand') + expect(result).to include('Channel: Test Inbox') + + # Check that messages appear in reverse order (newest first) + message_positions = { + '[2024-01-01 10:00] John Doe: Hello' => result.index('[2024-01-01 10:00] John Doe: Hello'), + '[2024-01-01 10:01] Jane Smith: Hi there' => result.index('[2024-01-01 10:01] Jane Smith: Hi there') + } + + # Latest message (10:01) should come before older message (10:00) + expect(message_positions['[2024-01-01 10:01] Jane Smith: Hi there']).to be < message_positions['[2024-01-01 10:00] John Doe: Hello'] + end + + context 'when message has attachments' do + let(:message_with_attachment) do + create(:message, :with_attachment, + conversation: conversation, + sender: user, + content: 'See attachment', + message_type: :outgoing, + created_at: Time.zone.parse('2024-01-01 10:03:00')) + end + + before { message_with_attachment } + + it 'includes attachment information' do + result = described_class.map_transcript_activity(conversation) + + expect(result).to include('See attachment') + expect(result).to include('[Attachment: image]') + end + end + + context 'when message has empty content' do + let(:empty_message) do + create(:message, + conversation: conversation, + sender: user, + content: '', + message_type: :outgoing, + created_at: Time.zone.parse('2024-01-01 10:04')) + end + + before { empty_message } + + it 'shows no content placeholder' do + result = described_class.map_transcript_activity(conversation) + expect(result).to include('[No content]') + end + end + + context 'when sender has no name' do + let(:unnamed_sender_message) do + create(:message, + conversation: conversation, + sender: create(:user, name: ''), + content: 'Message', + message_type: :outgoing, + created_at: Time.zone.parse('2024-01-01 10:05')) + end + + before { unnamed_sender_message } + + it 'uses sender type and id' do + result = described_class.map_transcript_activity(conversation) + expect(result).to include("User #{unnamed_sender_message.sender_id}") + end + end + end + + context 'when specific messages are provided' do + let(:message1) { create(:message, conversation: conversation, content: 'Message 1', message_type: :outgoing) } + let(:message2) { create(:message, conversation: conversation, content: 'Message 2', message_type: :outgoing) } + let(:specific_messages) { [message1] } + + it 'only includes provided messages' do + result = described_class.map_transcript_activity(conversation, specific_messages) + + expect(result).to include('Message 1') + expect(result).not_to include('Message 2') + end + end + + context 'when messages exceed the ACTIVITY_NOTE_MAX_SIZE' do + it 'truncates messages to stay within the character limit' do + # Create a large number of messages with reasonably sized content + long_message_content = 'A' * 200 + messages = [] + + # Create 15 messages (which should exceed the 1800 character limit) + 15.times do |i| + messages << create(:message, + conversation: conversation, + sender: user, + content: "#{long_message_content} #{i}", + message_type: :outgoing, + created_at: Time.zone.parse("2024-01-01 #{10 + i}:00:00")) + end + + result = described_class.map_transcript_activity(conversation, messages) + + # Verify latest message is included (message 14) + expect(result).to include("[2024-01-02 00:00] John Doe: #{long_message_content} 14") + + # Calculate the expected character count of the formatted messages + messages.map do |msg| + "[#{msg.created_at.strftime('%Y-%m-%d %H:%M')}] John Doe: #{msg.content}" + end + + # Verify the result is within the character limit + expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100 + + # Verify that not all messages are included (some were truncated) + expect(messages.count).to be > result.scan('John Doe:').count + end + + it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do + # Create a single message that would exceed the limit by itself + giant_content = 'A' * 2000 + message = create(:message, + conversation: conversation, + sender: user, + content: giant_content, + message_type: :outgoing) + + result = described_class.map_transcript_activity(conversation, [message]) + + # Extract just the formatted messages part + id = conversation.display_id + prefix = "Conversation Transcript from TestBrand\nChannel: Test Inbox\nConversation ID: #{id}\nView in TestBrand: " + formatted_messages = result.sub(prefix, '').sub(%r{http://.*}, '') + + # Check that it's under the limit (with some tolerance for the message format) + expect(formatted_messages.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100 + end + end + end +end diff --git a/spec/services/crm/leadsquared/processor_service_spec.rb b/spec/services/crm/leadsquared/processor_service_spec.rb new file mode 100644 index 000000000..d51881fdc --- /dev/null +++ b/spec/services/crm/leadsquared/processor_service_spec.rb @@ -0,0 +1,233 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::ProcessorService do + let(:account) { create(:account) } + let(:hook) do + create(:integrations_hook, :leadsquared, account: account, settings: { + 'access_key' => 'test_access_key', + 'secret_key' => 'test_secret_key', + 'endpoint_url' => 'https://api.leadsquared.com/v2', + 'enable_transcript_activity' => true, + 'enable_conversation_activity' => true, + 'conversation_activity_code' => 1001, + 'transcript_activity_code' => 1002 + }) + end + let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') } + let(:contact_with_social_profile) do + create(:contact, account: account, additional_attributes: { 'social_profiles' => { 'facebook' => 'chatwootapp' } }) + end + let(:blank_contact) { create(:contact, account: account, email: '', phone_number: '') } + let(:conversation) { create(:conversation, account: account, contact: contact) } + let(:service) { described_class.new(hook) } + let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) } + let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) } + let(:lead_finder) { instance_double(Crm::Leadsquared::LeadFinderService) } + + before do + account.enable_features('crm_integration') + allow(Crm::Leadsquared::Api::LeadClient).to receive(:new) + .with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2') + .and_return(lead_client) + allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new) + .with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2') + .and_return(activity_client) + allow(Crm::Leadsquared::LeadFinderService).to receive(:new) + .with(lead_client) + .and_return(lead_finder) + end + + describe '.crm_name' do + it 'returns leadsquared' do + expect(described_class.crm_name).to eq('leadsquared') + end + end + + describe '#handle_contact' do + context 'when contact is valid' do + before do + allow(service).to receive(:identifiable_contact?).and_return(true) + end + + context 'when contact has no stored lead ID' do + before do + contact.update!(additional_attributes: { 'external' => nil }) + contact.reload + + allow(lead_client).to receive(:create_or_update_lead) + .with(any_args) + .and_return('new_lead_id') + end + + it 'creates a new lead and stores the ID' do + service.handle_contact(contact) + expect(lead_client).to have_received(:create_or_update_lead).with(any_args) + expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('new_lead_id') + end + end + + context 'when contact has existing lead ID' do + before do + contact.additional_attributes = { 'external' => { 'leadsquared_id' => 'existing_lead_id' } } + contact.save! + + allow(lead_client).to receive(:update_lead) + .with(any_args) + .and_return(nil) # The update method doesn't need to return anything + end + + it 'updates the lead using existing ID' do + service.handle_contact(contact) + expect(lead_client).to have_received(:update_lead).with(any_args) + end + end + + context 'when API call raises an error' do + before do + allow(lead_client).to receive(:create_or_update_lead) + .with(any_args) + .and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('API Error')) + + allow(Rails.logger).to receive(:error) + end + + it 'catches and logs the error' do + service.handle_contact(contact) + expect(Rails.logger).to have_received(:error).with(/LeadSquared API error/) + end + end + end + + context 'when contact is invalid' do + before do + allow(service).to receive(:identifiable_contact?).and_return(false) + allow(lead_client).to receive(:create_or_update_lead) + end + + it 'returns without making API calls' do + service.handle_contact(blank_contact) + expect(lead_client).not_to have_received(:create_or_update_lead) + end + end + end + + describe '#handle_conversation_created' do + let(:activity_note) { 'New conversation started' } + + before do + allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity) + .with(conversation) + .and_return(activity_note) + end + + context 'when conversation activities are enabled' do + before do + service.instance_variable_set(:@allow_conversation, true) + end + + context 'when lead_id is found' do + before do + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('test_lead_id') + + allow(activity_client).to receive(:post_activity) + .with('test_lead_id', 1001, activity_note) + .and_return('test_activity_id') + end + + it 'creates the activity and stores metadata' do + service.handle_conversation_created(conversation) + expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('test_activity_id') + end + end + + context 'when post_activity raises an error' do + before do + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('test_lead_id') + + allow(activity_client).to receive(:post_activity) + .with('test_lead_id', 1001, activity_note) + .and_raise(StandardError.new('Activity error')) + + allow(Rails.logger).to receive(:error) + end + + it 'logs the error' do + service.handle_conversation_created(conversation) + expect(Rails.logger).to have_received(:error).with(/Error creating conversation activity/) + end + end + end + + context 'when conversation activities are disabled' do + before do + service.instance_variable_set(:@allow_conversation, false) + allow(activity_client).to receive(:post_activity) + end + + it 'does not create an activity' do + service.handle_conversation_created(conversation) + expect(activity_client).not_to have_received(:post_activity) + end + end + end + + describe '#handle_conversation_resolved' do + let(:activity_note) { 'Conversation transcript' } + + before do + allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity) + .with(conversation) + .and_return(activity_note) + end + + context 'when transcript activities are enabled and conversation is resolved' do + before do + service.instance_variable_set(:@allow_transcript, true) + conversation.update!(status: 'resolved') + + allow(lead_finder).to receive(:find_or_create) + .with(contact) + .and_return('test_lead_id') + + allow(activity_client).to receive(:post_activity) + .with('test_lead_id', 1002, activity_note) + .and_return('test_activity_id') + end + + it 'creates the transcript activity and stores metadata' do + service.handle_conversation_resolved(conversation) + expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id') + end + end + + context 'when conversation is not resolved' do + before do + service.instance_variable_set(:@allow_transcript, true) + conversation.update!(status: 'open') + allow(activity_client).to receive(:post_activity) + end + + it 'does not create an activity' do + service.handle_conversation_resolved(conversation) + expect(activity_client).not_to have_received(:post_activity) + end + end + + context 'when transcript activities are disabled' do + before do + service.instance_variable_set(:@allow_transcript, false) + conversation.update!(status: 'resolved') + allow(activity_client).to receive(:post_activity) + end + + it 'does not create an activity' do + service.handle_conversation_resolved(conversation) + expect(activity_client).not_to have_received(:post_activity) + end + end + end +end diff --git a/spec/services/crm/leadsquared/setup_service_spec.rb b/spec/services/crm/leadsquared/setup_service_spec.rb new file mode 100644 index 000000000..1d907ecda --- /dev/null +++ b/spec/services/crm/leadsquared/setup_service_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe Crm::Leadsquared::SetupService do + let(:account) { create(:account) } + let(:hook) { create(:integrations_hook, :leadsquared, account: account) } + let(:service) { described_class.new(hook) } + let(:base_client) { instance_double(Crm::Leadsquared::Api::BaseClient) } + let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) } + let(:endpoint_response) do + { + 'LSQCommonServiceURLs' => { + 'api' => 'api-in.leadsquared.com', + 'app' => 'app.leadsquared.com' + } + } + end + + before do + account.enable_features('crm_integration') + allow(Crm::Leadsquared::Api::BaseClient).to receive(:new).and_return(base_client) + allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new).and_return(activity_client) + allow(base_client).to receive(:get).with('Authentication.svc/UserByAccessKey.Get').and_return(endpoint_response) + end + + describe '#setup' do + context 'when fetching activity types succeeds' do + let(:started_type) do + { 'ActivityEventName' => 'Chatwoot Conversation Started', 'ActivityEvent' => 1001 } + end + + let(:transcript_type) do + { 'ActivityEventName' => 'Chatwoot Conversation Transcript', 'ActivityEvent' => 1002 } + end + + context 'when all required types exist' do + before do + allow(activity_client).to receive(:fetch_activity_types) + .and_return([started_type, transcript_type]) + end + + it 'uses existing activity types and updates hook settings' do + service.setup + + # Verify hook settings were merged with existing settings + updated_settings = hook.reload.settings + expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/') + expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/') + expect(updated_settings['conversation_activity_code']).to eq(1001) + expect(updated_settings['transcript_activity_code']).to eq(1002) + end + end + + context 'when some activity types need to be created' do + before do + allow(activity_client).to receive(:fetch_activity_types) + .and_return([started_type]) + + allow(activity_client).to receive(:create_activity_type) + .with( + name: 'Chatwoot Conversation Transcript', + score: 0, + direction: 0 + ) + .and_return(1002) + end + + it 'creates missing types and updates hook settings' do + service.setup + + # Verify hook settings were merged with existing settings + updated_settings = hook.reload.settings + expect(updated_settings['endpoint_url']).to eq('https://api-in.leadsquared.com/v2/') + expect(updated_settings['app_url']).to eq('https://app.leadsquared.com/') + expect(updated_settings['conversation_activity_code']).to eq(1001) + expect(updated_settings['transcript_activity_code']).to eq(1002) + end + end + + context 'when activity type creation fails' do + before do + allow(activity_client).to receive(:fetch_activity_types) + .and_return([started_type]) + + allow(activity_client).to receive(:create_activity_type) + .with(anything) + .and_raise(StandardError.new('Failed to create activity type')) + + allow(Rails.logger).to receive(:error) + end + + it 'logs the error and returns nil' do + expect(service.setup).to be_nil + expect(Rails.logger).to have_received(:error).with(/Error during LeadSquared setup/) + end + end + end + end + + describe '#activity_types' do + it 'defines conversation started activity type' do + required_types = service.send(:activity_types) + conversation_type = required_types.find { |t| t[:setting_key] == 'conversation_activity_code' } + expect(conversation_type).to include( + name: 'Chatwoot Conversation Started', + score: 0, + direction: 0, + setting_key: 'conversation_activity_code' + ) + end + + it 'defines transcript activity type' do + required_types = service.send(:activity_types) + transcript_type = required_types.find { |t| t[:setting_key] == 'transcript_activity_code' } + expect(transcript_type).to include( + name: 'Chatwoot Conversation Transcript', + score: 0, + direction: 0, + setting_key: 'transcript_activity_code' + ) + end + end +end diff --git a/spec/services/messages/status_update_service_spec.rb b/spec/services/messages/status_update_service_spec.rb new file mode 100644 index 000000000..ce8fc2163 --- /dev/null +++ b/spec/services/messages/status_update_service_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +describe Messages::StatusUpdateService do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:message) { create(:message, conversation: conversation, account: account) } + + describe '#perform' do + context 'when status is valid' do + it 'updates the status of the message' do + service = described_class.new(message, 'delivered') + service.perform + expect(message.reload.status).to eq('delivered') + end + + it 'clears external_error when status is not failed' do + message.update!(status: 'failed', external_error: 'previous error') + service = described_class.new(message, 'delivered') + service.perform + expect(message.reload.status).to eq('delivered') + expect(message.reload.external_error).to be_nil + end + + it 'updates external_error when status is failed' do + service = described_class.new(message, 'failed', 'some error') + service.perform + expect(message.reload.status).to eq('failed') + expect(message.reload.external_error).to eq('some error') + end + end + + context 'when status is invalid' do + it 'returns false for invalid status' do + service = described_class.new(message, 'invalid_status') + expect(service.perform).to be false + end + + it 'prevents transition from read to delivered' do + message.update!(status: 'read') + service = described_class.new(message, 'delivered') + expect(service.perform).to be false + expect(message.reload.status).to eq('read') + end + end + end +end