feat: Add native support for CSML in agent_bot API (#4913)

This commit is contained in:
Pranav Raj S 2022-06-23 19:17:46 +05:30 committed by GitHub
parent f71980bd95
commit b7606e4dd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 722 additions and 80 deletions

View file

@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def permitted_params def permitted_params
params.permit(:name, :description, :outgoing_url) params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end end
end end

View file

@ -30,7 +30,7 @@
<image-bubble <image-bubble
v-if="attachment.file_type === 'image' && !hasImageError" v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url" :url="attachment.data_url"
:thumb="attachment.thumb_url" :thumb="attachment.data_url"
:readable-time="readableTime" :readable-time="readableTime"
@error="onImageLoadError" @error="onImageLoadError"
/> />

View file

@ -20,7 +20,7 @@
<image-bubble <image-bubble
v-if="attachment.file_type === 'image' && !hasImageError" v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url" :url="attachment.data_url"
:thumb="attachment.thumb_url" :thumb="attachment.data_url"
:readable-time="readableTime" :readable-time="readableTime"
@error="onImageLoadError" @error="onImageLoadError"
/> />

View file

@ -1,3 +0,0 @@
class AgentBotJob < WebhookJob
queue_as :bots
end

View file

@ -0,0 +1,10 @@
class AgentBots::CsmlJob < ApplicationJob
queue_as :bots
def perform(event, agent_bot, message)
event_data = { message: message }
Integrations::Csml::ProcessorService.new(
event_name: event, agent_bot: agent_bot, event_data: event_data
).perform
end
end

View file

@ -0,0 +1,3 @@
class AgentBots::WebhookJob < WebhookJob
queue_as :bots
end

View file

@ -2,61 +2,80 @@ class AgentBotListener < BaseListener
def conversation_resolved(event) def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox inbox = conversation.inbox
return if inbox.agent_bot_inbox.blank? return unless connected_agent_bot_exist?(inbox)
return unless inbox.agent_bot_inbox.active?
agent_bot = inbox.agent_bot_inbox.agent_bot event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
payload = conversation.webhook_data.merge(event: __method__.to_s) process_webhook_bot_event(inbox.agent_bot, payload)
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
end end
def conversation_opened(event) def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox inbox = conversation.inbox
return if inbox.agent_bot_inbox.blank? return unless connected_agent_bot_exist?(inbox)
return unless inbox.agent_bot_inbox.active?
agent_bot = inbox.agent_bot_inbox.agent_bot event_name = __method__.to_s
payload = conversation.webhook_data.merge(event: event_name)
payload = conversation.webhook_data.merge(event: __method__.to_s) process_webhook_bot_event(inbox.agent_bot, payload)
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
end end
def message_created(event) def message_created(event)
message = extract_message_and_account(event)[0] message = extract_message_and_account(event)[0]
inbox = message.inbox inbox = message.inbox
return unless message.webhook_sendable? && inbox.agent_bot_inbox.present? return unless connected_agent_bot_exist?(inbox)
return unless inbox.agent_bot_inbox.active? return unless message.webhook_sendable?
agent_bot = inbox.agent_bot_inbox.agent_bot method_name = __method__.to_s
process_message_event(method_name, inbox.agent_bot, message, event)
payload = message.webhook_data.merge(event: __method__.to_s)
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
end end
def message_updated(event) def message_updated(event)
message = extract_message_and_account(event)[0] message = extract_message_and_account(event)[0]
inbox = message.inbox inbox = message.inbox
return unless message.webhook_sendable? && inbox.agent_bot_inbox.present? return unless connected_agent_bot_exist?(inbox)
return unless inbox.agent_bot_inbox.active? return unless message.webhook_sendable?
agent_bot = inbox.agent_bot_inbox.agent_bot method_name = __method__.to_s
process_message_event(method_name, inbox.agent_bot, message, event)
payload = message.webhook_data.merge(event: __method__.to_s)
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
end end
def webwidget_triggered(event) def webwidget_triggered(event)
contact_inbox = event.data[:contact_inbox] contact_inbox = event.data[:contact_inbox]
inbox = contact_inbox.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)
end
private
def connected_agent_bot_exist?(inbox)
return if inbox.agent_bot_inbox.blank? return if inbox.agent_bot_inbox.blank?
return unless inbox.agent_bot_inbox.active? return unless inbox.agent_bot_inbox.active?
agent_bot = inbox.agent_bot_inbox.agent_bot true
end
payload = contact_inbox.webhook_data.merge(event: __method__.to_s) def process_message_event(method_name, agent_bot, message, event)
payload[:event_info] = event.data[:event_info] case agent_bot.bot_type
AgentBotJob.perform_later(agent_bot.outgoing_url, payload) when 'webhook'
payload = message.webhook_data.merge(event: method_name)
process_webhook_bot_event(agent_bot, payload)
when 'csml'
process_csml_bot_event(event.name, agent_bot, message)
end
end
def process_webhook_bot_event(agent_bot, payload)
return if agent_bot.outgoing_url.blank?
AgentBots::WebhookJob.perform_later(agent_bot.outgoing_url, payload)
end
def process_csml_bot_event(event, agent_bot, message)
AgentBots::CsmlJob.perform_later(event, agent_bot, message)
end end
end end

