Feature: API Channel (#1052)

This commit is contained in:
Sojan Jose
2020-07-21 12:15:24 +05:30
committed by GitHub
parent fa04098c20
commit 8079bf50a0
40 changed files with 735 additions and 246 deletions

View File

@@ -0,0 +1,139 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::Facebook::MessageBuilder
attr_reader :response
def initialize(response, inbox, outgoing_echo = false)
@response = response
@inbox = inbox
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (outgoing_echo ? :outgoing : :incoming)
end
def perform
ActiveRecord::Base.transaction do
build_contact
build_message
end
rescue StandardError => e
Raven.capture_exception(e)
true
end
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end
def build_message
@message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end
params
end
def file_type_params(attachment)
{
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
{
external_url: attachment['url'],
coordinates_lat: lat,
coordinates_long: long,
fallback_title: attachment['title']
}
end
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: contact
}
end
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue StandardError => e
result = {}
Raven.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
}
end
end

View File

@@ -1,2 +0,0 @@
class Messages::IncomingMessageBuilder < Messages::MessageBuilder
end

View File

@@ -1,139 +1,57 @@
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
# Assumptions
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
# based on this we are showing "not sent from chatwoot" message in frontend
# Hence there is no need to set user_id in message for outgoing echo messages.
class Messages::MessageBuilder class Messages::MessageBuilder
attr_reader :response include ::FileTypeHelper
attr_reader :message
def initialize(response, inbox, outgoing_echo = false) def initialize(user, conversation, params)
@response = response @content = params[:content]
@inbox = inbox @private = params[:private] || false
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) @conversation = conversation
@message_type = (outgoing_echo ? :outgoing : :incoming) @user = user
@message_type = params[:message_type] || 'outgoing'
@content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
end end
def perform def perform
ActiveRecord::Base.transaction do @message = @conversation.messages.build(message_params)
build_contact if @attachments.present?
build_message @attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end
end end
rescue StandardError => e @message.save
Raven.capture_exception(e) @message
true
end end
private private
def contact def message_type
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact if @conversation.inbox.channel.class != Channel::Api && @message_type == 'incoming'
end raise StandardError, 'Incoming messages are only allowed in Api inboxes'
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
ContactAvatarJob.perform_later(@contact, contact_params[:remote_avatar_url]) if contact_params[:remote_avatar_url]
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end
def build_message
@message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding)
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
elsif file_type == :fallback
params.merge!(fallback_params(attachment))
end end
params @message_type
end end
def file_type_params(attachment) def sender
{ message_type == 'outgoing' ? @user : @conversation.contact
external_url: attachment['payload']['url'],
remote_file_url: attachment['payload']['url']
}
end
def location_params(attachment)
lat = attachment['payload']['coordinates']['lat']
long = attachment['payload']['coordinates']['long']
{
external_url: attachment['url'],
coordinates_lat: lat,
coordinates_long: long,
fallback_title: attachment['title']
}
end
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url']
}
end
def conversation_params
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
}
end end
def message_params def message_params
{ {
account_id: conversation.account_id, account_id: @conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: @conversation.inbox_id,
message_type: @message_type, message_type: message_type,
content: response.content, content: @content,
source_id: response.identifier, private: @private,
sender: contact sender: sender,
} content_type: @content_type,
end items: @items
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Exception => e
result = {}
Raven.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
} }
end end
end end

View File

@@ -1,2 +0,0 @@
class Messages::Outgoing::EchoBuilder < ::Messages::MessageBuilder
end

View File

@@ -1,46 +0,0 @@
class Messages::Outgoing::NormalBuilder
include ::FileTypeHelper
attr_reader :message
def initialize(user, conversation, params)
@content = params[:content]
@private = params[:private] || false
@conversation = conversation
@user = user
@fb_id = params[:fb_id]
@content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments]
end
def perform
@message = @conversation.messages.build(message_params)
if @attachments.present?
@attachments.each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end
end
@message.save
@message
end
private
def message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :outgoing,
content: @content,
private: @private,
sender: @user,
source_id: @fb_id,
content_type: @content_type,
items: @items
}
end
end

View File

