diff --git a/app/models/concerns/inbox_agent_availability.rb b/app/models/concerns/inbox_agent_availability.rb new file mode 100644 index 000000000..d865af689 --- /dev/null +++ b/app/models/concerns/inbox_agent_availability.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module InboxAgentAvailability + extend ActiveSupport::Concern + + def available_agents(options = {}) + options = { check_capacity: true }.merge(options) + + # Get online agent IDs + online_agent_ids = fetch_online_agent_ids + return inbox_members.none if online_agent_ids.empty? + + # Base query - only online agents + scope = build_online_agents_scope(online_agent_ids) + + # Apply filters + apply_agent_filters(scope, options) + end + + def member_ids_with_assignment_capacity + return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled? + + available_agents(check_capacity: true).pluck(:user_id) + end + + private + + def build_online_agents_scope(online_agent_ids) + inbox_members + .joins(:user) + .where(users: { id: online_agent_ids }) + .includes(:user) + end + + def apply_agent_filters(scope, options) + # Exclude specific users if requested + scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present? + + # Apply capacity filtering for enterprise accounts + scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled? + + # Apply rate limiting if implemented + scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter) + + # Exclude agents who are on leave + scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false + + scope + end + + def fetch_online_agent_ids + OnlineStatusTracker.get_available_users(account_id) + .select { |_key, value| value.eql?('online') } + .keys + .map(&:to_i) + end + + def enterprise_capacity_enabled? + defined?(Enterprise) && + account.custom_attributes&.dig('enterprise_features', 'capacity_management').present? + end + + def filter_by_capacity(inbox_members_scope) + return inbox_members_scope unless capacity_check_required? + + assignment_counts = fetch_assignment_counts + + inbox_members_scope.select do |inbox_member| + agent_has_capacity?(inbox_member, assignment_counts) + end + end + + def capacity_check_required? + defined?(Enterprise::InboxCapacityLimit) && + account.account_users.joins(:agent_capacity_policy).exists? + end + + def fetch_assignment_counts + conversations + .where(status: :open) + .where.not(assignee_id: nil) + .group(:assignee_id) + .count + end + + def agent_has_capacity?(inbox_member, assignment_counts) + user = inbox_member.user + account_user = account.account_users.find_by(user: user) + + return true unless account_user&.agent_capacity_policy_id + + capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id) + return true unless capacity_limit&.conversation_limit + + current_count = assignment_counts[user.id] || 0 + current_count < capacity_limit.conversation_limit + end + + def fetch_capacity_limit(policy_id) + Enterprise::InboxCapacityLimit + .where(agent_capacity_policy_id: policy_id) + .find_by(inbox_id: id) + end + + def filter_by_rate_limits(inbox_members_scope) + # Filter out agents who have exceeded rate limits + return inbox_members_scope unless assignment_policy&.enabled? + + inbox_members_scope.select do |inbox_member| + rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user) + rate_limiter.within_limits? + end + end + + def filter_agents_on_leave(inbox_members_scope) + return inbox_members_scope unless defined?(Enterprise::AgentLeave) + + # Filter out agents who are currently on leave + on_leave_user_ids = Enterprise::AgentLeave + .active + .where(account_id: account_id) + .pluck(:user_id) + + return inbox_members_scope if on_leave_user_ids.empty? + + inbox_members_scope.where.not(user_id: on_leave_user_ids) + end +end diff --git a/app/models/concerns/inbox_channel_types.rb b/app/models/concerns/inbox_channel_types.rb new file mode 100644 index 000000000..2d7017085 --- /dev/null +++ b/app/models/concerns/inbox_channel_types.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module InboxChannelTypes + extend ActiveSupport::Concern + + def sms? + channel_type == 'Channel::Sms' + end + + def facebook? + channel_type == 'Channel::FacebookPage' + end + + def instagram? + (facebook? || instagram_direct?) && channel.instagram_id.present? + end + + def instagram_direct? + channel_type == 'Channel::Instagram' + end + + def web_widget? + channel_type == 'Channel::WebWidget' + end + + def api? + channel_type == 'Channel::Api' + end + + def email? + channel_type == 'Channel::Email' + end + + def twilio? + channel_type == 'Channel::TwilioSms' + end + + def twitter? + channel_type == 'Channel::TwitterProfile' + end + + def whatsapp? + channel_type == 'Channel::Whatsapp' + end + + def inbox_type + channel.name + end + + def active_bot? + agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow], + status: 'enabled').count.positive? + end +end diff --git a/app/models/concerns/inbox_name_sanitization.rb b/app/models/concerns/inbox_name_sanitization.rb new file mode 100644 index 000000000..cbf466488 --- /dev/null +++ b/app/models/concerns/inbox_name_sanitization.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module InboxNameSanitization + extend ActiveSupport::Concern + + included do + before_validation :sanitize_name, unless: :new_record? + before_save :ensure_name_present + end + + # Sanitizes inbox name for balanced email provider compatibility + # ALLOWS: /'._- and Unicode letters/numbers/emojis + # REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~) + def sanitized_name + return handle_blank_name if name.blank? + + sanitized = apply_sanitization_rules(name) + return sanitized if sanitized.present? + + email? ? (display_name_from_email || '') : '' + end + + private + + def handle_blank_name + email? ? (display_name_from_email || '') : '' + end + + def sanitize_name + self.name = apply_sanitization_rules(name) if name.present? + end + + def ensure_name_present + self.name = default_name_for_blank_name if name.blank? + self.name = apply_sanitization_rules(name) if name.present? + end + + def default_name_for_blank_name + return channel.try(:bot_name) if web_widget? + + readable_name = display_name_from_email if email? + readable_name ||= 'Inbox' + "#{readable_name} #{SecureRandom.hex(4)}" + end + + def apply_sanitization_rules(name) + # Remove forbidden characters and spam-trigger symbols + # Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis) + sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '') + # Normalize whitespace + sanitized = sanitized.gsub(/\s+/, ' ') + # Remove leading and trailing non-word characters (but keep Unicode including emojis) + # Use negative lookahead to exclude emoji ranges + sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '') + sanitized.strip + end + + def display_name_from_email + email_address = channel.try(:imap_email) || channel.try(:email) + return nil unless email_address + + local_part = email_address.split('@').first + return nil unless local_part + + # Convert underscores and hyphens to spaces and capitalize each word + local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ') + end +end diff --git a/app/models/concerns/inbox_webhooks.rb b/app/models/concerns/inbox_webhooks.rb new file mode 100644 index 000000000..5ce28877c --- /dev/null +++ b/app/models/concerns/inbox_webhooks.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module InboxWebhooks + extend ActiveSupport::Concern + + def webhook_data + { + id: id, + name: name + } + end + + def callback_webhook_url + case channel_type + when 'Channel::TwilioSms' + "#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback" + when 'Channel::Sms' + "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}" + when 'Channel::Line' + "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}" + when 'Channel::Whatsapp' + "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}" + end + end +end