View file

@ -3,6 +3,8 @@
# Table name: agent_bots # Table name: agent_bots
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# bot_config :jsonb
# bot_type :integer default(0)
# description :string # description :string
# name :string # name :string
# outgoing_url :string # outgoing_url :string
@ -27,6 +29,9 @@ class AgentBot < ApplicationRecord
has_many :inboxes, through: :agent_bot_inboxes has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :restrict_with_exception has_many :messages, as: :sender, dependent: :restrict_with_exception
belongs_to :account, optional: true belongs_to :account, optional: true
enum bot_type: { webhook: 0, csml: 1 }
validate :validate_agent_bot_config
def available_name def available_name
name name
@ -48,4 +53,10 @@ class AgentBot < ApplicationRecord
type: 'agent_bot' type: 'agent_bot'
} }
end end
private
def validate_agent_bot_config
errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
end
end end

View file

@ -0,0 +1,38 @@
class AgentBots::ValidateBotService
pattr_initialize [:agent_bot]
def perform
return true unless agent_bot.bot_type == 'csml'
validate_csml_bot
end
private
def csml_client
@csml_client ||= CsmlEngine.new
end
def csml_bot_payload
{
id: agent_bot[:name],
name: agent_bot[:name],
default_flow: 'Default',
flows: [
{
id: SecureRandom.uuid,
name: 'Default',
content: agent_bot.bot_config['csml_content'],
commands: []
}
]
}
end
def validate_csml_bot
response = csml_client.validate(csml_bot_payload)
response.blank? || response['valid']
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: agent_bot).capture_exception
false
end
end

View file

@ -2,5 +2,7 @@ json.id resource.id
json.name resource.name json.name resource.name
json.description resource.description json.description resource.description
json.outgoing_url resource.outgoing_url json.outgoing_url resource.outgoing_url
json.bot_type resource.bot_type
json.bot_config resource.bot_config
json.account_id resource.account_id json.account_id resource.account_id
json.access_token resource.access_token if resource.access_token.present? json.access_token resource.access_token if resource.access_token.present?

View file

@ -62,3 +62,9 @@
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT - name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
value: false value: false
locked: false locked: false
- name: CSML_BOT_HOST
value:
locked: false
- name: CSML_BOT_API_KEY
value:
locked: false

View file

@ -0,0 +1,15 @@
class AddTypeToAgentBots < ActiveRecord::Migration[6.1]
def up
change_table :agent_bots, bulk: true do |t|
t.column :bot_type, :integer, default: 0
t.column :bot_config, :jsonb, default: {}
end
end
def down
change_table :agent_bots, bulk: true do |t|
t.remove :bot_type
t.remove :bot_config
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_06_16_154502) do ActiveRecord::Schema.define(version: 2022_06_22_090344) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -108,6 +108,8 @@ ActiveRecord::Schema.define(version: 2022_06_16_154502) do
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.bigint "account_id" t.bigint "account_id"
t.integer "bot_type", default: 0
t.jsonb "bot_config", default: {}
t.index ["account_id"], name: "index_agent_bots_on_account_id" t.index ["account_id"], name: "index_agent_bots_on_account_id"
end end