@@ -11,9 +11,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def create def create
@contact = Current.account.contacts.new(contact_create_params) ActiveRecord::Base.transaction do
@contact.save! @contact = Current.account.contacts.new(contact_create_params)
render json: @contact @contact.save!
@contact_inbox = build_contact_inbox
end
end end
def update def update
@@ -26,6 +28,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
authorize(Contact) authorize(Contact)
end end
def build_contact_inbox
return if params[:inbox_id].blank?
inbox = Inbox.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create(contact: @contact, inbox: inbox, source_id: source_id)
end
def contact_params def contact_params
params.require(:contact).permit(:name, :email, :phone_number) params.require(:contact).permit(:name, :email, :phone_number)
end end

View File

@@ -5,8 +5,10 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
def create def create
user = current_user || @resource user = current_user || @resource
mb = Messages::Outgoing::NormalBuilder.new(user, @conversation, params) mb = Messages::MessageBuilder.new(user, @conversation, params)
@message = mb.perform @message = mb.perform
rescue StandardError => e
render_could_not_create_error(e.message)
end end
private private

View File

@@ -4,12 +4,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
def index def index
@inboxes = policy_scope(Current.account.inboxes) @inboxes = policy_scope(Current.account.inboxes.includes(:channel, :avatar_attachment))
end end
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel = web_widgets.create!(permitted_params[:channel].except(:type)) if permitted_params[:channel][:type] == 'web_widget' channel = create_channel
@inbox = Current.account.inboxes.build( @inbox = Current.account.inboxes.build(
name: permitted_params[:name], name: permitted_params[:name],
greeting_message: permitted_params[:greeting_message], greeting_message: permitted_params[:greeting_message],
@@ -52,21 +52,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def web_widgets
Current.account.web_widgets
end
def check_authorization def check_authorization
authorize(Inbox) authorize(Inbox)
end end
def create_channel
case permitted_params[:channel][:type]
when 'web_widget'
Current.account.web_widgets.create!(permitted_params[:channel].except(:type))
when 'api'
Current.account.api_channels.create!(permitted_params[:channel].except(:type))
when 'email'
Current.account.email_channels.create!(permitted_params[:channel].except(:type))
end
end
def permitted_params def permitted_params
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel: params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel:
[:type, :website_url, :widget_color, :welcome_title, :welcome_tagline]) [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email])
end end
def inbox_update_params def inbox_update_params
params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled, params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled,
channel: [:website_url, :widget_color, :welcome_title, :welcome_tagline]) channel: [:website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email])
end end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -63,6 +63,8 @@ const INBOX_TYPES = {
FB: 'Channel::FacebookPage', FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile', TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms', TWILIO: 'Channel::TwilioSms',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
}; };
const getInboxClassByType = type => { const getInboxClassByType = type => {
switch (type) { switch (type) {
@@ -78,6 +80,12 @@ const getInboxClassByType = type => {
case INBOX_TYPES.TWILIO: case INBOX_TYPES.TWILIO:
return 'ion-android-textsms'; return 'ion-android-textsms';
case INBOX_TYPES.API:
return 'ion-cloud';
case INBOX_TYPES.EMAIL:
return 'ion-email';
default: default:
return ''; return '';
} }

View File

@@ -16,6 +16,14 @@
v-if="channel === 'telegram'" v-if="channel === 'telegram'"
src="~dashboard/assets/images/channels/telegram.png" src="~dashboard/assets/images/channels/telegram.png"
/> />
<img
v-if="channel === 'api'"
src="~dashboard/assets/images/channels/api.png"
/>
<img
v-if="channel === 'email'"
src="~dashboard/assets/images/channels/email.png"
/>
<img <img
v-if="channel === 'line'" v-if="channel === 'line'"
src="~dashboard/assets/images/channels/line.png" src="~dashboard/assets/images/channels/line.png"
@@ -56,7 +64,10 @@ export default {
if (channel === 'twitter') { if (channel === 'twitter') {
return this.enabledFeatures.channel_twitter; return this.enabledFeatures.channel_twitter;
} }
return ['website', 'twilio'].includes(channel); if (channel === 'email') {
return this.enabledFeatures.channel_email;
}
return ['website', 'twilio', 'api'].includes(channel);
}, },
onItemClick() { onItemClick() {
if (this.isActive(this.channel)) { if (this.isActive(this.channel)) {

View File

@@ -115,6 +115,43 @@
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again" "ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
} }
}, },
"API_CHANNEL": {
"TITLE": "API Channel",
"DESC": "Integrate with API channel and start supporting your customers via chatwoot.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
"ERROR": "This field is required"
},
"WEBHOOK_URL": {
"LABEL": "Webhook Url",
"SUBTITLE": "Configure the url where you want to recieve callbacks from chatwoot on events.",
"PLACEHOLDER": "Webhook Url"
},
"SUBMIT_BUTTON": "Create API Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate you email inbox with chatwoot.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
"ERROR": "This field is required"
},
"EMAIL": {
"LABEL": "Email",
"SUBTITLE": "Email where your customers sends you support tickets",
"PLACEHOLDER": "Email"
},
"SUBMIT_BUTTON": "Create Email Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the email channel"
},
"FINISH_MESSAGE" : "Start forwarding your emails to the following email address."
},
"AUTH": { "AUTH": {
"TITLE": "Channels", "TITLE": "Channels",
"DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon." "DESC": "Currently we support Website live chat widgets, Facebook Pages and Twitter profiles as platforms. We have more platforms like Whatsapp, Email, Telegram and Line in the works, which will be out soon."

View File

@@ -34,6 +34,8 @@ export default {
'facebook', 'facebook',
'twitter', 'twitter',
'twilio', 'twilio',
'email',
'api',
'telegram', 'telegram',
'line', 'line',
], ],

View File

@@ -21,6 +21,14 @@
> >
</woot-code> </woot-code>
</div> </div>
<div class="medium-6 small-offset-3">
<woot-code
v-if="isAEmailInbox"
lang="html"
:script="currentInbox.forward_to_address"
>
</woot-code>
</div>
<router-link <router-link
class="button success nice" class="button success nice"
:to="{ :to="{
@@ -53,6 +61,9 @@ export default {
isATwilioInbox() { isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms'; return this.currentInbox.channel_type === 'Channel::TwilioSms';
}, },
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
message() { message() {
if (this.isATwilioInbox) { if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t( return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -60,6 +71,10 @@ export default {
)}`; )}`;
} }
if (this.isAEmailInbox) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}
if (!this.currentInbox.website_token) { if (!this.currentInbox.website_token) {
return this.$t('INBOX_MGMT.FINISH.MESSAGE'); return this.$t('INBOX_MGMT.FINISH.MESSAGE');
} }

View File

@@ -45,6 +45,12 @@
<span v-if="item.channel_type === 'Channel::TwilioSms'"> <span v-if="item.channel_type === 'Channel::TwilioSms'">
Twilio SMS Twilio SMS
</span> </span>
<span v-if="item.channel_type === 'Channel::Email'">
Email
</span>
<span v-if="item.channel_type === 'Channel::Api'">
Api
</span>
</td> </td>
<!-- Action Buttons --> <!-- Action Buttons -->

View File

@@ -2,12 +2,16 @@ import Facebook from './channels/Facebook';
import Website from './channels/Website'; import Website from './channels/Website';
import Twitter from './channels/Twitter'; import Twitter from './channels/Twitter';
import Twilio from './channels/Twilio'; import Twilio from './channels/Twilio';
import Api from './channels/Api';
import Email from './channels/Email';
const channelViewList = { const channelViewList = {
facebook: Facebook, facebook: Facebook,
website: Website, website: Website,
twitter: Twitter, twitter: Twitter,
twilio: Twilio, twilio: Twilio,
api: Api,
email: Email,
}; };
export default { export default {

View File

@@ -0,0 +1,110 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.API_CHANNEL.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">{{
$t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.webhookUrl.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.LABEL') }}
<input
v-model.trim="webhookUrl"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.PLACEHOLDER')
"
@blur="$v.webhookUrl.$touch"
/>
</label>
<p class="help-text">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.SUBTITLE') }}
</p>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.API_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const shouldBeWebhookUrl = (value = '') => value.startsWith('http');
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
channelName: '',
webhookUrl: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
webhookUrl: { required, shouldBeWebhookUrl },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const apiChannel = await this.$store.dispatch('inboxes/createChannel', {
name: this.channelName,
channel: {
type: 'api',
webhook_url: this.webhookUrl,
},
});
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: apiChannel.id,
},
});
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
}
},
},
};
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div class="wizard-body small-9 columns">
<page-header
:header-title="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.DESC')"
/>
<form class="row" @submit.prevent="createChannel()">
<div class="medium-8 columns">
<label :class="{ error: $v.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
v-model.trim="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
@blur="$v.channelName.$touch"
/>
<span v-if="$v.channelName.$error" class="message">{{
$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.CHANNEL_NAME.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.email.$error }">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.LABEL') }}
<input
v-model.trim="email"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.PLACEHOLDER')"
@blur="$v.email.$touch"
/>
</label>
<p class="help-text">
{{ $t('INBOX_MGMT.ADD.EMAIL_CHANNEL.EMAIL.SUBTITLE') }}
</p>
</div>
<div class="medium-12 columns">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const validEmail = (value = '') => value.includes('@');
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
data() {
return {
channelName: '',
email: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
email: { required, validEmail },
},
methods: {
async createChannel() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
const emailChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName,
channel: {
type: 'email',
email: this.email,
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: emailChannel.id,
},
});
} catch (error) {
this.showAlert(
this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.API.ERROR_MESSAGE')
);
}
},
},
};
</script>

