[Feature] Email collect message hooks (#331)

- Add email collect hook on creating conversation
- Merge contact if it already exist
This commit is contained in:
Sojan Jose 2020-01-09 13:06:40 +05:30 committed by Pranav Raj S
parent 59d4eaeca7
commit 722f540b03
68 changed files with 1111 additions and 544 deletions

View file

@ -4,13 +4,13 @@ require:
- rubocop-rspec - rubocop-rspec
inherit_from: .rubocop_todo.yml inherit_from: .rubocop_todo.yml
Metrics/LineLength: Layout/LineLength:
Max: 150 Max: 150
Metrics/ClassLength: Metrics/ClassLength:
Max: 125 Max: 125
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 15 Max: 15
Documentation: Style/Documentation:
Enabled: false Enabled: false
Style/FrozenStringLiteralComment: Style/FrozenStringLiteralComment:
Enabled: false Enabled: false

View file

@ -6,132 +6,137 @@ require 'open-uri'
# based on this we are showing "not sent from chatwoot" message in frontend # 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. # Hence there is no need to set user_id in message for outgoing echo messages.
module Messages class Messages::MessageBuilder
class MessageBuilder attr_reader :response
attr_reader :response
def initialize(response, inbox, outgoing_echo = false) def initialize(response, inbox, outgoing_echo = false)
@response = response @response = response
@inbox = inbox @inbox = inbox
@sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) @sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (outgoing_echo ? :outgoing : :incoming) @message_type = (outgoing_echo ? :outgoing : :incoming)
end
def perform
ActiveRecord::Base.transaction do
build_contact
build_message
end end
rescue StandardError => e
Raven.capture_exception(e)
true
end
def perform private
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 contact def build_contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact return if contact.present?
end
def build_contact @contact = Contact.create!(contact_params.except(:remote_avatar_url))
return if contact.present? avatar_resource = LocalResource.new(contact_params[:remote_avatar_url])
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
@contact = Contact.create!(contact_params.except(:remote_avatar_url)) @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) end
@contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding)
ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) def build_message
end @message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
def build_message attachment_obj = @message.build_attachment(attachment_params(attachment).except(:remote_file_url))
@message = conversation.messages.create!(message_params) attachment_obj.save!
(response.attachments || []).each do |attachment| attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
attachment_obj = @message.build_attachment(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) || Conversation.create!(conversation_params)
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,
fb_id: response.identifier
}
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 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
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,
fb_id: response.identifier
}
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 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

View file

@ -1,55 +1,51 @@
module Api class Api::V1::InboxMembersController < Api::BaseController
module V1 before_action :fetch_inbox, only: [:create, :show]
class InboxMembersController < Api::BaseController before_action :current_agents_ids, only: [:create]
before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create]
def create def create
# update also done via same action # update also done via same action
if @inbox if @inbox
begin begin
update_agents_list update_agents_list
head :ok head :ok
rescue StandardError => e rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}" Rails.logger.debug "Rescued: #{e.inspect}"
render_could_not_create_error('Could not add agents to inbox') render_could_not_create_error('Could not add agents to inbox')
end
else
render_not_found_error('Agents or inbox not found')
end
end
def show
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
end
private
def update_agents_list
# get all the user_ids which the inbox currently has as members.
# get the list of user_ids from params
# the missing ones are the agents which are to be deleted from the inbox
# the new ones are the agents which are to be added to the inbox
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
end
def agents_to_be_added_ids
params[:user_ids] - @current_agents_ids
end
def agents_to_be_removed_ids
@current_agents_ids - params[:user_ids]
end
def current_agents_ids
@current_agents_ids = @inbox.members.pluck(:id)
end
def fetch_inbox
@inbox = current_account.inboxes.find(params[:inbox_id])
end end
else
render_not_found_error('Agents or inbox not found')
end end
end end
def show
@agents = current_account.users.where(id: @inbox.members.pluck(:user_id))
end
private
def update_agents_list
# get all the user_ids which the inbox currently has as members.
# get the list of user_ids from params
# the missing ones are the agents which are to be deleted from the inbox
# the new ones are the agents which are to be added to the inbox
agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) }
agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) }
end
def agents_to_be_added_ids
params[:user_ids] - @current_agents_ids
end
def agents_to_be_removed_ids
@current_agents_ids - params[:user_ids]
end
def current_agents_ids
@current_agents_ids = @inbox.members.pluck(:id)
end
def fetch_inbox
@inbox = current_account.inboxes.find(params[:inbox_id])
end
end end

View file

@ -0,0 +1,29 @@
class Api::V1::Widget::BaseController < ApplicationController
private
def conversation
@conversation ||= @contact_inbox.conversations.find_by(
inbox_id: auth_token_params[:inbox_id]
)
end
def auth_token_params
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token
end
def header_name
'X-Auth-Token'
end
def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
@account = @web_widget.account
end
def set_contact
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
source_id: auth_token_params[:source_id]
)
@contact = @contact_inbox.contact
end
end

View file