52
lib/csml_engine.rb Normal file
View file

@ -0,0 +1,52 @@
class CsmlEngine
API_KEY_HEADER = 'X-Api-Key'.freeze
def initialize
@host_url = GlobalConfigService.load('CSML_BOT_HOST', '')
@api_key = GlobalConfigService.load('CSML_BOT_API_KEY', '')
raise ArgumentError, 'Missing Credentials' if @host_url.blank? || @api_key.blank?
end
def status
response = HTTParty.get("#{@host_url}/status")
process_response(response)
end
def run(bot, params)
payload = {
bot: bot,
event: {
request_id: SecureRandom.uuid,
client: params[:client],
payload: params[:payload],
metadata: params[:metadata],
ttl_duration: 4000
}
}
response = post('run', payload)
process_response(response)
end
def validate(bot)
response = post('validate', bot)
process_response(response)
end
private
def process_response(response)
return response.parsed_response if response.success?
{ error: response.parsed_response, status: response.code }
end
def post(path, payload)
HTTParty.post(
"#{@host_url}/#{path}", {
headers: { API_KEY_HEADER => @api_key, 'Content-Type' => 'application/json' },
body: payload.to_json
}
)
end
end

View file

@ -0,0 +1,63 @@
class Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!]
def perform
message = event_data[:message]
return unless should_run_processor?(message)
process_content(message)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: agent_bot).capture_exception
end
private
def should_run_processor?(message)
return if message.private?
return unless processable_message?(message)
return unless conversation.pending?
true
end
def conversation
message = event_data[:message]
@conversation ||= message.conversation
end
def process_content(message)
content = message_content(message)
response = get_response(conversation.contact_inbox.source_id, content) if content.present?
process_response(message, response) if response.present?
end
def message_content(message)
# TODO: might needs to change this to a way that we fetch the updated value from event data instead
# cause the message.updated event could be that that the message was deleted
return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated'
message.content
end
def processable_message?(message)
# TODO: change from reportable and create a dedicated method for this?
return unless message.reportable?
return if message.outgoing? && !processable_outgoing_message?(message)
true
end
def processable_outgoing_message?(message)
event_name == 'message.updated' && ['input_select'].include?(message.content_type)
end
def process_action(message, action)
case action
when 'handoff'
message.conversation.open!
when 'resolve'
message.conversation.resolved!
end
end
end

View file