View File

@@ -55,6 +55,18 @@ export const actions = {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false }); commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
} }
}, },
createChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WebChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw new Error(error);
}
},
createWebsiteChannel: async ({ commit }, params) => { createWebsiteChannel: async ({ commit }, params) => {
try { try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true }); commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });

View File

@@ -50,9 +50,7 @@ class WebhookListener < BaseListener
WebhookJob.perform_later(webhook.url, payload) WebhookJob.perform_later(webhook.url, payload)
end end
# Inbox webhooks # Deliver for API Inbox
inbox.webhooks.inbox.each do |webhook| WebhookJob.perform_later(inbox.channel.webhook_url, payload) if inbox.channel_type == 'Channel::Api'
WebhookJob.perform_later(webhook.url, payload)
end
end end
end end

View File

@@ -43,6 +43,8 @@ class Account < ApplicationRecord
has_many :twilio_sms, dependent: :destroy, class_name: '::Channel::TwilioSms' has_many :twilio_sms, dependent: :destroy, class_name: '::Channel::TwilioSms'
has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile' has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile'
has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget'
has_many :email_channels, dependent: :destroy, class_name: '::Channel::Email'
has_many :api_channels, dependent: :destroy, class_name: '::Channel::Api'
has_many :canned_responses, dependent: :destroy has_many :canned_responses, dependent: :destroy
has_many :webhooks, dependent: :destroy has_many :webhooks, dependent: :destroy
has_many :labels, dependent: :destroy has_many :labels, dependent: :destroy

