Connected Agent Bot-handled conversation creation now records the bot as the explicit owner instead of relying on pending status alone. Closes: https://linear.app/chatwoot/issue/CW-7449/set-connected-agent-bot-as-owner-in-bot-handled-flows ## Why When an inbox has a connected Agent Bot, bot-handled conversations already move to pending for queue placement. They should also carry explicit Agent Bot ownership so agents can see that the bot is handling the conversation and later takeover/hand-back behavior has a real owner to work with. ## What changed - Sets active connected Agent Bot inbox conversations to pending. - Sets `assignee_agent_bot` to the connected Agent Bot when no explicit human assignee is present. - Clears the human `assignee` when assigning the connected Agent Bot owner. - Preserves explicit human assignees on conversation creation. - Sets bot-initiated campaign conversations in active Agent Bot inboxes to pending and owned by the connected Agent Bot. - Keeps human-sender campaign conversations open and without Agent Bot ownership. - Leaves Dialogflow pending behavior without Agent Bot ownership. - Clears `assignee_agent_bot` when the bot hands the conversation off. ## Validation - Create a conversation in an inbox with an active connected Agent Bot and verify it is pending and owned by the Agent Bot. - Create a conversation in that inbox with an explicit human assignee and verify the human assignee is preserved. - Create a bot-initiated campaign conversation in the same inbox and verify it is pending and owned by the Agent Bot. - Create a human-sender campaign conversation and verify it remains open without Agent Bot ownership. - Create a Dialogflow-handled pending conversation and verify no Agent Bot owner is set. - Trigger bot handoff and verify the Agent Bot owner is cleared.
420 lines
15 KiB
Ruby
420 lines
15 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: conversations
|
|
#
|
|
# id :integer not null, primary key
|
|
# additional_attributes :jsonb
|
|
# agent_last_seen_at :datetime
|
|
# assignee_last_seen_at :datetime
|
|
# cached_label_list :text
|
|
# contact_last_seen_at :datetime
|
|
# custom_attributes :jsonb
|
|
# first_reply_created_at :datetime
|
|
# identifier :string
|
|
# last_activity_at :datetime not null
|
|
# priority :integer
|
|
# snoozed_until :datetime
|
|
# status :integer default("open"), not null
|
|
# uuid :uuid not null
|
|
# waiting_since :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :integer not null
|
|
# assignee_agent_bot_id :bigint
|
|
# assignee_id :integer
|
|
# campaign_id :bigint
|
|
# contact_id :bigint
|
|
# contact_inbox_id :bigint
|
|
# display_id :integer not null
|
|
# inbox_id :integer not null
|
|
# sla_policy_id :bigint
|
|
# team_id :bigint
|
|
#
|
|
# Indexes
|
|
#
|
|
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
|
|
# index_conversations_on_account_id (account_id)
|
|
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
|
|
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
|
|
# index_conversations_on_campaign_id (campaign_id)
|
|
# index_conversations_on_contact_id (contact_id)
|
|
# index_conversations_on_contact_inbox_id (contact_inbox_id)
|
|
# index_conversations_on_first_reply_created_at (first_reply_created_at)
|
|
# index_conversations_on_id_and_account_id (account_id,id)
|
|
# index_conversations_on_identifier_and_account_id (identifier,account_id)
|
|
# index_conversations_on_inbox_id (inbox_id)
|
|
# index_conversations_on_priority (priority)
|
|
# index_conversations_on_status_and_account_id (status,account_id)
|
|
# index_conversations_on_status_and_priority (status,priority)
|
|
# index_conversations_on_team_id (team_id)
|
|
# index_conversations_on_uuid (uuid) UNIQUE
|
|
# index_conversations_on_waiting_since (waiting_since)
|
|
#
|
|
|
|
class Conversation < ApplicationRecord
|
|
include Labelable
|
|
include LlmFormattable
|
|
include AssignmentHandler
|
|
include AutoAssignmentHandler
|
|
include ActivityMessageHandler
|
|
include UrlHelper
|
|
include SortHandler
|
|
include PushDataHelper
|
|
include ConversationMuteHelpers
|
|
|
|
CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS = %w[conversation_language].freeze
|
|
FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS = %w[browser_language conversation_language mail_subject referer].freeze
|
|
FILTERED_UNREAD_COUNT_UPDATE_KEYS = %w[
|
|
cached_label_list campaign_id custom_attributes first_reply_created_at label_list last_activity_at priority snoozed_until waiting_since
|
|
].freeze
|
|
private_constant :CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS, :FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS,
|
|
:FILTERED_UNREAD_COUNT_UPDATE_KEYS
|
|
|
|
validates :account_id, presence: true
|
|
validates :inbox_id, presence: true
|
|
validates :contact_id, presence: true
|
|
before_validation :validate_additional_attributes
|
|
before_validation :reset_agent_bot_when_assignee_present
|
|
validates :additional_attributes, jsonb_attributes_length: true
|
|
validates :custom_attributes, jsonb_attributes_length: true
|
|
validates :uuid, uniqueness: true
|
|
validate :validate_referer_url
|
|
|
|
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
|
|
enum priority: { low: 0, medium: 1, high: 2, urgent: 3 }
|
|
|
|
scope :unassigned, -> { where(assignee_id: nil) }
|
|
scope :assigned, -> { where.not(assignee_id: nil) }
|
|
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
|
|
scope :sort_on_unread, lambda { |_direction|
|
|
order(unread_messages_count_arel.desc).sort_on_last_activity_at('desc')
|
|
}
|
|
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
|
|
scope :resolvable_not_waiting, lambda { |auto_resolve_after|
|
|
return none if auto_resolve_after.to_i.zero?
|
|
|
|
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
|
|
}
|
|
scope :resolvable_all, lambda { |auto_resolve_after|
|
|
return none if auto_resolve_after.to_i.zero?
|
|
|
|
open.where('last_activity_at < ?', Time.now.utc - auto_resolve_after.minutes)
|
|
}
|
|
|
|
scope :last_user_message_at, lambda {
|
|
joins(
|
|
"INNER JOIN (#{last_messaged_conversations.to_sql}) AS grouped_conversations
|
|
ON grouped_conversations.conversation_id = conversations.id"
|
|
).sort_on_last_user_message_at
|
|
}
|
|
|
|
belongs_to :account
|
|
belongs_to :inbox
|
|
belongs_to :assignee, class_name: 'User', optional: true, inverse_of: :assigned_conversations
|
|
belongs_to :assignee_agent_bot, class_name: 'AgentBot', optional: true
|
|
belongs_to :contact
|
|
belongs_to :contact_inbox
|
|
belongs_to :team, optional: true
|
|
belongs_to :campaign, optional: true
|
|
|
|
has_many :mentions, dependent: :destroy_async
|
|
has_many :messages, dependent: :destroy_async, autosave: true
|
|
has_one :csat_survey_response, dependent: :destroy_async
|
|
has_many :conversation_participants, dependent: :destroy_async
|
|
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
|
has_many :attachments, through: :messages
|
|
has_many :reporting_events, dependent: :destroy_async
|
|
|
|
before_save :ensure_snooze_until_reset
|
|
before_create :determine_conversation_status
|
|
before_create :ensure_waiting_since
|
|
|
|
after_update_commit :execute_after_update_commit_callbacks
|
|
after_create_commit :notify_conversation_creation
|
|
after_create_commit :load_attributes_created_by_db_triggers
|
|
before_destroy :set_unread_count_deletion_data
|
|
after_destroy_commit :notify_conversation_deletion
|
|
|
|
delegate :auto_resolve_after, to: :account
|
|
|
|
def can_reply?
|
|
Conversations::MessageWindowService.new(self).can_reply?
|
|
end
|
|
|
|
def language
|
|
additional_attributes&.dig('conversation_language')
|
|
end
|
|
|
|
# Be aware: The precision of created_at and last_activity_at may differ from Ruby's Time precision.
|
|
# Our DB column (see schema) stores timestamps with second-level precision (no microseconds), so
|
|
# if you assign a Ruby Time with microseconds, the DB will truncate it. This may cause subtle differences
|
|
# if you compare or copy these values in Ruby, also in our specs
|
|
# So in specs rely on to be_with(1.second) instead of to eq()
|
|
# TODO: Migrate to use a timestamp with microsecond precision
|
|
def last_activity_at
|
|
self[:last_activity_at] || created_at
|
|
end
|
|
|
|
def last_incoming_message
|
|
messages.where(account_id: account_id)&.incoming&.last
|
|
end
|
|
|
|
def toggle_status
|
|
# FIXME: implement state machine with aasm
|
|
self.status = open? ? :resolved : :open
|
|
self.status = :open if pending? || snoozed?
|
|
save
|
|
end
|
|
|
|
def toggle_priority(priority = nil)
|
|
self.priority = priority.presence
|
|
save
|
|
end
|
|
|
|
def bot_handoff!
|
|
update(waiting_since: Time.current) if waiting_since.blank?
|
|
self.assignee_agent_bot = nil
|
|
open!
|
|
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
|
|
end
|
|
|
|
def unread_messages
|
|
agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages
|
|
end
|
|
|
|
def assignee_unread_messages
|
|
assignee_last_seen_at.present? ? messages.created_since(assignee_last_seen_at) : messages
|
|
end
|
|
|
|
def unread_incoming_messages
|
|
unread_messages.where(account_id: account_id).incoming.last(10)
|
|
end
|
|
|
|
def cached_label_list_array
|
|
(cached_label_list || '').split(',').map(&:strip)
|
|
end
|
|
|
|
def notifiable_assignee_change?
|
|
return false unless saved_change_to_assignee_id?
|
|
return false if assignee_id.blank?
|
|
return false if self_assign?(assignee_id)
|
|
|
|
true
|
|
end
|
|
|
|
# Virtual attribute till we switch completely to polymorphic assignee
|
|
def assignee_type
|
|
return 'AgentBot' if assignee_agent_bot_id.present?
|
|
return 'User' if assignee_id.present?
|
|
|
|
nil
|
|
end
|
|
|
|
def assigned_entity
|
|
assignee_agent_bot || assignee
|
|
end
|
|
|
|
def tweet?
|
|
inbox.inbox_type == 'Twitter' && additional_attributes['type'] == 'tweet'
|
|
end
|
|
|
|
def self.unread_messages_count_arel
|
|
messages = Message.arel_table
|
|
conversations = arel_table
|
|
unread_messages = messages
|
|
.project(messages[:id].count)
|
|
.where(unread_messages_condition(messages, conversations))
|
|
|
|
Arel::Nodes::Grouping.new(unread_messages.ast)
|
|
end
|
|
|
|
def self.unread_messages_condition(messages, conversations)
|
|
messages[:conversation_id].eq(conversations[:id])
|
|
.and(messages[:account_id].eq(conversations[:account_id]))
|
|
.and(messages[:message_type].eq(Message.message_types[:incoming]))
|
|
.and(
|
|
conversations[:agent_last_seen_at].eq(nil)
|
|
.or(messages[:created_at].gt(conversations[:agent_last_seen_at]))
|
|
)
|
|
end
|
|
|
|
def recent_messages
|
|
messages.chat.last(5)
|
|
end
|
|
|
|
def csat_survey_link
|
|
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{uuid}"
|
|
end
|
|
|
|
def dispatch_conversation_updated_event(previous_changes = nil)
|
|
dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes)
|
|
end
|
|
|
|
private
|
|
|
|
def execute_after_update_commit_callbacks
|
|
handle_resolved_status_change
|
|
notify_status_change
|
|
create_activity
|
|
invalidate_filtered_unread_count_conversation
|
|
notify_conversation_updation
|
|
end
|
|
|
|
def handle_resolved_status_change
|
|
# When conversation is resolved, clear waiting_since using update_column to avoid callbacks
|
|
return unless saved_change_to_status? && status == 'resolved'
|
|
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
update_column(:waiting_since, nil)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
end
|
|
|
|
def ensure_snooze_until_reset
|
|
self.snoozed_until = nil unless snoozed?
|
|
end
|
|
|
|
def ensure_waiting_since
|
|
self.waiting_since = created_at
|
|
end
|
|
|
|
def validate_additional_attributes
|
|
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
|
|
end
|
|
|
|
def reset_agent_bot_when_assignee_present
|
|
return if assignee_id.blank?
|
|
|
|
self.assignee_agent_bot_id = nil
|
|
end
|
|
|
|
def determine_conversation_status
|
|
self.status = :resolved and return if contact.blocked?
|
|
|
|
return handle_campaign_status if campaign.present?
|
|
|
|
set_active_bot_conversation if inbox.active_bot?
|
|
end
|
|
|
|
def handle_campaign_status
|
|
set_active_bot_conversation if campaign.sender_id.nil? && inbox.active_bot?
|
|
end
|
|
|
|
def set_active_bot_conversation
|
|
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
|
|
self.status = :pending
|
|
return unless inbox.agent_bot_inbox&.active? && assignee_id.blank?
|
|
|
|
self.assignee_agent_bot = inbox.agent_bot
|
|
end
|
|
|
|
def notify_conversation_creation
|
|
dispatcher_dispatch(CONVERSATION_CREATED)
|
|
end
|
|
|
|
def notify_conversation_deletion
|
|
return if @unread_count_deletion_data.blank?
|
|
|
|
Rails.configuration.dispatcher.dispatch(CONVERSATION_DELETED, Time.zone.now, conversation_data: @unread_count_deletion_data)
|
|
end
|
|
|
|
def notify_conversation_updation
|
|
return unless previous_changes.keys.present? && allowed_keys?
|
|
|
|
dispatch_conversation_updated_event(previous_changes)
|
|
end
|
|
|
|
def list_of_keys
|
|
%w[team_id assignee_id assignee_agent_bot_id status snoozed_until custom_attributes label_list waiting_since
|
|
first_reply_created_at priority]
|
|
end
|
|
|
|
def allowed_keys?
|
|
previous_changes.keys.intersect?(list_of_keys) ||
|
|
additional_attributes_changed?(CONVERSATION_UPDATED_ADDITIONAL_ATTRIBUTE_KEYS)
|
|
end
|
|
|
|
def invalidate_filtered_unread_count_conversation
|
|
return unless filtered_unread_count_update?
|
|
|
|
::Conversations::UnreadCounts::FilteredCountInvalidator.new(account).conversation_changed!
|
|
end
|
|
|
|
def filtered_unread_count_update?
|
|
previous_changes.keys.intersect?(FILTERED_UNREAD_COUNT_UPDATE_KEYS) ||
|
|
additional_attributes_changed?(FILTERED_UNREAD_COUNT_ADDITIONAL_ATTRIBUTE_KEYS)
|
|
end
|
|
|
|
def additional_attributes_changed?(keys)
|
|
Array(previous_changes['additional_attributes']).compact.any? { |attributes| attributes.keys.intersect?(keys) }
|
|
end
|
|
|
|
def load_attributes_created_by_db_triggers
|
|
# Display id is set via a trigger in the database
|
|
# So we need to specifically fetch it after the record is created
|
|
# We can't use reload because it will clear the previous changes, which we need for the dispatcher
|
|
obj_from_db = self.class.find(id)
|
|
self[:display_id] = obj_from_db[:display_id]
|
|
self[:uuid] = obj_from_db[:uuid]
|
|
end
|
|
|
|
def notify_status_change
|
|
{
|
|
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
|
|
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
|
|
CONVERSATION_STATUS_CHANGED => -> { saved_change_to_status? },
|
|
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
|
|
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
|
|
}.each do |event, condition|
|
|
condition.call && dispatcher_dispatch(event, status_change)
|
|
end
|
|
end
|
|
|
|
def dispatcher_dispatch(event_name, changed_attributes = nil)
|
|
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
|
|
changed_attributes: changed_attributes,
|
|
performed_by: Current.executed_by)
|
|
end
|
|
|
|
def set_unread_count_deletion_data
|
|
@unread_count_deletion_data = {
|
|
id: id,
|
|
account_id: account_id,
|
|
inbox_id: inbox_id,
|
|
assignee_id: assignee_id,
|
|
team_id: team_id,
|
|
cached_label_list: cached_label_list
|
|
}
|
|
end
|
|
|
|
def conversation_status_changed_to_open?
|
|
return false unless open?
|
|
# saved_change_to_status? method only works in case of update
|
|
return true if previous_changes.key?(:id) || saved_change_to_status?
|
|
end
|
|
|
|
def create_label_change(user_name)
|
|
return unless user_name
|
|
|
|
previous_labels, current_labels = previous_changes[:label_list]
|
|
return unless (previous_labels.is_a? Array) && (current_labels.is_a? Array)
|
|
|
|
create_label_added(user_name, current_labels - previous_labels)
|
|
create_label_removed(user_name, previous_labels - current_labels)
|
|
end
|
|
|
|
def validate_referer_url
|
|
return unless additional_attributes['referer']
|
|
|
|
self['additional_attributes']['referer'] = nil unless url_valid?(additional_attributes['referer'])
|
|
end
|
|
|
|
# creating db triggers
|
|
trigger.before(:insert).for_each(:row) do
|
|
"NEW.display_id := nextval('conv_dpid_seq_' || NEW.account_id);"
|
|
end
|
|
end
|
|
|
|
Conversation.include_mod_with('Audit::Conversation')
|
|
Conversation.include_mod_with('Concerns::Conversation')
|
|
Conversation.prepend_mod_with('Conversation')
|