@ -0,0 +1,142 @@
class Integrations::Csml::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :event_data!, :agent_bot!]
private
def csml_client
@csml_client ||= CsmlEngine.new
end
def get_response(session_id, content)
csml_client.run(
bot_payload,
{
client: client_params(session_id),
payload: message_payload(content),
metadata: metadata_params
}
)
end
def client_params(session_id)
{
bot_id: "chatwoot-bot-#{conversation.inbox.id}",
channel_id: "chatwoot-bot-inbox-#{conversation.inbox.id}",
user_id: session_id
}
end
def message_payload(content)
{
content_type: 'text',
content: { text: content }
}
end
def metadata_params
{
conversation: conversation,
contact: conversation.contact
}
end
def bot_payload
{
id: "chatwoot-csml-bot-#{agent_bot.id}",
name: "chatwoot-csml-bot-#{agent_bot.id}",
default_flow: 'chatwoot_bot_flow',
flows: [
{
id: "chatwoot-csml-bot-flow-#{agent_bot.id}-inbox-#{conversation.inbox.id}",
name: 'chatwoot_bot_flow',
content: agent_bot.bot_config['csml_content'],
commands: []
}
]
}
end
def process_response(message, response)
csml_messages = response['messages']
has_conversation_ended = response['conversation_end']
process_action(message, 'handoff') if has_conversation_ended.present?
return if csml_messages.blank?
# We do not support wait, typing now.
csml_messages.each do |csml_message|
create_messages(csml_message, conversation)
end
end
def create_messages(message, conversation)
message_payload = message['payload']
case message_payload['content_type']
when 'text'
process_text_messages(message_payload, conversation)
when 'question'
process_question_messages(message_payload, conversation)
when 'image'
process_image_messages(message_payload, conversation)
end
end
def process_text_messages(message_payload, conversation)
conversation.messages.create(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: message_payload['content']['text'],
sender: agent_bot
}
)
end
def process_question_messages(message_payload, conversation)
buttons = message_payload['content']['buttons'].map do |button|
{ title: button['content']['title'], value: button['content']['payload'] }
end
conversation.messages.create(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: message_payload['content']['title'],
content_type: 'input_select',
content_attributes: { items: buttons },
sender: agent_bot
}
)
end
def prepare_attachment(message_payload, message, account_id)
attachment_params = { file_type: :image, account_id: account_id }
attachment_url = message_payload['content']['url']
attachment = message.attachments.new(attachment_params)
attachment_file = Down.download(attachment_url)
attachment.file.attach(
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
def process_image_messages(message_payload, conversation)
message = conversation.messages.new(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: '',
content_type: 'text',
sender: agent_bot
}
)
prepare_attachment(message_payload, message, conversation.account_id)
message.save!
end
end

View file

@ -1,17 +1,6 @@
class Integrations::Dialogflow::ProcessorService class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!] pattr_initialize [:event_name!, :hook!, :event_data!]
def perform
message = event_data[:message]
return if message.private?
return unless processable_message?(message)
return unless message.conversation.pending?
content = message_content(message)
response = get_dialogflow_response(message.conversation.contact_inbox.source_id, content) if content.present?
process_response(message, response) if response.present?
end
private private
def message_content(message) def message_content(message)
@ -23,19 +12,7 @@ class Integrations::Dialogflow::ProcessorService
message.content message.content
end end
def processable_message?(message) def get_response(session_id, message)
# TODO: change from reportable and create a dedicated method for this?
return unless message.reportable?
return if message.outgoing? && !processable_outgoing_message?(message)
true
end
def processable_outgoing_message?(message)
event_name == 'message.updated' && ['input_select'].include?(message.content_type)
end
def get_dialogflow_response(session_id, message)
Google::Cloud::Dialogflow.configure { |config| config.credentials = hook.settings['credentials'] } Google::Cloud::Dialogflow.configure { |config| config.credentials = hook.settings['credentials'] }
session_client = Google::Cloud::Dialogflow.sessions session_client = Google::Cloud::Dialogflow.sessions
session = session_client.session_path project: hook.settings['project_id'], session: session_id session = session_client.session_path project: hook.settings['project_id'], session: session_id
@ -72,13 +49,4 @@ class Integrations::Dialogflow::ProcessorService
inbox_id: conversation.inbox_id inbox_id: conversation.inbox_id
})) }))
end end
def process_action(message, action)
case action
when 'handoff'
message.conversation.open!
when 'resolve'
message.conversation.resolved!
end
end
end end

View file

@ -3,5 +3,11 @@ FactoryBot.define do
name { 'MyString' } name { 'MyString' }
description { 'MyString' } description { 'MyString' }
outgoing_url { 'MyString' } outgoing_url { 'MyString' }
bot_config { {} }
bot_type { 'webhook' }
trait :skip_validate do
to_create { |instance| instance.save(validate: false) }
end
end end
end end

View file

@ -0,0 +1,19 @@
require 'rails_helper'
RSpec.describe AgentBots::CsmlJob, type: :job 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

View file

