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:
Sojan Jose 2021-05-05 21:06:11 +05:30 committed by GitHub
parent fd0c26cdae
commit b30ecb27a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 303 additions and 26 deletions

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -38,6 +38,8 @@ class Integrations::App
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
when 'dialogflow'
false
else
true
end

View file

@ -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

View file

@ -8,3 +8,8 @@ webhooks:
logo: cable.svg
i18n_key: webhooks
action: /webhook
dialogflow:
id: dialogflow
logo: dialogflow.svg
i18n_key: dialogflow
action: /dialogflow

View file

@ -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."

View file

@ -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

View file

@ -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|

View 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

View 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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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) }