feat: Add APIs for Dialogflow integration V1 (#2155)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
parent
fd0c26cdae
commit
b30ecb27a6
19 changed files with 303 additions and 26 deletions
|
@ -101,6 +101,7 @@ Rails/BulkChangeTable:
|
|||
- 'db/migrate/20170511134418_latlong.rb'
|
||||
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
|
||||
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
|
||||
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
|
||||
Rails/UniqueValidationWithoutIndex:
|
||||
Exclude:
|
||||
- 'app/models/channel/twitter_profile.rb'
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -80,6 +80,8 @@ gem 'twitty'
|
|||
gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow'
|
||||
|
||||
##--- gems for debugging and error reporting ---##
|
||||
# static analysis
|
||||
|
|
37
Gemfile.lock
37
Gemfile.lock
|
@ -211,6 +211,12 @@ GEM
|
|||
fugit (1.4.1)
|
||||
et-orbi (~> 1.1, >= 1.1.8)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.3.4)
|
||||
google-protobuf (~> 3.12, >= 3.12.2)
|
||||
googleapis-common-protos (>= 1.3.9, < 2.0)
|
||||
googleapis-common-protos-types (>= 1.0.4, < 2.0)
|
||||
googleauth (~> 0.9)
|
||||
grpc (~> 1.25)
|
||||
geocoder (1.6.3)
|
||||
gli (2.19.2)
|
||||
globalid (0.4.2)
|
||||
|
@ -223,12 +229,18 @@ GEM
|
|||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.0)
|
||||
signet (~> 0.12)
|
||||
google-cloud-core (1.5.0)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.3.3)
|
||||
google-cloud-dialogflow (1.2.0)
|
||||
google-cloud-core (~> 1.5)
|
||||
google-cloud-dialogflow-v2 (~> 0.1)
|
||||
google-cloud-dialogflow-v2 (0.6.4)
|
||||
gapic-common (~> 0.3)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.5.0)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
google-cloud-errors (1.0.1)
|
||||
google-cloud-errors (1.1.0)
|
||||
google-cloud-storage (1.28.0)
|
||||
addressable (~> 2.5)
|
||||
digest-crc (~> 0.4)
|
||||
|
@ -236,7 +248,14 @@ GEM
|
|||
google-cloud-core (~> 1.2)
|
||||
googleauth (~> 0.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (0.13.1)
|
||||
google-protobuf (3.15.8)
|
||||
googleapis-common-protos (1.3.11)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (>= 1.0.6, < 2.0)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos-types (1.0.6)
|
||||
google-protobuf (~> 3.14)
|
||||
googleauth (0.16.2)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
|
@ -245,6 +264,9 @@ GEM
|
|||
signet (~> 0.14)
|
||||
groupdate (5.1.0)
|
||||
activesupport (>= 5)
|
||||
grpc (1.37.1)
|
||||
google-protobuf (~> 3.15)
|
||||
googleapis-common-protos-types (~> 1.0)
|
||||
haikunator (1.1.0)
|
||||
hairtrigger (0.2.23)
|
||||
activerecord (>= 5.0, < 7)
|
||||
|
@ -273,7 +295,7 @@ GEM
|
|||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.3.1)
|
||||
jwt (2.2.2)
|
||||
jwt (2.2.3)
|
||||
kaminari (1.2.1)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.1)
|
||||
|
@ -339,7 +361,7 @@ GEM
|
|||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.5)
|
||||
public_suffix (4.0.6)
|
||||
puma (4.3.6)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
|
@ -489,7 +511,7 @@ GEM
|
|||
sidekiq-cron (1.2.0)
|
||||
fugit (~> 1.1)
|
||||
sidekiq (>= 4.2.1)
|
||||
signet (0.14.0)
|
||||
signet (0.15.0)
|
||||
addressable (~> 2.3)
|
||||
faraday (>= 0.17.3, < 2.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
|
@ -612,6 +634,7 @@ DEPENDENCIES
|
|||
flag_shih_tzu
|
||||
foreman
|
||||
geocoder
|
||||
google-cloud-dialogflow
|
||||
google-cloud-storage
|
||||
groupdate
|
||||
haikunator
|
||||
|
|
|
@ -1,11 +1,29 @@
|
|||
class HookJob < ApplicationJob
|
||||
queue_as :integrations
|
||||
|
||||
def perform(hook, message)
|
||||
return unless hook.slack?
|
||||
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
def perform(hook, event_name, event_data = {})
|
||||
case hook.app_id
|
||||
when 'slack'
|
||||
process_slack_integration(hook, event_name, event_data)
|
||||
when 'dialogflow'
|
||||
process_dialogflow_integration(hook, event_name, event_data)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Raven.capture_exception(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_slack_integration(hook, event_name, event_data)
|
||||
return unless ['message.created'].include?(event_name)
|
||||
|
||||
message = event_data[:message]
|
||||
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
|
||||
end
|
||||
|
||||
def process_dialogflow_integration(hook, event_name, event_data)
|
||||
return unless ['message.created', 'message.updated'].include?(event_name)
|
||||
|
||||
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,7 +4,16 @@ class HookListener < BaseListener
|
|||
return unless message.reportable?
|
||||
|
||||
message.account.hooks.each do |hook|
|
||||
HookJob.perform_later(hook, message)
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
end
|
||||
end
|
||||
|
||||
def message_updated(event)
|
||||
message = extract_message_and_account(event)[0]
|
||||
return unless message.reportable?
|
||||
|
||||
message.account.hooks.each do |hook|
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -142,7 +142,7 @@ class Conversation < ApplicationRecord
|
|||
end
|
||||
|
||||
def set_bot_conversation
|
||||
self.status = :bot if inbox.agent_bot_inbox&.active?
|
||||
self.status = :bot if inbox.agent_bot_inbox&.active? || inbox.hooks.pluck(:app_id).include?('dialogflow')
|
||||
end
|
||||
|
||||
def notify_conversation_creation
|
||||
|
|
|
@ -38,6 +38,8 @@ class Integrations::App
|
|||
case params[:id]
|
||||
when 'slack'
|
||||
ENV['SLACK_CLIENT_SECRET'].present?
|
||||
when 'dialogflow'
|
||||
false
|
||||
else
|
||||
true
|
||||
end
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
# id :bigint not null, primary key
|
||||
# access_token :string
|
||||
# hook_type :integer default("account")
|
||||
# settings :text
|
||||
# settings :jsonb
|
||||
# status :integer default("disabled")
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
|
|
|
@ -8,3 +8,8 @@ webhooks:
|
|||
logo: cable.svg
|
||||
i18n_key: webhooks
|
||||
action: /webhook
|
||||
dialogflow:
|
||||
id: dialogflow
|
||||
logo: dialogflow.svg
|
||||
i18n_key: dialogflow
|
||||
action: /dialogflow
|
||||
|
|
|
@ -93,3 +93,6 @@ en:
|
|||
webhooks:
|
||||
name: "Webhooks"
|
||||
description: "Webhook events provide you the realtime information about what's happening in your account. You can make use of the webhooks to communicate the events to your favourite apps like Slack or Github. Click on Configure to set up your webhooks."
|
||||
dialogflow:
|
||||
name: "Dialogflow"
|
||||
description: "Connect your Dialogflow bot to your inbox. Let the bots handle the queries before handing it off to the customer service agent."
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
class ConvertIntegrationHookSettingsField < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
remove_column :integrations_hooks, :settings, :text
|
||||
add_column :integrations_hooks, :settings, :jsonb, default: {}
|
||||
end
|
||||
end
|
|
@ -338,12 +338,12 @@ ActiveRecord::Schema.define(version: 2021_04_30_100138) do
|
|||
t.integer "inbox_id"
|
||||
t.integer "account_id"
|
||||
t.string "app_id"
|
||||
t.text "settings"
|
||||
t.integer "hook_type", default: 0
|
||||
t.string "reference_id"
|
||||
t.string "access_token"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
end
|
||||
|
||||
create_table "kbase_articles", force: :cascade do |t|
|
||||
|
|
69
lib/integrations/dialogflow/processor_service.rb
Normal file
69
lib/integrations/dialogflow/processor_service.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
class Integrations::Dialogflow::ProcessorService
|
||||
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.bot?
|
||||
|
||||
response = get_dialogflow_response(message.conversation.contact_inbox.source_id, message_content(message))
|
||||
process_response(message, response)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def message_content(message)
|
||||
return message.content_attributes['submitted_values'].first['value'] if event_name == 'message.updated'
|
||||
|
||||
message.content
|
||||
end
|
||||
|
||||
def processable_message?(message)
|
||||
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'] }
|
||||
session_client = Google::Cloud::Dialogflow.sessions
|
||||
session = session_client.session_path project: hook.settings['project_id'], session: session_id
|
||||
query_input = { text: { text: message, language_code: 'en-US' } }
|
||||
session_client.detect_intent session: session, query_input: query_input
|
||||
end
|
||||
|
||||
def process_response(message, response)
|
||||
text_response = response.query_result['fulfillment_text']
|
||||
|
||||
content_params = { content: text_response } if text_response.present?
|
||||
content_params ||= response.query_result['fulfillment_messages'].first['payload'].to_h
|
||||
|
||||
process_action(message, content_params['action']) and return if content_params['action'].present?
|
||||
|
||||
create_conversation(message, content_params)
|
||||
end
|
||||
|
||||
def create_conversation(message, content_params)
|
||||
return if content_params.blank?
|
||||
|
||||
conversation = message.conversation
|
||||
conversation.messages.create(content_params.merge({
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id
|
||||
}))
|
||||
end
|
||||
|
||||
def process_action(message, action)
|
||||
case action
|
||||
when 'handoff'
|
||||
message.conversation.open!
|
||||
end
|
||||
end
|
||||
end
|
15
public/dashboard/images/integrations/dialogflow.svg
Normal file
15
public/dashboard/images/integrations/dialogflow.svg
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="256px" height="326px" viewBox="0 0 256 326" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<defs>
|
||||
<path d="M244.828364,64.4290247 L139.050994,3.38839172 C132.188872,-0.58635615 123.724702,-0.58635615 116.86258,3.38839172 L11.0672138,64.4290247 C4.21670092,68.4140969 5.68434189e-14,75.7408852 5.68434189e-14,83.6661817 L5.68434189e-14,205.837425 C0.00604377806,213.747388 4.22254667,221.055993 11.0672138,225.020596 L63.99189,255.612894 L63.99189,317.985192 C64.0010519,320.359385 65.2737404,322.549075 67.3321592,323.732202 C69.390578,324.915329 71.9232588,324.91287 73.9793756,323.725747 L244.936337,225.074582 C251.789146,221.118113 256.008624,213.804351 256.003556,205.891411 L256.003556,83.6481863 C255.962209,75.7100959 251.7068,68.3916143 244.828364,64.4290247 L244.828364,64.4290247 Z" id="path-1"></path>
|
||||
</defs>
|
||||
<g>
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<polygon fill="#EF6C00" mask="url(#mask-2)" points="255.895578 70.8714028 127.98378 144.742806 0 70.8714028 0 218.614208 63.99189 255.522917 63.99189 329.412315 255.895578 218.614208"></polygon>
|
||||
<polygon fill="#FF9800" mask="url(#mask-2)" points="127.98378 144.742806 2.84217094e-14 70.8714028 127.98378 -3 255.895578 70.8714028"></polygon>
|
||||
<polygon fill="#FF9800" mask="url(#mask-2)" points="125.716351 142.493372 0.809796133 70.3855251 2.84217094e-14 70.8714028 127.98378 144.742806 255.895578 70.8714028 252.890335 69.0898513"></polygon>
|
||||
<polygon fill="#FF9800" mask="url(#mask-2)" points="127.98378 145.660575 127.98378 144.742806 0.809796133 71.3032941 2.84217094e-14 71.7891718"></polygon>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -1,11 +1,11 @@
|
|||
FactoryBot.define do
|
||||
factory :integrations_hook, class: 'Integrations::Hook' do
|
||||
status { 1 }
|
||||
inbox_id { 1 }
|
||||
account_id { 1 }
|
||||
status { Integrations::Hook.statuses['enabled'] }
|
||||
inbox
|
||||
account
|
||||
app_id { 'slack' }
|
||||
settings { 'MyText' }
|
||||
hook_type { 1 }
|
||||
settings { { 'test': 'test' } }
|
||||
hook_type { Integrations::Hook.statuses['account'] }
|
||||
access_token { SecureRandom.hex }
|
||||
reference_id { SecureRandom.hex }
|
||||
end
|
||||
|
|
|
@ -1,15 +1,38 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe HookJob, type: :job do
|
||||
subject(:job) { described_class.perform_later(hook, message) }
|
||||
subject(:job) { described_class.perform_later(hook, event_name, event_data) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, account: account) }
|
||||
let(:message) { create(:message) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: create(:message, account: account) } }
|
||||
|
||||
it 'queues the job' do
|
||||
it 'enqueues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(hook, message)
|
||||
.with(hook, event_name, event_data)
|
||||
.on_queue('integrations')
|
||||
end
|
||||
|
||||
context 'when handleable events like message.created' do
|
||||
let(:process_service) { double }
|
||||
|
||||
before do
|
||||
allow(process_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
|
||||
hook = create(:integrations_hook, app_id: 'slack', account: account)
|
||||
allow(Integrations::Slack::SendOnSlackService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Slack::SendOnSlackService).to receive(:new)
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
|
||||
it 'calls Integrations::Dialogflow::ProcessorService when its a dialogflow intergation' do
|
||||
hook = create(:integrations_hook, app_id: 'dialogflow', account: account)
|
||||
allow(Integrations::Dialogflow::ProcessorService).to receive(:new).and_return(process_service)
|
||||
expect(Integrations::Dialogflow::ProcessorService).to receive(:new)
|
||||
described_class.perform_now(hook, event_name, event_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
73
spec/lib/integrations/dialogflow/processor_service_spec.rb
Normal file
73
spec/lib/integrations/dialogflow/processor_service_spec.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Dialogflow::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, app_id: 'dialogflow', account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :bot) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:dialogflow_service) { double }
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_text: 'hello'
|
||||
)
|
||||
end
|
||||
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response)
|
||||
allow(processor).to receive(:get_dialogflow_response).and_return(dialogflow_service)
|
||||
end
|
||||
|
||||
context 'when valid message and dialogflow returns fullfillment text' do
|
||||
it 'creates the response message' do
|
||||
expect(processor.perform.content).to eql('hello')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns fullfillment text to be empty' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { content: 'hello payload' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates the response message based on fulfillment messages' do
|
||||
expect(processor.perform.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns action' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'handoff' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'handsoff the conversation to agent' do
|
||||
processor.perform
|
||||
expect(conversation.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not bot' 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
|
||||
end
|
||||
end
|
|
@ -12,7 +12,7 @@ describe HookListener do
|
|||
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' }
|
||||
|
||||
context 'when hook is not configured' do
|
||||
it 'does not trigger hook job' do
|
||||
|
@ -24,9 +24,28 @@ describe HookListener do
|
|||
context 'when hook is configured' do
|
||||
it 'triggers hook job' do
|
||||
hook = create(:integrations_hook, account: account)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, message).once
|
||||
expect(HookJob).to receive(:perform_later).with(hook, 'message.created', message: message).once
|
||||
listener.message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#message_updated' do
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
context 'when hook is not configured' do
|
||||
it 'does not trigger hook job' do
|
||||
expect(HookJob).to receive(:perform_later).exactly(0).times
|
||||
listener.message_updated(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hook is configured' do
|
||||
it 'triggers hook job' do
|
||||
hook = create(:integrations_hook, account: account)
|
||||
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
|
||||
listener.message_updated(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -350,6 +350,15 @@ RSpec.describe Conversation, type: :model do
|
|||
end
|
||||
end
|
||||
|
||||
describe '#botintegration: when conversation created in inbox with dialogflow integration' do
|
||||
let(:hook) { create(:integrations_hook, app_id: 'dialogflow') }
|
||||
let(:conversation) { create(:conversation, inbox: hook.inbox) }
|
||||
|
||||
it 'returns conversation status as bot' do
|
||||
expect(conversation.status).to eq('bot')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_reply?' do
|
||||
describe 'on channels without 24 hour restriction' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
|
Loading…
Reference in a new issue