@ -1,6 +1,8 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe AgentBotJob, type: :job do RSpec.describe AgentBots::WebhookJob, type: :job do
include ActiveJob::TestHelper
subject(:job) { described_class.perform_later(url, payload) } subject(:job) { described_class.perform_later(url, payload) }
let(:url) { 'https://test.com' } let(:url) { 'https://test.com' }
@ -11,4 +13,9 @@ RSpec.describe AgentBotJob, type: :job do
.with(url, payload) .with(url, payload)
.on_queue('bots') .on_queue('bots')
end end
it 'executes perform' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload)
perform_enqueued_jobs { job }
end
end end

View file

@ -1,9 +1,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe WebhookJob, type: :job do RSpec.describe WebhookJob, type: :job do
include ActiveJob::TestHelper
subject(:job) { described_class.perform_later(url, payload) } subject(:job) { described_class.perform_later(url, payload) }
let(:url) { 'https://test.com' } let(:url) { 'https://test.chatwoot.com' }
let(:payload) { { name: 'test' } } let(:payload) { { name: 'test' } }
it 'queues the job' do it 'queues the job' do
@ -11,4 +13,9 @@ RSpec.describe WebhookJob, type: :job do
.with(url, payload) .with(url, payload)
.on_queue('webhooks') .on_queue('webhooks')
end end
it 'executes perform' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload)
perform_enqueued_jobs { job }
end
end end

View file

@ -0,0 +1,99 @@
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

View file

@ -0,0 +1,108 @@
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

View file

@ -24,7 +24,7 @@ describe Integrations::Dialogflow::ProcessorService do
before do before do
allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response) allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response)
allow(processor).to receive(:get_dialogflow_response).and_return(dialogflow_service) allow(processor).to receive(:get_response).and_return(dialogflow_service)
allow(dialogflow_text_double).to receive(:to_h).and_return({ text: ['hello payload'] }) allow(dialogflow_text_double).to receive(:to_h).and_return({ text: ['hello payload'] })
end end

View file

@ -6,18 +6,18 @@ describe AgentBotListener do
let!(:inbox) { create(:inbox, account: account) } let!(:inbox) { create(:inbox, account: account) }
let!(:agent_bot) { create(:agent_bot) } let!(:agent_bot) { create(:agent_bot) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
describe '#message_created' do
let(:event_name) { 'message.created' }
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
let!(:message) do let!(:message) do
create(:message, message_type: 'outgoing', create(:message, message_type: 'outgoing',
account: account, inbox: inbox, conversation: conversation) account: account, inbox: inbox, conversation: conversation)
end end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
describe '#message_created' do
let(:event_name) { :'conversation.created' }
context 'when agent bot is not configured' do context 'when agent bot is not configured' do
it 'does not send message to agent bot' do it 'does not send message to agent bot' do
expect(AgentBotJob).to receive(:perform_later).exactly(0).times expect(AgentBots::WebhookJob).to receive(:perform_later).exactly(0).times
listener.message_created(event) listener.message_created(event)
end end
end end
@ -25,9 +25,52 @@ describe AgentBotListener do
context 'when agent bot is configured' do context 'when agent bot is configured' do
it 'sends message to agent bot' do it 'sends message to agent bot' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBotJob).to receive(:perform_later).with(agent_bot.outgoing_url, message.webhook_data.merge(event: 'message_created')).once expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
message.webhook_data.merge(event: 'message_created')).once
listener.message_created(event)
end
it 'does not send message to agent bot if url is empty' do
agent_bot = create(:agent_bot, outgoing_url: '')
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).not_to receive(:perform_later)
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) listener.message_created(event)
end end
end end
end end
describe '#webwidget_triggered' do
let(:event_name) { 'webwidget.triggered' }
context 'when agent bot is configured' do
it 'send message to agent bot URL' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
event = double
allow(event).to receive(:data)
.and_return(
{
contact_inbox: conversation.contact_inbox,
event_info: { country: 'US' }
}
)
expect(AgentBots::WebhookJob).to receive(:perform_later)
.with(
agent_bot.outgoing_url,
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' })
).once
listener.webwidget_triggered(event)
end
end
end
end end

View file

@ -0,0 +1,25 @@
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