19
app/models/channel/api.rb Normal file
View File

@@ -0,0 +1,19 @@
# == Schema Information
#
# Table name: channel_api
#
# id :bigint not null, primary key
# webhook_url :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
class Channel::Api < ApplicationRecord
self.table_name = 'channel_api'
validates :account_id, presence: true
belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy
end

View File

@@ -0,0 +1,35 @@
# == Schema Information
#
# Table name: channel_email
#
# id :bigint not null, primary key
# email :string not null
# forward_to_address :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
#
# Indexes
#
# index_channel_email_on_email (email) UNIQUE
# index_channel_email_on_forward_to_address (forward_to_address) UNIQUE
#
class Channel::Email < ApplicationRecord
self.table_name = 'channel_email'
validates :account_id, presence: true
belongs_to :account
validates :email, uniqueness: true
validates :forward_to_address, uniqueness: true
has_one :inbox, as: :channel, dependent: :destroy
before_validation :ensure_forward_to_address, on: :create
private
def ensure_forward_to_address
# TODO : implement better logic here
self.forward_to_address ||= "#{SecureRandom.hex}@xyc.com"
end
end

View File

@@ -17,9 +17,6 @@
# #
class Channel::FacebookPage < ApplicationRecord class Channel::FacebookPage < ApplicationRecord
# FIXME: this should be removed post 1.4 release. we moved avatars to inbox
include Avatarable
self.table_name = 'channel_facebook_pages' self.table_name = 'channel_facebook_pages'
validates :account_id, presence: true validates :account_id, presence: true

View File

@@ -0,0 +1,9 @@
json.payload do
json.contact do
json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact
end
json.contact_inbox do
json.inbox @contact_inbox&.inbox
json.source_id @contact_inbox&.source_id
end
end

View File

@@ -1,14 +1 @@
json.id @inbox.id json.partial! 'api/v1/models/inbox.json.jbuilder', resource: @inbox
json.channel_id @inbox.channel_id
json.name @inbox.name
json.channel_type @inbox.channel_type
json.greeting_enabled @inbox.greeting_enabled
json.greeting_message @inbox.greeting_message
json.avatar_url @inbox.try(:avatar_url)
json.website_token @inbox.channel.try(:website_token)
json.widget_color @inbox.channel.try(:widget_color)
json.website_url @inbox.channel.try(:website_url)
json.welcome_title @inbox.channel.try(:welcome_title)
json.welcome_tagline @inbox.channel.try(:welcome_tagline)
json.web_widget_script @inbox.channel.try(:web_widget_script)
json.enable_auto_assignment @inbox.enable_auto_assignment

