feat: APIs to assign agents_bots as assignee in conversations (#12836)

## Summary
- add an assignee_agent_bot_id column as an initital step to prototype
this before fully switching to polymorphic assignee
- update assignment APIs and conversation list / show endpoints to
reflect assignee as agent bot
- ensure webhook payloads contains agent bot assignee


[Codex
Task](https://chatgpt.com/codex/tasks/task_e_6912833377e48326b6641b9eee32d50f)

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose
2025-11-18 18:20:58 -08:00
committed by GitHub
parent 70c183ea6e
commit 5f2b2f4221
23 changed files with 316 additions and 52 deletions

View File

@@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :agent_bot, except: [:index, :create]
def index
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
@agent_bots = AgentBot.accessible_to(Current.account)
end
def show; end
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
private
def agent_bot
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id])
end

View File

@@ -1,7 +1,7 @@
class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController
# assigns agent/team to a conversation
def create
if params.key?(:assignee_id)
if params.key?(:assignee_id) || agent_bot_assignment?
set_agent
elsif params.key?(:team_id)
set_team
@@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
private
def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.assignee = @agent
@conversation.save!
render_agent
resource = Conversations::AssignmentService.new(
conversation: @conversation,
assignee_id: params[:assignee_id],
assignee_type: params[:assignee_type]
).perform
render_agent(resource)
end
def render_agent
if @agent.nil?
render json: nil
def render_agent(resource)
case resource
when User
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource }
when AgentBot
render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource }
else
render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent }
render json: nil
end
end
@@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
@conversation.update!(team: @team)
render json: @team
end
def agent_bot_assignment?
params[:assignee_type].to_s == 'AgentBot'
end
end

View File

@@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper
end
def account_accessible_for_bot?(account)
render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
return if @resource.account_id == account.id
return if @resource.agent_bot_inboxes.find_by(account_id: account.id)
render_unauthorized('Bot is not authorized to access this account')
end
end

View File

