diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb
index 9f4ef2cd9..efb48c5c6 100644
--- a/app/controllers/api/v1/accounts/agent_bots_controller.rb
+++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb
@@ -30,6 +30,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
- params.permit(:name, :description, :outgoing_url)
+ params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
end
end
diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue
index 186b47bd3..c338dce83 100755
--- a/app/javascript/widget/components/AgentMessage.vue
+++ b/app/javascript/widget/components/AgentMessage.vue
@@ -30,7 +30,7 @@
diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue
index b95620819..103632527 100755
--- a/app/javascript/widget/components/UserMessage.vue
+++ b/app/javascript/widget/components/UserMessage.vue
@@ -20,7 +20,7 @@
diff --git a/app/jobs/agent_bot_job.rb b/app/jobs/agent_bot_job.rb
deleted file mode 100644
index e988701c4..000000000
--- a/app/jobs/agent_bot_job.rb
+++ /dev/null
@@ -1,3 +0,0 @@
-class AgentBotJob < WebhookJob
- queue_as :bots
-end
diff --git a/app/jobs/agent_bots/csml_job.rb b/app/jobs/agent_bots/csml_job.rb
new file mode 100644
index 000000000..1a65bf713
--- /dev/null
+++ b/app/jobs/agent_bots/csml_job.rb
@@ -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
diff --git a/app/jobs/agent_bots/webhook_job.rb b/app/jobs/agent_bots/webhook_job.rb
new file mode 100644
index 000000000..bfad365bc
--- /dev/null
+++ b/app/jobs/agent_bots/webhook_job.rb
@@ -0,0 +1,3 @@
+class AgentBots::WebhookJob < WebhookJob
+ queue_as :bots
+end
diff --git a/app/listeners/agent_bot_listener.rb b/app/listeners/agent_bot_listener.rb
index c697bcf65..12e8c6570 100644
--- a/app/listeners/agent_bot_listener.rb
+++ b/app/listeners/agent_bot_listener.rb
@@ -2,61 +2,80 @@ class AgentBotListener < BaseListener
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
- return if inbox.agent_bot_inbox.blank?
- return unless inbox.agent_bot_inbox.active?
+ return unless connected_agent_bot_exist?(inbox)
- agent_bot = inbox.agent_bot_inbox.agent_bot
-
- payload = conversation.webhook_data.merge(event: __method__.to_s)
- AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
+ event_name = __method__.to_s
+ payload = conversation.webhook_data.merge(event: event_name)
+ process_webhook_bot_event(inbox.agent_bot, payload)
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
inbox = conversation.inbox
- return if inbox.agent_bot_inbox.blank?
- return unless inbox.agent_bot_inbox.active?
+ return unless connected_agent_bot_exist?(inbox)
- agent_bot = inbox.agent_bot_inbox.agent_bot
-
- payload = conversation.webhook_data.merge(event: __method__.to_s)
- AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
+ event_name = __method__.to_s
+ payload = conversation.webhook_data.merge(event: event_name)
+ process_webhook_bot_event(inbox.agent_bot, payload)
end
def message_created(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
- return unless message.webhook_sendable? && inbox.agent_bot_inbox.present?
- return unless inbox.agent_bot_inbox.active?
+ return unless connected_agent_bot_exist?(inbox)
+ return unless message.webhook_sendable?
- agent_bot = inbox.agent_bot_inbox.agent_bot
-
- payload = message.webhook_data.merge(event: __method__.to_s)
- AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
+ method_name = __method__.to_s
+ process_message_event(method_name, inbox.agent_bot, message, event)
end
def message_updated(event)
message = extract_message_and_account(event)[0]
inbox = message.inbox
- return unless message.webhook_sendable? && inbox.agent_bot_inbox.present?
- return unless inbox.agent_bot_inbox.active?
+ return unless connected_agent_bot_exist?(inbox)
+ return unless message.webhook_sendable?
- agent_bot = inbox.agent_bot_inbox.agent_bot
-
- payload = message.webhook_data.merge(event: __method__.to_s)
- AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
+ method_name = __method__.to_s
+ process_message_event(method_name, inbox.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)
+ end
+
+ private
+
+ def connected_agent_bot_exist?(inbox)
return if inbox.agent_bot_inbox.blank?
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)
- payload[:event_info] = event.data[:event_info]
- AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
+ def process_message_event(method_name, agent_bot, message, event)
+ case agent_bot.bot_type
+ 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
diff --git a/app/models/agent_bot.rb b/app/models/agent_bot.rb
index 8e8a0684e..337b3a2b6 100644
--- a/app/models/agent_bot.rb
+++ b/app/models/agent_bot.rb
@@ -3,6 +3,8 @@
# Table name: agent_bots
#
# id :bigint not null, primary key
+# bot_config :jsonb
+# bot_type :integer default(0)
# description :string
# name :string
# outgoing_url :string
@@ -27,6 +29,9 @@ class AgentBot < ApplicationRecord
has_many :inboxes, through: :agent_bot_inboxes
has_many :messages, as: :sender, dependent: :restrict_with_exception
belongs_to :account, optional: true
+ enum bot_type: { webhook: 0, csml: 1 }
+
+ validate :validate_agent_bot_config
def available_name
name
@@ -48,4 +53,10 @@ class AgentBot < ApplicationRecord
type: 'agent_bot'
}
end
+
+ private
+
+ def validate_agent_bot_config
+ errors.add(:bot_config, 'Invalid Bot Configuration') unless AgentBots::ValidateBotService.new(agent_bot: self).perform
+ end
end
diff --git a/app/services/agent_bots/validate_bot_service.rb b/app/services/agent_bots/validate_bot_service.rb
new file mode 100644
index 000000000..fbb6b446a
--- /dev/null
+++ b/app/services/agent_bots/validate_bot_service.rb
@@ -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
diff --git a/app/views/api/v1/models/_agent_bot.json.jbuilder b/app/views/api/v1/models/_agent_bot.json.jbuilder
index 044624fd8..eafa31cd6 100644
--- a/app/views/api/v1/models/_agent_bot.json.jbuilder
+++ b/app/views/api/v1/models/_agent_bot.json.jbuilder
@@ -2,5 +2,7 @@ json.id resource.id
json.name resource.name
json.description resource.description
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.access_token resource.access_token if resource.access_token.present?
diff --git a/config/installation_config.yml b/config/installation_config.yml
index e95c6da2c..ec3b0b635 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -62,3 +62,9 @@
- name: ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT
value: false
locked: false
+- name: CSML_BOT_HOST
+ value:
+ locked: false
+- name: CSML_BOT_API_KEY
+ value:
+ locked: false
diff --git a/db/migrate/20220622090344_add_type_to_agent_bots.rb b/db/migrate/20220622090344_add_type_to_agent_bots.rb
new file mode 100644
index 000000000..b247d3cfa
--- /dev/null
+++ b/db/migrate/20220622090344_add_type_to_agent_bots.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 43f616ada..f9557e535 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# 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
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 "updated_at", precision: 6, null: false
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"
end
diff --git a/lib/csml_engine.rb b/lib/csml_engine.rb
new file mode 100644
index 000000000..5e238ac2d
--- /dev/null
+++ b/lib/csml_engine.rb
@@ -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
diff --git a/lib/integrations/bot_processor_service.rb b/lib/integrations/bot_processor_service.rb
new file mode 100644
index 000000000..42a416fdf
--- /dev/null
+++ b/lib/integrations/bot_processor_service.rb
@@ -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
diff --git a/lib/integrations/csml/processor_service.rb b/lib/integrations/csml/processor_service.rb
new file mode 100644
index 000000000..e9f7e85ef
--- /dev/null
+++ b/lib/integrations/csml/processor_service.rb
@@ -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
diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb
index e9dfa8fb2..b23b8e082 100644
--- a/lib/integrations/dialogflow/processor_service.rb
+++ b/lib/integrations/dialogflow/processor_service.rb
@@ -1,17 +1,6 @@
-class Integrations::Dialogflow::ProcessorService
+class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorService
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
def message_content(message)
@@ -23,19 +12,7 @@ class Integrations::Dialogflow::ProcessorService
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 get_dialogflow_response(session_id, message)
+ def get_response(session_id, message)
Google::Cloud::Dialogflow.configure { |config| config.credentials = hook.settings['credentials'] }
session_client = Google::Cloud::Dialogflow.sessions
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
}))
end
-
- def process_action(message, action)
- case action
- when 'handoff'
- message.conversation.open!
- when 'resolve'
- message.conversation.resolved!
- end
- end
end
diff --git a/spec/factories/agent_bots.rb b/spec/factories/agent_bots.rb
index 227fd86a2..87556709b 100644
--- a/spec/factories/agent_bots.rb
+++ b/spec/factories/agent_bots.rb
@@ -3,5 +3,11 @@ FactoryBot.define do
name { 'MyString' }
description { 'MyString' }
outgoing_url { 'MyString' }
+ bot_config { {} }
+ bot_type { 'webhook' }
+
+ trait :skip_validate do
+ to_create { |instance| instance.save(validate: false) }
+ end
end
end
diff --git a/spec/jobs/agent_bots/csml_job_spec.rb b/spec/jobs/agent_bots/csml_job_spec.rb
new file mode 100644
index 000000000..bd974a71a
--- /dev/null
+++ b/spec/jobs/agent_bots/csml_job_spec.rb
@@ -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
diff --git a/spec/jobs/agent_bot_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb
similarity index 57%
rename from spec/jobs/agent_bot_job_spec.rb
rename to spec/jobs/agent_bots/webhook_job_spec.rb
index af73e4933..3c4fdce7a 100644
--- a/spec/jobs/agent_bot_job_spec.rb
+++ b/spec/jobs/agent_bots/webhook_job_spec.rb
@@ -1,6 +1,8 @@
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) }
let(:url) { 'https://test.com' }
@@ -11,4 +13,9 @@ RSpec.describe AgentBotJob, type: :job do
.with(url, payload)
.on_queue('bots')
end
+
+ it 'executes perform' do
+ expect(Webhooks::Trigger).to receive(:execute).with(url, payload)
+ perform_enqueued_jobs { job }
+ end
end
diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb
index 4d8eeff97..4a10762f4 100644
--- a/spec/jobs/webhook_job_spec.rb
+++ b/spec/jobs/webhook_job_spec.rb
@@ -1,9 +1,11 @@
require 'rails_helper'
RSpec.describe WebhookJob, type: :job do
+ include ActiveJob::TestHelper
+
subject(:job) { described_class.perform_later(url, payload) }
- let(:url) { 'https://test.com' }
+ let(:url) { 'https://test.chatwoot.com' }
let(:payload) { { name: 'test' } }
it 'queues the job' do
@@ -11,4 +13,9 @@ RSpec.describe WebhookJob, type: :job do
.with(url, payload)
.on_queue('webhooks')
end
+
+ it 'executes perform' do
+ expect(Webhooks::Trigger).to receive(:execute).with(url, payload)
+ perform_enqueued_jobs { job }
+ end
end
diff --git a/spec/lib/csml_engine_spec.rb b/spec/lib/csml_engine_spec.rb
new file mode 100644
index 000000000..966e4d95a
--- /dev/null
+++ b/spec/lib/csml_engine_spec.rb
@@ -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
diff --git a/spec/lib/integrations/csml/processor_service_spec.rb b/spec/lib/integrations/csml/processor_service_spec.rb
new file mode 100644
index 000000000..2578881bb
--- /dev/null
+++ b/spec/lib/integrations/csml/processor_service_spec.rb
@@ -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
diff --git a/spec/lib/integrations/dialogflow/processor_service_spec.rb b/spec/lib/integrations/dialogflow/processor_service_spec.rb
index e002e1c3e..87f1e2cc4 100644
--- a/spec/lib/integrations/dialogflow/processor_service_spec.rb
+++ b/spec/lib/integrations/dialogflow/processor_service_spec.rb
@@ -24,7 +24,7 @@ describe Integrations::Dialogflow::ProcessorService do
before do
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'] })
end
diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb
index 3425ef3d3..5e17bfbef 100644
--- a/spec/listeners/agent_bot_listener_spec.rb
+++ b/spec/listeners/agent_bot_listener_spec.rb
@@ -6,18 +6,18 @@ describe AgentBotListener do
let!(:inbox) { create(:inbox, account: account) }
let!(:agent_bot) { create(:agent_bot) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
- let!(:message) do
- create(:message, message_type: 'outgoing',
- account: account, inbox: inbox, conversation: conversation)
- end
- let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
describe '#message_created' do
- let(:event_name) { :'conversation.created' }
+ let(:event_name) { 'message.created' }
+ let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
+ let!(:message) do
+ create(:message, message_type: 'outgoing',
+ account: account, inbox: inbox, conversation: conversation)
+ end
context 'when agent bot is not configured' 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)
end
end
@@ -25,9 +25,52 @@ describe AgentBotListener do
context 'when agent bot is configured' do
it 'sends message to agent bot' do
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)
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
diff --git a/spec/services/agent_bots/validate_bot_service_spec.rb b/spec/services/agent_bots/validate_bot_service_spec.rb
new file mode 100644
index 000000000..005d5b94e
--- /dev/null
+++ b/spec/services/agent_bots/validate_bot_service_spec.rb
@@ -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