mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
feat: Implement UI for Agent Bots in settings and remove CSML support (#11276)
- Add agent bots management UI in settings with avatar upload - Enable agent bot configuration for all inbox types - Implement proper CRUD operations with webhook URL support - Fix agent bots menu item visibility in settings sidebar - Remove all CSML-related code and features - Add migration to convert existing CSML bots to webhook bots - Simplify agent bot model and services to focus on webhook bots - Improve UI to differentiate between system bots and account bots ## Video https://github.com/user-attachments/assets/3f4edbb7-b758-468c-8dd6-a9537b983f7d --------- Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
@@ -28,6 +28,31 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).to include(agent_bot.access_token.token)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'properly differentiates between system bots and account bots' do
|
||||
global_bot = create(:agent_bot)
|
||||
get "/api/v1/accounts/#{account.id}/agent_bots",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
response_data = response.parsed_body
|
||||
# Find the global bot in the response
|
||||
global_bot_response = response_data.find { |bot| bot['id'] == global_bot.id }
|
||||
# Find the account bot in the response
|
||||
account_bot_response = response_data.find { |bot| bot['id'] == agent_bot.id }
|
||||
|
||||
# Verify system_bot attribute and outgoing_url for global bot
|
||||
expect(global_bot_response['system_bot']).to be(true)
|
||||
expect(global_bot_response).not_to include('outgoing_url')
|
||||
|
||||
# Verify account bot has system_bot attribute false and includes outgoing_url
|
||||
expect(account_bot_response['system_bot']).to be(false)
|
||||
expect(account_bot_response).to include('outgoing_url')
|
||||
|
||||
# Verify both bots have thumbnail field
|
||||
expect(global_bot_response).to include('thumbnail')
|
||||
expect(account_bot_response).to include('thumbnail')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +85,10 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(global_bot.name)
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
|
||||
# Test for system_bot attribute and webhook URL not being exposed
|
||||
expect(response.parsed_body['system_bot']).to be(true)
|
||||
expect(response.parsed_body).not_to include('outgoing_url')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -142,7 +171,7 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response.body).not_to include(global_bot.access_token.token)
|
||||
end
|
||||
|
||||
it 'updates avatar' do
|
||||
it 'updates avatar and includes thumbnail in response' do
|
||||
# no avatar before upload
|
||||
expect(agent_bot.avatar.attached?).to be(false)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
@@ -153,6 +182,9 @@ RSpec.describe 'Agent Bot API', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
agent_bot.reload
|
||||
expect(agent_bot.avatar.attached?).to be(true)
|
||||
|
||||
# Verify thumbnail is included in the response
|
||||
expect(response.parsed_body).to include('thumbnail')
|
||||
end
|
||||
|
||||
it 'updated avatar with avatar_url' do
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentBots::CsmlJob do
|
||||
it 'runs csml processor service' do
|
||||
event = 'message.created'
|
||||
message = create(:message)
|
||||
agent_bot = create(:agent_bot)
|
||||
processor = double
|
||||
|
||||
allow(Integrations::Csml::ProcessorService).to receive(:new).and_return(processor)
|
||||
allow(processor).to receive(:perform)
|
||||
|
||||
described_class.perform_now(event, agent_bot, message)
|
||||
|
||||
expect(Integrations::Csml::ProcessorService)
|
||||
.to have_received(:new)
|
||||
.with(event_name: event, agent_bot: agent_bot, event_data: { message: message })
|
||||
end
|
||||
end
|
||||
@@ -1,99 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CsmlEngine do
|
||||
it 'raises an exception if host and api is absent' do
|
||||
expect { described_class.new }.to raise_error(StandardError)
|
||||
end
|
||||
|
||||
context 'when CSML_BOT_HOST & CSML_BOT_API_KEY is present' do
|
||||
before do
|
||||
create(:installation_config, { name: 'CSML_BOT_HOST', value: 'https://csml.chatwoot.dev' })
|
||||
create(:installation_config, { name: 'CSML_BOT_API_KEY', value: 'random_api_key' })
|
||||
end
|
||||
|
||||
let(:csml_request) { double }
|
||||
|
||||
context 'when status is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'engine_version': '1.11.1' })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'engine_version': '1.11.1' })
|
||||
end
|
||||
|
||||
it 'returns error if client response is invalid' do
|
||||
allow(HTTParty).to receive(:get).and_return(csml_request)
|
||||
allow(csml_request).to receive(:success?).and_return(false)
|
||||
allow(csml_request).to receive(:code).and_return(401)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'error': true })
|
||||
|
||||
response = described_class.new.status
|
||||
|
||||
expect(HTTParty).to have_received(:get).with('https://csml.chatwoot.dev/status')
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(response).to eq({ error: { 'error': true }, status: 401 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when run is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
response = described_class.new.run({ flow: 'default' }, { client: 'client', payload: { id: 1 }, metadata: {} })
|
||||
|
||||
payload = {
|
||||
bot: { flow: 'default' },
|
||||
event: {
|
||||
request_id: 'xxxx-yyyy-wwww-cccc',
|
||||
client: 'client',
|
||||
payload: { id: 1 },
|
||||
metadata: {},
|
||||
ttl_duration: 4000
|
||||
}
|
||||
}
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/run', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when validate is called' do
|
||||
it 'returns api response if client response is valid' do
|
||||
allow(HTTParty).to receive(:post).and_return(csml_request)
|
||||
allow(SecureRandom).to receive(:uuid).and_return('xxxx-yyyy-wwww-cccc')
|
||||
allow(csml_request).to receive(:success?).and_return(true)
|
||||
allow(csml_request).to receive(:parsed_response).and_return({ 'success': true })
|
||||
|
||||
payload = { flow: 'default' }
|
||||
response = described_class.new.validate(payload)
|
||||
|
||||
expect(HTTParty).to have_received(:post)
|
||||
.with(
|
||||
'https://csml.chatwoot.dev/validate', {
|
||||
body: payload.to_json,
|
||||
headers: { 'X-Api-Key' => 'random_api_key', 'Content-Type' => 'application/json' }
|
||||
}
|
||||
)
|
||||
expect(csml_request).to have_received(:success?)
|
||||
expect(csml_request).to have_received(:parsed_response)
|
||||
expect(response).to eq({ 'success': true })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,108 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Csml::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:agent_bot) { create(:agent_bot, :skip_validate, bot_type: 'csml', account: account) }
|
||||
let(:agent_bot_inbox) { create(:agent_bot_inbox, agent_bot: agent_bot, inbox: inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:csml_client) { double }
|
||||
let(:processor) { described_class.new(event_name: event_name, agent_bot: agent_bot, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
end
|
||||
|
||||
context 'when a conversation is completed from CSML' do
|
||||
it 'open the conversation and handsoff it to an agent' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(conversation_end: true)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new message is returned from CSML' do
|
||||
it 'creates a text message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [
|
||||
{ payload: { content_type: 'text', content: { text: 'hello payload' } } }
|
||||
]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
|
||||
it 'creates a question message' do
|
||||
csml_response = ActiveSupport::HashWithIndifferentAccess.new(
|
||||
messages: [{
|
||||
payload: {
|
||||
content_type: 'question',
|
||||
content: { title: 'Question Payload', buttons: [{ content: { title: 'Q1', payload: 'q1' } }] }
|
||||
}
|
||||
}]
|
||||
)
|
||||
allow(csml_client).to receive(:run).and_return(csml_response)
|
||||
processor.perform
|
||||
expect(conversation.messages.last.content).to eql('Question Payload')
|
||||
expect(conversation.messages.last.content_type).to eql('input_select')
|
||||
expect(conversation.messages.last.content_attributes).to eql({ items: [{ title: 'Q1', value: 'q1' }] }.with_indifferent_access)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation status is not pending' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is private' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message type is template (not outgoing or incoming)' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :template) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message updated' do
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
context 'when content_type is input_select' do
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, private: true,
|
||||
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
||||
end
|
||||
|
||||
it 'returns submitted value for message content' do
|
||||
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when content_type is not input_select' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content_type: :text) }
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -37,15 +37,6 @@ describe AgentBotListener do
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot csml type is configured' do
|
||||
it 'sends message to agent bot' do
|
||||
agent_bot_csml = create(:agent_bot, :skip_validate, bot_type: 'csml')
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot_csml)
|
||||
expect(AgentBots::CsmlJob).to receive(:perform_later).with('message.created', agent_bot_csml, message).once
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#webwidget_triggered' do
|
||||
|
||||
@@ -39,4 +39,23 @@ RSpec.describe AgentBot do
|
||||
expect(message.reload.sender).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#system_bot?' do
|
||||
context 'when account_id is nil' do
|
||||
let(:agent_bot) { create(:agent_bot, account_id: nil) }
|
||||
|
||||
it 'returns true' do
|
||||
expect(agent_bot.system_bot?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account_id is present' do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent_bot) { create(:agent_bot, account: account) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(agent_bot.system_bot?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe AgentBots::ValidateBotService do
|
||||
describe '#perform' do
|
||||
it 'returns true if bot_type is not csml' do
|
||||
agent_bot = create(:agent_bot)
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
end
|
||||
|
||||
it 'returns true if validate csml returns true' do
|
||||
agent_bot = create(:agent_bot, :skip_validate, bot_type: 'csml', bot_config: {})
|
||||
csml_client = double
|
||||
csml_response = double
|
||||
allow(CsmlEngine).to receive(:new).and_return(csml_client)
|
||||
allow(csml_client).to receive(:validate).and_return(csml_response)
|
||||
allow(csml_response).to receive(:blank?).and_return(false)
|
||||
allow(csml_response).to receive(:[]).with('valid').and_return(true)
|
||||
|
||||
valid = described_class.new(agent_bot: agent_bot).perform
|
||||
expect(valid).to be true
|
||||
expect(CsmlEngine).to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user