@@ -63,10 +63,9 @@ class ConversationApi extends ApiClient {
}
assignAgent({ conversationId, agentId }) {
return axios.post(
`${this.url}/${conversationId}/assignments?assignee_id=${agentId}`,
{}
);
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
});
}
assignTeam({ conversationId, teamId }) {

View File

@@ -92,8 +92,10 @@ describe('#ConversationAPI', () => {
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments?assignee_id=34`,
{}
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
}
);
});

View File

@@ -30,7 +30,7 @@ const assignedAgent = computed({
return currentChat.value?.meta?.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
const agentId = agent ? agent.id : null;
store.dispatch('setCurrentChatAssignee', agent);
store.dispatch('assignAgent', {
conversationId: currentChat.value?.id,

View File

@@ -84,7 +84,7 @@ export default {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : 0;
const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', agent);
this.$store
.dispatch('assignAgent', {

View File

@@ -2,61 +2,60 @@ class AgentBotListener < BaseListener
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
return unless connected_agent_bot_exist?(inbox)
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
process_webhook_bot_event(inbox.agent_bot, payload)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
return unless connected_agent_bot_exist?(inbox)
event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
process_webhook_bot_event(inbox.agent_bot, payload)
agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
def message_created(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
return unless connected_agent_bot_exist?(inbox)
return unless message.webhook_sendable?
method_name = __method__.to_s
process_message_event(method_name, inbox.agent_bot, message, event)
agent_bots_for(inbox, message.conversation).each { |agent_bot| process_message_event(method_name, agent_bot, message, event) }
end
def message_updated(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
return unless connected_agent_bot_exist?(inbox)
return unless message.webhook_sendable?
method_name = __method__.to_s
process_message_event(method_name, inbox.agent_bot, message, event)
agent_bots_for(inbox, message.conversation).each { |agent_bot| process_message_event(method_name, agent_bot, message, event) }
end
def webwidget_triggered(event)
contact_inbox = event.data[:contact_inbox]
inbox = contact_inbox.inbox
return unless connected_agent_bot_exist?(inbox)
event_name = __method__.to_s
payload = contact_inbox.webhook_data.merge(event: event_name)
payload[:event_info] = event.data[:event_info]
process_webhook_bot_event(inbox.agent_bot, payload)
agent_bots_for(inbox).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) }
end
private
def connected_agent_bot_exist?(inbox)
return if inbox.agent_bot_inbox.blank?
return unless inbox.agent_bot_inbox.active?
def agent_bots_for(inbox, conversation = nil)
bots = []
bots << conversation.assignee_agent_bot if conversation&.assignee_agent_bot.present?
inbox_bot = active_inbox_agent_bot(inbox)
bots << inbox_bot if inbox_bot.present?
bots.compact.uniq
end
true
def active_inbox_agent_bot(inbox)
return unless inbox.agent_bot_inbox&.active?
inbox.agent_bot
end
def process_message_event(method_name, agent_bot, message, _event)

View File

@@ -21,9 +21,18 @@ class AgentBot < ApplicationRecord
include AccessTokenable
include Avatarable
scope :accessible_to, lambda { |account|
account_id = account&.id
where(account_id: [nil, account_id])
}
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :assigned_conversations, class_name: 'Conversation',
foreign_key: :assignee_agent_bot_id,
dependent: :nullify,
inverse_of: :assignee_agent_bot
belongs_to :account, optional: true
enum bot_type: { webhook: 0 }

View File

@@ -20,6 +20,7 @@
# 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
@@ -65,6 +66,7 @@ class Conversation < ApplicationRecord
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
@@ -98,6 +100,7 @@ class Conversation < ApplicationRecord
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
@@ -180,6 +183,18 @@ class Conversation < ApplicationRecord
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
@@ -226,6 +241,12 @@ class Conversation < ApplicationRecord
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?
@@ -251,8 +272,8 @@ class Conversation < ApplicationRecord
end
def list_of_keys
%w[team_id assignee_id status snoozed_until custom_attributes label_list waiting_since first_reply_created_at
priority]
%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?

View File

@@ -30,7 +30,8 @@ class Conversations::EventDataPresenter < SimpleDelegator
def push_meta
{
sender: contact.push_event_data,
assignee: assignee&.push_event_data,
assignee: assigned_entity&.push_event_data,
assignee_type: assignee_type,
team: team&.push_event_data,
hmac_verified: contact_inbox&.hmac_verified
}

View File

@@ -0,0 +1,43 @@
class Conversations::AssignmentService
def initialize(conversation:, assignee_id:, assignee_type: nil)
@conversation = conversation
@assignee_id = assignee_id
@assignee_type = assignee_type
end
def perform
agent_bot_assignment? ? assign_agent_bot : assign_agent
end
private
attr_reader :conversation, :assignee_id, :assignee_type
def assign_agent
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.save!
assignee
end
def assign_agent_bot
return unless agent_bot
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.save!
agent_bot
end
def assignee
@assignee ||= conversation.account.users.find_by(id: assignee_id)
end
def agent_bot
@agent_bot ||= AgentBot.accessible_to(conversation.account).find_by(id: assignee_id)
end
def agent_bot_assignment?
assignee_type.to_s == 'AgentBot'
end
end

View File

@@ -1,4 +0,0 @@
json.payload do
json.assignee @conversation.assignee
json.conversation_id @conversation.display_id
end

View File

@@ -7,10 +7,16 @@ json.meta do
json.partial! 'api/v1/models/contact', formats: [:json], resource: conversation.contact
end
json.channel conversation.inbox.try(:channel_type)
if conversation.assignee&.account
if conversation.assigned_entity.is_a?(AgentBot)
json.assignee do
json.partial! 'api/v1/models/agent', formats: [:json], resource: conversation.assignee
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: conversation.assigned_entity
end
json.assignee_type 'AgentBot'
elsif conversation.assigned_entity&.account
json.assignee do
json.partial! 'api/v1/models/agent', formats: [:json], resource: conversation.assigned_entity
end
json.assignee_type 'User'
end
if conversation.team.present?
json.team do

View File

@@ -0,0 +1,6 @@
json.id resource.id
json.name resource.name
json.description resource.description
json.thumbnail resource.avatar_url
json.outgoing_url resource.outgoing_url unless resource.system_bot?
json.bot_type resource.bot_type