@ -1,6 +1,8 @@
class Api::V1::Widget::MessagesController < ActionController::Base class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
skip_before_action :verify_authenticity_token before_action :set_web_widget
before_action :set_contact
before_action :set_conversation, only: [:create] before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
def index def index
@messages = conversation.nil? ? [] : message_finder.perform @messages = conversation.nil? ? [] : message_finder.perform
@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base
def create def create
@message = conversation.messages.new(message_params) @message = conversation.messages.new(message_params)
@message.save! @message.save!
render json: @message
end
def update
@message.update!(input_submitted_email: permitted_params[:contact][:email])
update_contact(permitted_params[:contact][:email])
head :no_content
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end end
private private
def conversation
@conversation ||= ::Conversation.find_by(
contact_id: cookie_params[:contact_id],
inbox_id: cookie_params[:inbox_id]
)
end
def set_conversation def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil? @conversation = ::Conversation.create!(conversation_params) if conversation.nil?
end end
@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
{ {
account_id: inbox.account_id, account_id: inbox.account_id,
inbox_id: inbox.id, inbox_id: inbox.id,
contact_id: cookie_params[:contact_id], contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { additional_attributes: {
browser: browser_params, browser: browser_params,
referer: permitted_params[:message][:referer_url], referer: permitted_params[:message][:referer_url],
@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
end end
def inbox def inbox
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id]) @inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
end
def cookie_params
@cookie_params ||= JWT.decode(
request.headers[header_name], secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
end end
def message_finder_params def message_finder_params
@ -83,15 +82,27 @@ class Api::V1::Widget::MessagesController < ActionController::Base
@message_finder ||= MessageFinder.new(conversation, message_finder_params) @message_finder ||= MessageFinder.new(conversation, message_finder_params)
end end
def header_name def update_contact(email)
'X-Auth-Token' contact_with_email = @account.contacts.find_by(email: email)
if contact_with_email
::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform
else
@contact.update!(
email: permitted_params[:contact][:email],
name: contact_name
)
end
end
def contact_name
permitted_params[:contact][:email].split('@')[0]
end end
def permitted_params def permitted_params
params.permit(:before, message: [:content, :referer_url, :timestamp]) params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
end end
def secret_key def set_message
Rails.application.secrets.secret_key_base @message = @web_widget.inbox.messages.find(permitted_params[:id])
end end
end end

View file

@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base
before_action :set_contact before_action :set_contact
before_action :build_contact before_action :build_contact
def index
render
end
private private
def set_contact
return if cookie_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: cookie_params[:source_id]
)
@contact = contact_inbox ? contact_inbox.contact : nil
end
def set_token
@token = conversation_token
end
def set_web_widget def set_web_widget
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
end end
def set_token
@token = permitted_params[:cw_conversation]
@auth_token_params = if @token.present?
::Widget::TokenService.new(token: @token).decode_token
else
{}
end
end
def set_contact
return if @auth_token_params[:source_id].nil?
contact_inbox = ::ContactInbox.find_by(
inbox_id: @web_widget.inbox.id,
source_id: @auth_token_params[:source_id]
)
@contact = contact_inbox ? contact_inbox.contact : nil
end
def build_contact def build_contact
return if @contact.present? return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox contact_inbox = @web_widget.create_contact_inbox
@contact = contact_inbox.contact @contact = contact_inbox.contact
payload = { payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
source_id: contact_inbox.source_id, @token = ::Widget::TokenService.new(payload: payload).generate_token
contact_id: @contact.id,
inbox_id: @web_widget.inbox.id
}
@token = JWT.encode payload, secret_key, 'HS256'
end
def cookie_params
return @cookie_params if @cookie_params.present?
if conversation_token.present?
begin
@cookie_params = JWT.decode(
conversation_token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
@cookie_params = {}
end
return @cookie_params
end
{}
end
def conversation_token
permitted_params[:cw_conversation]
end end
def permitted_params def permitted_params
params.permit(:website_token, :cw_conversation) params.permit(:website_token, :cw_conversation)
end end
def secret_key
Rails.application.secrets.secret_key_base
end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,3 +0,0 @@
<template>
<span class="spinner small"></span>
</template>

View file

@ -12,7 +12,7 @@
</template> </template>
<script> <script>
import Spinner from '../Spinner'; import Spinner from 'shared/components/Spinner';
export default { export default {
components: { components: {

View file

@ -15,7 +15,7 @@
/* eslint no-console: 0 */ /* eslint no-console: 0 */
/* global bus */ /* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Spinner from '../Spinner'; import Spinner from 'shared/components/Spinner';
export default { export default {
props: ['conversationId'], props: ['conversationId'],

View file

@ -1,6 +1,7 @@
/* eslint no-plusplus: 0 */ /* eslint no-plusplus: 0 */
/* eslint-env browser */ /* eslint-env browser */
import Spinner from 'shared/components/Spinner';
import Bar from './widgets/chart/BarChart'; import Bar from './widgets/chart/BarChart';
import Code from './Code'; import Code from './Code';
import LoadingState from './widgets/LoadingState'; import LoadingState from './widgets/LoadingState';
@ -8,7 +9,6 @@ import Modal from './Modal';
import ModalHeader from './ModalHeader'; import ModalHeader from './ModalHeader';
import ReportStatsCard from './widgets/ReportStatsCard'; import ReportStatsCard from './widgets/ReportStatsCard';
import SidemenuIcon from './SidemenuIcon'; import SidemenuIcon from './SidemenuIcon';
import Spinner from './Spinner';
import SubmitButton from './buttons/FormSubmitButton'; import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs'; import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem'; import TabsItem from './ui/Tabs/TabsItem';

View file

@ -74,13 +74,13 @@ export default {
return this.formatMessage(this.data.content); return this.formatMessage(this.data.content);
}, },
alignBubble() { alignBubble() {
return this.data.message_type === 1 ? 'right' : 'left'; return !this.data.message_type ? 'left' : 'right';
}, },
readableTime() { readableTime() {
return this.messageStamp(this.data.created_at); return this.messageStamp(this.data.created_at);
}, },
isBubble() { isBubble() {
return this.data.message_type === 1 || this.data.message_type === 0; return [0, 1, 3].includes(this.data.message_type);
}, },
isPrivate() { isPrivate() {
return this.data.private; return this.data.private;

View file

@ -120,7 +120,7 @@ const IFrameHelper = {
createFrame: ({ baseUrl, websiteToken }) => { createFrame: ({ baseUrl, websiteToken }) => {
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
const cwCookie = Cookies.get('cw_conversation'); const cwCookie = Cookies.get('cw_conversation');
let widgetUrl = `${baseUrl}/widgets?website_token=${websiteToken}`; let widgetUrl = `${baseUrl}/widget?website_token=${websiteToken}`;
if (cwCookie) { if (cwCookie) {
widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`; widgetUrl = `${widgetUrl}&cw_conversation=${cwCookie}`;
} }
@ -143,6 +143,18 @@ const IFrameHelper = {
); );
}, },
initPostMessageCommunication: () => { initPostMessageCommunication: () => {
const events = {
loaded: message => {
Cookies.set('cw_conversation', message.config.authToken);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
},
set_auth_token: message => {
Cookies.set('cw_conversation', message.authToken);
},
};
window.onmessage = e => { window.onmessage = e => {
if ( if (
typeof e.data !== 'string' || typeof e.data !== 'string' ||
@ -151,11 +163,8 @@ const IFrameHelper = {
return; return;
} }
const message = JSON.parse(e.data.replace('chatwoot-widget:', '')); const message = JSON.parse(e.data.replace('chatwoot-widget:', ''));
if (message.event === 'loaded') { if (typeof events[message.event] === 'function') {
Cookies.set('cw_conversation', message.config.authToken); events[message.event](message);
IFrameHelper.sendMessage('config-set', {});
IFrameHelper.onLoad(message.config.channelConfig);
IFrameHelper.setCurrentUrl();
} }
}; };
}, },
@ -195,7 +204,6 @@ const IFrameHelper = {
onClickChatBubble(); onClickChatBubble();
}, },
setCurrentUrl: () => { setCurrentUrl: () => {
console.log(IFrameHelper.getAppFrame(), document);
IFrameHelper.sendMessage('set-current-url', { IFrameHelper.sendMessage('set-current-url', {
refererURL: window.location.href, refererURL: window.location.href,
}); });

View file

@ -1,9 +1,12 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuelidate from 'vuelidate';
import store from '../widget/store'; import store from '../widget/store';
import App from '../widget/App.vue'; import App from '../widget/App.vue';
import router from '../widget/router'; import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable'; import ActionCableConnector from '../widget/helpers/actionCable';
Vue.use(Vuelidate);
Vue.config.productionTip = false; Vue.config.productionTip = false;
window.onload = () => { window.onload = () => {
window.WOOT_WIDGET = new Vue({ window.WOOT_WIDGET = new Vue({

View file

@ -1,12 +0,0 @@
import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
const createContact = async (inboxId, accountId) => {
const urlData = authEndPoint.createContact(inboxId, accountId);
const result = await API.post(urlData.url, urlData.params);
return result;
};
export default {
createContact,
};

View file

@ -0,0 +1,10 @@
import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
export const updateContact = async ({ messageId, email }) => {
const urlData = authEndPoint.updateContact(messageId);
const result = await API.patch(urlData.url, {
contact: { email },
});
return result;
};

View file

@ -14,7 +14,12 @@ const getConversation = ({ before }) => ({
params: { before }, params: { before },
}); });
const updateContact = id => ({
url: `/api/v1/widget/messages/${id}${window.location.search}`,
});
export default { export default {
sendMessage, sendMessage,
getConversation, getConversation,
updateContact,
}; };

View file

@ -9,6 +9,7 @@ $input-height: $space-two * 2;
appearance: none; appearance: none;
background: $color-white; background: $color-white;
border: 1px solid $color-border;
border-radius: $border-radius; border-radius: $border-radius;
box-sizing: border-box; box-sizing: border-box;
color: $color-body; color: $color-body;

View file

@ -57,6 +57,9 @@ $color-background-light: #fafafa;
$color-white: #fff; $color-white: #fff;
$color-body: #3c4858; $color-body: #3c4858;
$color-heading: #1f2d3d; $color-heading: #1f2d3d;
$color-error: #ff4949;
// Thumbnail // Thumbnail
$thumbnail-radius: 4rem; $thumbnail-radius: 4rem;
@ -89,3 +92,8 @@ $footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10; $header-expanded-height: $space-medium * 10;
$font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; $font-family: 'Inter', -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
$ionicons-font-path: '~ionicons/fonts';
$spinkit-spinner-color: $color-white !default;
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
$spinkit-size: 1.6rem !default;

View file

@ -4,6 +4,8 @@
@import 'mixins'; @import 'mixins';
@import 'forms'; @import 'forms';
@import 'shared/assets/fonts/inter'; @import 'shared/assets/fonts/inter';
@import '~ionicons/scss/ionicons';
@import '~spinkit/scss/spinners/7-three-bounce';
html, html,
body { body {

View file

@ -9,7 +9,13 @@
/> />
</div> </div>
<div class="message-wrap"> <div class="message-wrap">
<AgentMessageBubble :message="message" /> <AgentMessageBubble
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="messageId"
:message-type="messageType"
:message="message"
/>
<p v-if="showAvatar" class="agent-name"> <p v-if="showAvatar" class="agent-name">
{{ agentName }} {{ agentName }}
</p> </p>
@ -32,7 +38,22 @@ export default {
avatarUrl: String, avatarUrl: String,
agentName: String, agentName: String,
showAvatar: Boolean, showAvatar: Boolean,
createdAt: Number, contentType: {
type: String,
default: '',
},
messageContentAttributes: {
type: Object,
default: () => {},
},
messageType: {
type: Number,
default: 1,
},
messageId: {
type: Number,
default: 0,
},
}, },
}; };
</script> </script>

View file

@ -1,20 +1,42 @@
<template> <template>
<div class="chat-bubble agent" v-html="formatMessage(message)"></div> <div class="chat-bubble agent">
<span v-html="formatMessage(message)"></span>
<email-input
v-if="shouldShowInput"
:message-id="messageId"
:message-content-attributes="messageContentAttributes"
/>
</div>
</template> </template>
<script> <script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import EmailInput from './template/EmailInput';
export default { export default {
name: 'AgentMessageBubble', name: 'AgentMessageBubble',
components: {
EmailInput,
},
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin],
props: { props: {
message: String, message: String,
contentType: String,
messageType: Number,
messageId: Number,
messageContentAttributes: {
type: Object,
default: () => {},
},
},
computed: {
shouldShowInput() {
return this.contentType === 'input_email' && this.messageType === 3;
},
}, },
}; };
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss"> <style lang="scss">
@import '~widget/assets/scss/variables.scss'; @import '~widget/assets/scss/variables.scss';

View file

@ -7,9 +7,13 @@
<AgentMessage <AgentMessage
v-else v-else
:agent-name="agentName" :agent-name="agentName"
:avatar-url="avatarUrl"
:content-type="message.content_type"
:message-content-attributes="message.content_attributes"
:message-id="message.id"
:message-type="message.message_type"
:message="message.content" :message="message.content"
:show-avatar="message.showAvatar" :show-avatar="message.showAvatar"
:avatar-url="avatarUrl"
/> />
</template> </template>
@ -32,9 +36,18 @@ export default {
return this.message.message_type === MESSAGE_TYPE.INCOMING; return this.message.message_type === MESSAGE_TYPE.INCOMING;
}, },
agentName() { agentName() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return 'Bot';
}
return this.message.sender ? this.message.sender.name : ''; return this.message.sender ? this.message.sender.name : '';
}, },
avatarUrl() { avatarUrl() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
// eslint-disable-next-line
return require('dashboard/assets/images/chatwoot_bot.png');
}
return this.message.sender ? this.message.sender.avatar_url : ''; return this.message.sender ? this.message.sender.avatar_url : '';
}, },
}, },

View file

@ -6,7 +6,7 @@
@click="onClick" @click="onClick"
> >
<span v-if="!loading" class="icon-holder"> <span v-if="!loading" class="icon-holder">
<img src="~widget/assets/images/message-send.svg" /> <i class="ion-android-send" />
</span> </span>
<spinner v-else size="small" /> <spinner v-else size="small" />
</button> </button>
@ -51,6 +51,7 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
fill: $color-white; fill: $color-white;
font-size: $font-size-big;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
} }

View file

@ -0,0 +1,115 @@
<template>
<div>
<form
v-if="!hasSubmitted"
class="email-input-group"
@submit.prevent="onSubmit()"
>
<input
v-model.trim="email"
class="form-input small"
placeholder="Please enter your email"
:class="{ error: $v.email.$error }"
@input="$v.email.$touch"
/>
<button
class="button"
:disabled="$v.email.$invalid"
:style="{ background: widgetColor, borderColor: widgetColor }"
>
<i v-if="!uiFlags.isUpdating" class="ion-android-arrow-forward" />
<spinner v-else />
</button>
</form>
<span v-else>
<i>{{ messageContentAttributes.submitted_email }}</i>
</span>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner';
import { required, email } from 'vuelidate/lib/validators';
export default {
components: {
Spinner,
},
props: {
messageId: {
type: Number,
required: true,
},
messageContentAttributes: {
type: Object,
default: () => {},
},
},
data() {
return {
email: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contact/getUIFlags',
widgetColor: 'appConfig/getWidgetColor',
}),
hasSubmitted() {
return (
this.messageContentAttributes &&
this.messageContentAttributes.submitted_email
);
},
},
validations: {
email: {
required,
email,
},
},
methods: {
onSubmit() {
this.$store.dispatch('contact/updateContactAttributes', {
email: this.email,
messageId: this.messageId,
});
},
},
};
</script>
<style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
.email-input-group {
display: flex;
margin: $space-small 0;
min-width: 200px;
input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
&.error {
border-color: $color-error;
}
}
.button {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
font-size: $font-size-large;
height: auto;
margin-left: -1px;
.spinner {
display: block;
padding: 0;
height: auto;
width: auto;
}
}
}
</style>

View file

@ -9,4 +9,6 @@ export const MESSAGE_STATUS = {
export const MESSAGE_TYPE = { export const MESSAGE_TYPE = {
INCOMING: 0, INCOMING: 0,
OUTGOING: 1, OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
}; };

View file

@ -1,13 +1,15 @@
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import conversation from 'widget/store/modules/conversation';
import appConfig from 'widget/store/modules/appConfig'; import appConfig from 'widget/store/modules/appConfig';
import contact from 'widget/store/modules/contact';
import conversation from 'widget/store/modules/conversation';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
modules: { modules: {
appConfig, appConfig,
contact,
conversation, conversation,
}, },
}); });

View file

@ -0,0 +1,45 @@
import { updateContact } from 'widget/api/contact';
const state = {
uiFlags: {
isUpdating: false,
},
};
const getters = {
getUIFlags: $state => $state.uiFlags,
};
const actions = {
updateContactAttributes: async ({ commit }, { email, messageId }) => {
commit('toggleUpdateStatus', true);
try {
await updateContact({ email, messageId });
commit(
'conversation/updateMessage',
{
id: messageId,
content_attributes: { submitted_email: email },
},
{ root: true }
);
} catch (error) {
// Ignore error
}
commit('toggleUpdateStatus', false);
},
};
const mutations = {
toggleUpdateStatus($state, status) {
$state.uiFlags.isUpdating = status;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -135,6 +135,13 @@ export const mutations = {
payload.map(message => Vue.set($state.conversations, message.id, message)); payload.map(message => Vue.set($state.conversations, message.id, message));
}, },
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes,
};
},
}; };
export default { export default {

View file

@ -17,31 +17,29 @@
# index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE # index_channel_facebook_pages_on_page_id_and_account_id (page_id,account_id) UNIQUE
# #
module Channel class Channel::FacebookPage < ApplicationRecord
class FacebookPage < ApplicationRecord include Avatarable
include Avatarable
self.table_name = 'channel_facebook_pages' self.table_name = 'channel_facebook_pages'
validates :account_id, presence: true validates :account_id, presence: true
validates :page_id, uniqueness: { scope: :account_id } validates :page_id, uniqueness: { scope: :account_id }
has_one_attached :avatar has_one_attached :avatar
belongs_to :account belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy has_one :inbox, as: :channel, dependent: :destroy
before_destroy :unsubscribe before_destroy :unsubscribe
def name def name
'Facebook' 'Facebook'
end end
private private
def unsubscribe def unsubscribe
Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token) Facebook::Messenger::Subscriptions.unsubscribe(access_token: page_access_token)
rescue => e rescue => e
true true
end
end end
end end

View file

@ -16,33 +16,31 @@
# index_channel_web_widgets_on_website_token (website_token) UNIQUE # index_channel_web_widgets_on_website_token (website_token) UNIQUE
# #
module Channel class Channel::WebWidget < ApplicationRecord
class WebWidget < ApplicationRecord self.table_name = 'channel_web_widgets'
self.table_name = 'channel_web_widgets'
validates :website_name, presence: true validates :website_name, presence: true
validates :website_url, presence: true validates :website_url, presence: true
validates :widget_color, presence: true validates :widget_color, presence: true
belongs_to :account belongs_to :account
has_one :inbox, as: :channel, dependent: :destroy has_one :inbox, as: :channel, dependent: :destroy
has_secure_token :website_token has_secure_token :website_token
def name def name
'Website' 'Website'
end end
def create_contact_inbox def create_contact_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000)) contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000))
::ContactInbox.create!( ::ContactInbox.create!(
contact_id: contact.id, contact_id: contact.id,
inbox_id: inbox.id, inbox_id: inbox.id,
source_id: SecureRandom.uuid source_id: SecureRandom.uuid
) )
rescue StandardError => e rescue StandardError => e
Rails.logger e Rails.logger e
end
end end
end end
end end

View file

@ -29,4 +29,6 @@ class ContactInbox < ApplicationRecord
belongs_to :contact belongs_to :contact
belongs_to :inbox belongs_to :inbox
has_many :conversations, dependent: :destroy
end end

View file

@ -13,6 +13,7 @@
# account_id :integer not null # account_id :integer not null
# assignee_id :integer # assignee_id :integer
# contact_id :bigint # contact_id :bigint
# contact_inbox_id :bigint
# display_id :integer not null # display_id :integer not null
# inbox_id :integer not null # inbox_id :integer not null
# #
@ -20,6 +21,11 @@
# #
# index_conversations_on_account_id (account_id) # index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE # index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_contact_inbox_id (contact_inbox_id)
#
# Foreign Keys
#
# fk_rails_... (contact_inbox_id => contact_inboxes.id)
# #
class Conversation < ApplicationRecord class Conversation < ApplicationRecord
@ -38,6 +44,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true belongs_to :assignee, class_name: 'User', optional: true
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox
has_many :messages, dependent: :destroy, autosave: true has_many :messages, dependent: :destroy, autosave: true

View file

@ -49,6 +49,10 @@ class Inbox < ApplicationRecord
channel.class.name.to_s == 'Channel::FacebookPage' channel.class.name.to_s == 'Channel::FacebookPage'
end end
def web_widget?
channel.class.name.to_s == 'Channel::WebWidget'
end
def next_available_agent def next_available_agent
user_id = Redis::Alfred.rpoplpush(round_robin_key, round_robin_key) user_id = Redis::Alfred.rpoplpush(round_robin_key, round_robin_key)
account.users.find_by(id: user_id) account.users.find_by(id: user_id)

View file

@ -2,18 +2,20 @@
# #
# Table name: messages # Table name: messages
# #
# id :integer not null, primary key # id :integer not null, primary key
# content :text # content :text
# message_type :integer not null # content_attributes :json
# private :boolean default(FALSE) # content_type :integer default("text")
# status :integer default("sent") # message_type :integer not null
# created_at :datetime not null # private :boolean default(FALSE)
# updated_at :datetime not null # status :integer default("sent")
# account_id :integer not null # created_at :datetime not null
# conversation_id :integer not null # updated_at :datetime not null
# fb_id :string # account_id :integer not null
# inbox_id :integer not null # conversation_id :integer not null
# user_id :integer # fb_id :string
# inbox_id :integer not null
# user_id :integer
# #
# Indexes # Indexes
# #
@ -27,8 +29,10 @@ class Message < ApplicationRecord
validates :inbox_id, presence: true validates :inbox_id, presence: true
validates :conversation_id, presence: true validates :conversation_id, presence: true
enum message_type: [:incoming, :outgoing, :activity] enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum status: [:sent, :delivered, :read, :failed] enum content_type: { text: 0, input: 1, input_textarea: 2, input_email: 3 }
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
store :content_attributes, accessors: [:submitted_email], coder: JSON, prefix: :input
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be # .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) } scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
@ -44,7 +48,8 @@ class Message < ApplicationRecord
after_create :reopen_conversation, after_create :reopen_conversation,
:dispatch_event, :dispatch_event,
:send_reply :send_reply,
:execute_message_template_hooks
def channel_token def channel_token
@token ||= inbox.channel.try(:page_access_token) @token ||= inbox.channel.try(:page_access_token)
@ -81,4 +86,8 @@ class Message < ApplicationRecord
Rails.configuration.dispatcher.dispatch(CONVERSATION_REOPENED, Time.zone.now, conversation: conversation) Rails.configuration.dispatcher.dispatch(CONVERSATION_REOPENED, Time.zone.now, conversation: conversation)
end end
end end
def execute_message_template_hooks
::MessageTemplates::HookExecutionService.new(message: self).perform
end
end end

View file

@ -1,37 +1,35 @@
module Conversations class Conversations::EventDataPresenter < SimpleDelegator
class EventDataPresenter < SimpleDelegator def lock_data
def lock_data { id: display_id, locked: locked? }
{ id: display_id, locked: locked? } end
end
def push_data def push_data
{ {
id: display_id, id: display_id,
inbox_id: inbox_id, inbox_id: inbox_id,
messages: push_messages, messages: push_messages,
meta: push_meta, meta: push_meta,
status: status_before_type_cast.to_i, status: status_before_type_cast.to_i,
unread_count: unread_incoming_messages.count, unread_count: unread_incoming_messages.count,
**push_timestamps **push_timestamps
} }
end end
private private
def push_messages def push_messages
[messages.chat.last&.push_event_data].compact [messages.chat.last&.push_event_data].compact
end end
def push_meta def push_meta
{ sender: contact.push_event_data, assignee: assignee } { sender: contact.push_event_data, assignee: assignee }
end end
def push_timestamps def push_timestamps
{ {
agent_last_seen_at: agent_last_seen_at.to_i, agent_last_seen_at: agent_last_seen_at.to_i,
user_last_seen_at: user_last_seen_at.to_i, user_last_seen_at: user_last_seen_at.to_i,
timestamp: created_at.to_i timestamp: created_at.to_i
} }
end
end end
end end

View file

View file

@ -0,0 +1,20 @@
class MessageTemplates::HookExecutionService
pattr_initialize [:message!]
def perform
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if should_send_email_collect?
end
private
delegate :inbox, :conversation, to: :message
delegate :contact, to: :conversation
def first_message_from_contact?
conversation.messages.outgoing.count.zero? && conversation.messages.template.count.zero?
end
def should_send_email_collect?
conversation.inbox.web_widget? && first_message_from_contact?
end
end

View file

@ -0,0 +1,56 @@
class MessageTemplates::Template::EmailCollect
pattr_initialize [:conversation!]
def perform
ActiveRecord::Base.transaction do
conversation.messages.create!(typical_reply_message_params)
conversation.messages.create!(ways_to_reach_you_message_params)
conversation.messages.create!(email_input_box_template_message_params)
end
rescue StandardError => e
Raven.capture_exception(e)
true
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def typical_reply_message_params
content = I18n.t('conversations.templates.typical_reply_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
def ways_to_reach_you_message_params
content = I18n.t('conversations.templates.ways_to_reach_you_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: content
}
end
def email_input_box_template_message_params
content = I18n.t('conversations.templates.email_input_box_message_body',
account_name: account.name)
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content_type: :input_email,
content: content
}
end
end

View file

@ -0,0 +1,21 @@
class Widget::TokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload, secret_key, 'HS256'
end
def decode_token
JWT.decode(
token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
{}
end
private
def secret_key
Rails.application.secrets.secret_key_base
end
end

View file

@ -2,6 +2,8 @@ json.array! @messages do |message|
json.id message.id json.id message.id
json.content message.content json.content message.content
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.content_attributes message.content_attributes
json.created_at message.created_at.to_i json.created_at message.created_at.to_i
json.conversation_id message. conversation_id json.conversation_id message. conversation_id
json.attachment message.attachment.push_event_data if message.attachment json.attachment message.attachment.push_event_data if message.attachment

View file

@ -29,7 +29,7 @@ Rails.application.configure do
config.cache_store = :null_store config.cache_store = :null_store
# Raise exceptions instead of rendering exception templates. # Raise exceptions instead of rendering exception templates.
config.action_dispatch.show_exceptions = false config.action_dispatch.show_exceptions = true
# Disable request forgery protection in test environment. # Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false config.action_controller.allow_forgery_protection = false

View file

@ -50,3 +50,7 @@ en:
assignee: assignee:
assigned: "Assigned to %{assignee_name} by %{user_name}" assigned: "Assigned to %{assignee_name} by %{user_name}"
removed: "Conversation unassigned by %{user_name}" removed: "Conversation unassigned by %{user_name}"
templates:
typical_reply_message_body: "%{account_name} typically replies in a few hours."
ways_to_reach_you_message_body: "Give the team a way to reach you."
email_input_box_message_body: "Get notified by email"

View file

@ -11,7 +11,7 @@ Rails.application.routes.draw do
match '/status', to: 'home#status', via: [:get] match '/status', to: 'home#status', via: [:get]
resources :widgets, only: [:index] resource :widget, only: [:show]
namespace :api, defaults: { format: 'json' } do namespace :api, defaults: { format: 'json' } do
namespace :v1 do namespace :v1 do
@ -25,7 +25,7 @@ Rails.application.routes.draw do
end end
namespace :widget do namespace :widget do
resources :messages, only: [:index, :create] resources :messages, only: [:index, :create, :update]
resources :inboxes, only: [:create, :update] resources :inboxes, only: [:create, :update]
end end

View file

@ -0,0 +1,6 @@
class AddTemplateTypeToMessages < ActiveRecord::Migration[6.0]
def change
add_column :messages, :content_type, :integer, default: '0'
add_column :messages, :content_attributes, :json, default: {}
end
end

View file

@ -0,0 +1,14 @@
class AddContactInboxToConversation < ActiveRecord::Migration[6.0]
def change
add_reference(:conversations, :contact_inbox, foreign_key: true, index: true)
::Conversation.all.each do |conversation|
contact_inbox = ::ContactInbox.find_by(
contact_id: conversation.contact_id,
inbox_id: conversation.inbox_id
)
conversation.update!(contact_inbox_id: contact_inbox.id) if contact_inbox
end
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_12_27_191631) do ActiveRecord::Schema.define(version: 2020_01_07_164449) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -123,8 +123,10 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.datetime "agent_last_seen_at" t.datetime "agent_last_seen_at"
t.boolean "locked", default: false t.boolean "locked", default: false
t.jsonb "additional_attributes" t.jsonb "additional_attributes"
t.bigint "contact_inbox_id"
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["account_id"], name: "index_conversations_on_account_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
end end
create_table "inbox_members", id: :serial, force: :cascade do |t| create_table "inbox_members", id: :serial, force: :cascade do |t|
@ -157,6 +159,8 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.integer "user_id" t.integer "user_id"
t.integer "status", default: 0 t.integer "status", default: 0
t.string "fb_id" t.string "fb_id"
t.integer "content_type", default: 0
t.json "content_attributes", default: {}
t.index ["conversation_id"], name: "index_messages_on_conversation_id" t.index ["conversation_id"], name: "index_messages_on_conversation_id"
end end
@ -186,11 +190,9 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
t.index ["taggable_id"], name: "index_taggings_on_taggable_id" t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
t.index ["taggable_type"], name: "index_taggings_on_taggable_type" t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
t.index ["tagger_id"], name: "index_taggings_on_tagger_id" t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end end
create_table "tags", id: :serial, force: :cascade do |t| create_table "tags", id: :serial, force: :cascade do |t|
@ -243,5 +245,6 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "contact_inboxes", "contacts" add_foreign_key "contact_inboxes", "contacts"
add_foreign_key "contact_inboxes", "inboxes" add_foreign_key "contact_inboxes", "inboxes"
add_foreign_key "conversations", "contact_inboxes"
add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify
end end

View file

@ -10,6 +10,6 @@ inbox = Inbox.create!(channel: web_widget, account: account, name: 'Acme Support
InboxMember.create!(user: user, inbox: inbox) InboxMember.create!(user: user, inbox: inbox)
contact = Contact.create!(name: 'jane', email: 'jane@example.com', phone_number: '0000', account: account) contact = Contact.create!(name: 'jane', email: 'jane@example.com', phone_number: '0000', account: account)
ContactInbox.create!(inbox: inbox, contact: contact, source_id: user.id) contact_inbox = ContactInbox.create!(inbox: inbox, contact: contact, source_id: user.id)
conversation = Conversation.create!(account: account, inbox: inbox, status: :open, assignee: user, contact: contact) conversation = Conversation.create!(account: account, inbox: inbox, status: :open, assignee: user, contact: contact, contact_inbox: contact_inbox)
Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming) Message.create!(content: 'Hello', account: account, inbox: inbox, conversation: conversation, message_type: :incoming)

View file

@ -1,34 +1,30 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::DeliveryStatus
module Facebook def initialize(params)
class DeliveryStatus @params = params
def initialize(params) end
@params = params
end
def perform def perform
update_message_status update_message_status
end end
private private
def sender_id def sender_id
@params.sender['id'] @params.sender['id']
end end
def contact def contact
::ContactInbox.find_by(source_id: sender_id).contact ::ContactInbox.find_by(source_id: sender_id).contact
end end
def conversation def conversation
@conversation ||= ::Conversation.find_by(contact_id: contact.id) @conversation ||= ::Conversation.find_by(contact_id: contact.id)
end end
def update_message_status def update_message_status
conversation.user_last_seen_at = @params.at conversation.user_last_seen_at = @params.at
conversation.save! conversation.save!
end
end
end end
end end

View file

@ -1,47 +1,43 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::MessageCreator
module Facebook attr_reader :response
class MessageCreator
attr_reader :response
def initialize(response) def initialize(response)
@response = response @response = response
end end
def perform def perform
# begin # begin
if outgoing_message_via_echo? if outgoing_message_via_echo?
create_outgoing_message create_outgoing_message
else else
create_incoming_message create_incoming_message
end end
# rescue => e # rescue => e
# Raven.capture_exception(e) # Raven.capture_exception(e)
# end # end
end end
private private
def outgoing_message_via_echo? def outgoing_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 outgoing 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 messenger, so this case should be handled as outgoing message
end end
def create_outgoing_message def create_outgoing_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::Outgoing::EchoBuilder.new(response, page.inbox, true)
mb.perform mb.perform
end end
end end
def create_incoming_message def create_incoming_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::IncomingMessageBuilder.new(response, page.inbox)
mb.perform mb.perform
end
end
end end
end end
end end

View file

@ -1,52 +1,48 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Facebook::MessageParser
module Facebook def initialize(response_json)
class MessageParser @response = response_json
def initialize(response_json) end
@response = response_json
end
def sender_id def sender_id
@response.sender['id'] @response.sender['id']
end end
def recipient_id def recipient_id
@response.recipient['id'] @response.recipient['id']
end end
def time_stamp def time_stamp
@response.sent_at @response.sent_at
end end
def content def content
@response.text @response.text
end end
def sequence def sequence
@response.seq @response.seq
end end
def attachments def attachments
@response.attachments @response.attachments
end end
def identifier def identifier
@response.id @response.id
end end
def echo? def echo?
@response.echo? @response.echo?
end end
def app_id def app_id
@response.app_id @response.app_id
end end
def sent_from_chatwoot_app? def sent_from_chatwoot_app?
app_id && app_id == ENV['FB_APP_ID'].to_i app_id && app_id == ENV['FB_APP_ID'].to_i
end
end
end end
end end

View file

@ -1,61 +1,57 @@
# frozen_string_literal: true # frozen_string_literal: true
module Integrations class Integrations::Widget::IncomingMessageBuilder
module Widget # params = {
class Integrations::Widget::IncomingMessageBuilder # contact_id: 1,
# params = { # inbox_id: 1,
# contact_id: 1, # content: "Hello world"
# inbox_id: 1, # }
# content: "Hello world"
# }
attr_accessor :options, :message attr_accessor :options, :message
def initialize(options) def initialize(options)
@options = options @options = options
end end
def perform def perform
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
build_message build_message
end
end
private
def inbox
@inbox ||= Inbox.find(options[:inbox_id])
end
def contact
@contact ||= Contact.find(options[:contact_id])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params)
end
def build_message
@message = conversation.messages.new(message_params)
@message.save!
end
def conversation_params
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: options[:contact_id]
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: 0,
content: options[:content]
}
end
end end
end end
private
def inbox
@inbox ||= Inbox.find(options[:inbox_id])
end
def contact
@contact ||= Contact.find(options[:contact_id])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params)
end
def build_message
@message = conversation.messages.new(message_params)
@message.save!
end
def conversation_params
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: options[:contact_id]
}
end
def message_params
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: 0,
content: options[:content]
}
end
end end

View file

@ -9,9 +9,7 @@ describe ::ContactMergeAction do
before do before do
2.times.each { create(:conversation, contact: base_contact) } 2.times.each { create(:conversation, contact: base_contact) }
2.times.each { create(:contact_inbox, contact: base_contact) }
2.times.each { create(:conversation, contact: mergee_contact) } 2.times.each { create(:conversation, contact: mergee_contact) }
2.times.each { create(:contact_inbox, contact: mergee_contact) }
end end
describe '#perform' do describe '#perform' do

View file

@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
describe ::Messages::MessageBuilder do describe ::Messages::IncomingMessageBuilder 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,84 @@
require 'rails_helper'
RSpec.describe '/api/v1/widget/messages', type: :request do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
before do
2.times.each { create(:message, account: account, inbox: web_widget.inbox, conversation: conversation) }
end
describe 'GET /api/v1/widget/messages' do
context 'when get request is made' do
it 'returns messages in conversation' do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
# 2 messages created + 3 messages by the template hook
expect(json_response.length).to eq(5)
end
end
end
describe 'POST /api/v1/widget/messages' do
context 'when post request is made' do
it 'creates message in conversation' do
message_params = { content: 'hello world' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = JSON.parse(response.body)
expect(json_response['content']).to eq(message_params[:content])
end
end
end
describe 'PUT /api/v1/widget/messages' do
context 'when put request is made with non existing email' do
it 'updates message in conversation and creates a new contact' do
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect(message.input_submitted_email).to eq(email)
expect(message.conversation.contact.email).to eq(email)
end
end
context 'when put request is made with existing email' do
it 'updates message in conversation and deletes the current contact' do
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
create(:contact, account: account, email: email)
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View file

@ -1,12 +1,14 @@
require 'rails_helper' require 'rails_helper'
describe WidgetTestsController, type: :controller do describe '/widget_tests', type: :request do
let(:channel_widget) { create(:channel_widget) } before do
create(:channel_widget)
end
describe '#index' do describe 'GET /widget_tests' do
it 'renders the page correctly' do it 'renders the page correctly' do
get :index get widget_tests_url
expect(response.status).to eq 200 expect(response).to be_successful
end end
end end
end end

View file

@ -0,0 +1,17 @@
require 'rails_helper'
describe '/widget', type: :request do
let(:web_widget) { create(:channel_widget) }
describe 'GET /widget' do
it 'renders the page correctly when called with website_token' do
get widget_url(website_token: web_widget.website_token)
expect(response).to be_successful
end
it 'returns 404 when called with out website_token' do
get widget_url
expect(response.status).to eq(404)
end
end
end

View file

@ -6,5 +6,8 @@ FactoryBot.define do
sequence(:website_url) { |n| "https://example-#{n}.com" } sequence(:website_url) { |n| "https://example-#{n}.com" }
sequence(:widget_color, &:to_s) sequence(:widget_color, &:to_s)
account account
after(:create) do |channel_widget|
create(:inbox, channel: channel_widget, account: channel_widget.account)
end
end end
end end

View file

@ -16,6 +16,7 @@ FactoryBot.define do
channel: create(:channel_widget, account: conversation.account) channel: create(:channel_widget, account: conversation.account)
) )
conversation.contact ||= create(:contact, account: conversation.account) conversation.contact ||= create(:contact, account: conversation.account)
conversation.contact_inbox ||= create(:contact_inbox, contact: conversation.contact, inbox: conversation.inbox)
end end
end end
end end

View file

@ -3,7 +3,11 @@
FactoryBot.define do FactoryBot.define do
factory :inbox do factory :inbox do
account account
name { 'Inbox' }
channel { FactoryBot.build(:channel_widget, account: account) } channel { FactoryBot.build(:channel_widget, account: account) }
name { 'Inbox' }
after(:create) do |inbox|
inbox.channel.save!
end
end end
end end

View file

@ -5,9 +5,13 @@ FactoryBot.define do
content { 'Message' } content { 'Message' }
status { 'sent' } status { 'sent' }
message_type { 'incoming' } message_type { 'incoming' }
account content_type { 'text' }
inbox account { create(:account) }
conversation
user after(:build) do |message|
message.user ||= create(:user, account: message.account)
message.conversation ||= create(:conversation, account: message.account)
message.inbox ||= create(:inbox, account: message.account)
end
end end
end end

View file

@ -21,7 +21,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 4 expect(result.count).to be 7
end end
end end
@ -30,7 +30,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 2 expect(result.count).to be 5
end end
end end
@ -40,7 +40,7 @@ describe ::MessageFinder do
it 'filter conversations by status' do it 'filter conversations by status' do
result = message_finder.perform result = message_finder.perform
expect(result.count).to be 4 expect(result.count).to be 7
end end
end end
end end

View file

@ -57,19 +57,17 @@ RSpec.describe Conversation, type: :model do
expect(Rails.configuration.dispatcher).to have_received(:dispatch) expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation) .with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation)
# create_activity
expect(conversation.messages.pluck(:content)).to eq(
[
"Conversation was marked resolved by #{old_assignee.name}",
"Assigned to #{new_assignee.name} by #{old_assignee.name}"
]
)
# send_email_notification_to_assignee # send_email_notification_to_assignee
expect(AssignmentMailer).to have_received(:conversation_assigned).with(conversation, new_assignee) expect(AssignmentMailer).to have_received(:conversation_assigned).with(conversation, new_assignee)
expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present? expect(assignment_mailer).to have_received(:deliver_later) if ENV.fetch('SMTP_ADDRESS', nil).present?
end end
it 'creates conversation activities' do
# create_activity
expect(conversation.messages.pluck(:content)).to include("Conversation was marked resolved by #{old_assignee.name}")
expect(conversation.messages.pluck(:content)).to include("Assigned to #{new_assignee.name} by #{old_assignee.name}")
end
end end
describe '.after_create' do describe '.after_create' do
@ -169,7 +167,7 @@ RSpec.describe Conversation, type: :model do
end end
it 'returns unread messages' do it 'returns unread messages' do
expect(unread_messages).to contain_exactly(message) expect(unread_messages).to include(message)
end end
end end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Message, type: :model do
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:account_id) }
end
context 'when message is created' do
let(:message) { build(:message) }
it 'triggers ::MessageTemplates::HookExecutionService' do
hook_execution_service = double
allow(::MessageTemplates::HookExecutionService).to receive(:new).and_return(hook_execution_service)
allow(hook_execution_service).to receive(:perform).and_return(true)
message.save!
expect(::MessageTemplates::HookExecutionService).to have_received(:new).with(message: message)
expect(hook_execution_service).to have_received(:perform)
end
end
end

View file

@ -14,7 +14,8 @@ describe Facebook::SendReplyService do
let!(:facebook_channel) { create(:facebook_page, account: account) } let!(:facebook_channel) { create(:facebook_page, account: account) }
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) } let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
let!(:contact) { create(:contact, account: account) } let!(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, contact: contact, inbox: facebook_inbox) } let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: facebook_inbox, contact_inbox: contact_inbox) }
describe '#perform' do describe '#perform' do
context 'without reply' do context 'without reply' do
@ -41,7 +42,6 @@ describe Facebook::SendReplyService do
context 'with reply' do context 'with reply' do
it 'if message is sent from chatwoot and is outgoing' do it 'if message is sent from chatwoot and is outgoing' do
create(:contact_inbox, contact: contact, inbox: facebook_inbox)
create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation) create(:message, message_type: :incoming, inbox: facebook_inbox, account: account, conversation: conversation)
create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation) create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
expect(bot).to have_received(:deliver) expect(bot).to have_received(:deliver)

View file

@ -0,0 +1,20 @@
require 'rails_helper'
describe ::MessageTemplates::HookExecutionService do
context 'when it is a first message from web widget' do
it 'calls ::MessageTemplates::Template::EmailCollect' do
message = create(:message)
# this hook will only get executed for conversations with out any template messages
message.conversation.messages.template.destroy_all
email_collect_service = double
allow(::MessageTemplates::Template::EmailCollect).to receive(:new).and_return(email_collect_service)
allow(email_collect_service).to receive(:perform).and_return(true)
described_class.new(message: message).perform
expect(::MessageTemplates::Template::EmailCollect).to have_received(:new).with(conversation: message.conversation)
expect(email_collect_service).to have_received(:perform)
end
end
end

View file

@ -0,0 +1,12 @@
require 'rails_helper'
describe ::MessageTemplates::Template::EmailCollect do
context 'when this hook is called' do
let(:conversation) { create(:conversation) }
it 'creates the email collect messages' do
described_class.new(conversation: conversation).perform
expect(conversation.messages.count).to eq(3)
end
end
end