View File

@@ -1,19 +1,5 @@
json.payload do json.payload do
json.array! @inboxes do |inbox| json.array! @inboxes do |inbox|
json.id inbox.id json.partial! 'api/v1/models/inbox.json.jbuilder', resource: inbox
json.channel_id inbox.channel_id
json.name inbox.name
json.channel_type inbox.channel_type
json.greeting_enabled inbox.greeting_enabled
json.greeting_message inbox.greeting_message
json.avatar_url inbox.try(:avatar_url)
json.page_id inbox.channel.try(:page_id)
json.widget_color inbox.channel.try(:widget_color)
json.website_url inbox.channel.try(:website_url)
json.welcome_title inbox.channel.try(:welcome_title)
json.welcome_tagline inbox.channel.try(:welcome_tagline)
json.enable_auto_assignment inbox.enable_auto_assignment
json.web_widget_script inbox.channel.try(:web_widget_script)
json.phone_number inbox.channel.try(:phone_number)
end end
end end

View File

@@ -1,14 +1 @@
json.id @inbox.id json.partial! 'api/v1/models/inbox.json.jbuilder', resource: @inbox
json.channel_id @inbox.channel_id
json.name @inbox.name
json.channel_type @inbox.channel_type
json.greeting_enabled @inbox.greeting_enabled
json.greeting_message @inbox.greeting_message
json.avatar_url @inbox.try(:avatar_url)
json.website_token @inbox.channel.try(:website_token)
json.widget_color @inbox.channel.try(:widget_color)
json.website_url @inbox.channel.try(:website_url)
json.welcome_title @inbox.channel.try(:welcome_title)
json.welcome_tagline @inbox.channel.try(:welcome_tagline)
json.web_widget_script @inbox.channel.try(:web_widget_script)
json.enable_auto_assignment @inbox.enable_auto_assignment

View File

@@ -0,0 +1,16 @@
json.id resource.id
json.channel_id resource.channel_id
json.name resource.name
json.channel_type resource.channel_type
json.greeting_enabled resource.greeting_enabled
json.greeting_message resource.greeting_message
json.avatar_url resource.try(:avatar_url)
json.page_id resource.channel.try(:page_id)
json.widget_color resource.channel.try(:widget_color)
json.website_url resource.channel.try(:website_url)
json.welcome_title resource.channel.try(:welcome_title)
json.welcome_tagline resource.channel.try(:welcome_tagline)
json.enable_auto_assignment resource.enable_auto_assignment
json.web_widget_script resource.channel.try(:web_widget_script)
json.forward_to_address resource.channel.try(:forward_to_address)
json.phone_number resource.channel.try(:phone_number)

View File

@@ -0,0 +1,9 @@
class CreateApiChannel < ActiveRecord::Migration[6.0]
def change
create_table :channel_api do |t|
t.integer :account_id, null: false
t.string :webhook_url, null: false
t.timestamps
end
end
end

View File

@@ -0,0 +1,10 @@
class CreateEmailChannel < ActiveRecord::Migration[6.0]
def change
create_table :channel_email do |t|
t.integer :account_id, null: false
t.string :email, null: false, index: { unique: true }
t.string :forward_to_address, null: false, index: { unique: true }
t.timestamps
end
end
end

View File

@@ -120,6 +120,23 @@ ActiveRecord::Schema.define(version: 2020_07_19_171437) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
create_table "channel_api", force: :cascade do |t|
t.integer "account_id", null: false
t.string "webhook_url", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
create_table "channel_email", force: :cascade do |t|
t.integer "account_id", null: false
t.string "email", null: false
t.string "forward_to_address", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["email"], name: "index_channel_email_on_email", unique: true
t.index ["forward_to_address"], name: "index_channel_email_on_forward_to_address", unique: true
end
create_table "channel_facebook_pages", id: :serial, force: :cascade do |t| create_table "channel_facebook_pages", id: :serial, force: :cascade do |t|
t.string "page_id", null: false t.string "page_id", null: false
t.string "user_access_token", null: false t.string "user_access_token", null: false

View File

