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