@@ -9,10 +9,10 @@ class Integrations::Facebook::MessageCreator
def perform def perform
# begin # begin
if outgoing_message_via_echo? if agent_message_via_echo?
create_outgoing_message create_agent_message
else else
create_incoming_message create_contact_message
end end
# rescue => e # rescue => e
# Raven.capture_exception(e) # Raven.capture_exception(e)
@@ -21,22 +21,22 @@ class Integrations::Facebook::MessageCreator
private private
def outgoing_message_via_echo? def agent_message_via_echo?
response.echo? && !response.sent_from_chatwoot_app? response.echo? && !response.sent_from_chatwoot_app?
# this means that it is an outgoing message from page, but not sent from chatwoot. # this means that it is an agent message from page, but not sent from chatwoot.
# User can send from fb page directly on mobile messenger, so this case should be handled as outgoing message # User can send from fb page directly on mobile / web messenger, so this case should be handled as agent message
end end
def create_outgoing_message def create_agent_message
Channel::FacebookPage.where(page_id: response.sender_id).each do |page| Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
mb = Messages::Outgoing::EchoBuilder.new(response, page.inbox, true) mb = Messages::Facebook::MessageBuilder.new(response, page.inbox, true)
mb.perform mb.perform
end end
end end
def create_incoming_message def create_contact_message
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page| Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
mb = Messages::IncomingMessageBuilder.new(response, page.inbox) mb = Messages::Facebook::MessageBuilder.new(response, page.inbox)
mb.perform mb.perform
end end
end end

View File

@@ -1,6 +1,6 @@
class Webhooks::Trigger class Webhooks::Trigger
def self.execute(url, payload) def self.execute(url, payload)
RestClient.post(url, payload) RestClient.post(url, payload.to_json, { content_type: :json, accept: :json })
rescue StandardError => e rescue StandardError => e
Raven.capture_exception(e) Raven.capture_exception(e)
end end

View File

@@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
describe ::Messages::IncomingMessageBuilder do describe ::Messages::Facebook::MessageBuilder do
subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform } subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform }
let!(:facebook_channel) { create(:channel_facebook_page) } let!(:facebook_channel) { create(:channel_facebook_page) }

View File

@@ -0,0 +1,54 @@
require 'rails_helper'
describe ::Messages::MessageBuilder do
subject(:message_builder) { described_class.new(user, conversation, params).perform }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:params) do
ActionController::Parameters.new({
content: 'test'
})
end
describe '#perform' do
it 'creates a message' do
message = message_builder
expect(message.content).to eq params[:content]
end
end
describe '#perform when message_type is incoming' do
context 'when channel is not api' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
message_type: 'incoming'
})
end
it 'creates throws error when channel is not api' do
expect { message_builder }.to raise_error 'Incoming messages are only allowed in Api inboxes'
end
end
context 'when channel is api' do
let(:channel_api) { create(:channel_api, account: account) }
let(:conversation) { create(:conversation, inbox: channel_api.inbox, account: account) }
let(:params) do
ActionController::Parameters.new({
content: 'test',
message_type: 'incoming'
})
end
it 'creates message when channel is api' do
message = message_builder
expect(message.message_type).to eq params[:message_type]
end
end
end
end

View File

@@ -65,6 +65,7 @@ RSpec.describe 'Contacts API', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) } let(:admin) { create(:user, account: account, role: :administrator) }
let(:inbox) { create(:inbox, account: account) }
it 'creates the contact' do it 'creates the contact' do
expect do expect do
@@ -74,6 +75,15 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
it 'creates the contact identifier when inbox id is passed' do
expect do
post "/api/v1/accounts/#{account.id}/contacts", headers: admin.create_new_auth_token,
params: valid_params.merge({ inbox_id: inbox.id })
end.to change(ContactInbox, :count).by(1)
expect(response).to have_http_status(:success)
end
end end
end end

View File

@@ -0,0 +1,9 @@
FactoryBot.define do
factory :channel_api, class: 'Channel::Api' do
webhook_url { 'http://example.com' }
account
after(:create) do |channel_api|
create(:inbox, channel: channel_api, account: channel_api.account)
end
end
end

View File

@@ -5,10 +5,10 @@ describe Webhooks::Trigger do
describe '#execute' do describe '#execute' do
it 'triggers webhook' do it 'triggers webhook' do
params = { hello: 'hello' } params = { hello: :hello }
url = 'htpps://test.com' url = 'https://test.com'
expect(RestClient).to receive(:post).with(url, params).once expect(RestClient).to receive(:post).with(url, params.to_json, { accept: :json, content_type: :json }).once
trigger.execute(url, params) trigger.execute(url, params)
end end
end end