[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

@@ -6,132 +6,137 @@ require 'open-uri'
# 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.
module Messages
class MessageBuilder
attr_reader :response
class Messages::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)
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
def perform
ActiveRecord::Base.transaction do
build_contact
build_message
end
rescue StandardError => e
Raven.capture_exception(e)
true
end
private
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
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))
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_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
end
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.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'] || ''
}
def build_message
@message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
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) || 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

View File

@@ -1,55 +1,51 @@
module Api
module V1
class InboxMembersController < Api::BaseController
before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create]
class Api::V1::InboxMembersController < Api::BaseController
before_action :fetch_inbox, only: [:create, :show]
before_action :current_agents_ids, only: [:create]
def create
# update also done via same action
if @inbox
begin
update_agents_list
head :ok
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
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])
def create
# update also done via same action
if @inbox
begin
update_agents_list
head :ok
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
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

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
skip_before_action :verify_authenticity_token
class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
before_action :set_web_widget
before_action :set_contact
before_action :set_conversation, only: [:create]
before_action :set_message, only: [:update]
def index
@messages = conversation.nil? ? [] : message_finder.perform
@@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base
def create
@message = conversation.messages.new(message_params)
@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
private
def conversation
@conversation ||= ::Conversation.find_by(
contact_id: cookie_params[:contact_id],
inbox_id: cookie_params[:inbox_id]
)
end
def set_conversation
@conversation = ::Conversation.create!(conversation_params) if conversation.nil?
end
@@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base
{
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: cookie_params[:contact_id],
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
browser: browser_params,
referer: permitted_params[:message][:referer_url],
@@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base
end
def inbox
@inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id])
end
def cookie_params
@cookie_params ||= JWT.decode(
request.headers[header_name], secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
@inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id])
end
def message_finder_params
@@ -83,15 +82,27 @@ class Api::V1::Widget::MessagesController < ActionController::Base
@message_finder ||= MessageFinder.new(conversation, message_finder_params)
end
def header_name
'X-Auth-Token'
def update_contact(email)
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
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
def secret_key
Rails.application.secrets.secret_key_base
def set_message
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end

View File

@@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base
before_action :set_contact
before_action :build_contact
def index
render
end
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
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
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
return if @contact.present?
contact_inbox = @web_widget.create_contact_inbox
@contact = contact_inbox.contact
payload = {
source_id: contact_inbox.source_id,
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]
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
@token = ::Widget::TokenService.new(payload: payload).generate_token
end
def permitted_params
params.permit(:website_token, :cw_conversation)
end
def secret_key
Rails.application.secrets.secret_key_base
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>
<script>
import Spinner from '../Spinner';
import Spinner from 'shared/components/Spinner';
export default {
components: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import Vue from 'vue';
import Vuelidate from 'vuelidate';
import store from '../widget/store';
import App from '../widget/App.vue';
import router from '../widget/router';
import ActionCableConnector from '../widget/helpers/actionCable';
Vue.use(Vuelidate);
Vue.config.productionTip = false;
window.onload = () => {
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 },
});
const updateContact = id => ({
url: `/api/v1/widget/messages/${id}${window.location.search}`,
});
export default {
sendMessage,
getConversation,
updateContact,
};

View File

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

View File

@@ -57,6 +57,9 @@ $color-background-light: #fafafa;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-error: #ff4949;
// Thumbnail
$thumbnail-radius: 4rem;
@@ -89,3 +92,8 @@ $footer-height: 11.2rem;
$header-expanded-height: $space-medium * 10;
$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 'forms';
@import 'shared/assets/fonts/inter';
@import '~ionicons/scss/ionicons';
@import '~spinkit/scss/spinners/7-three-bounce';
html,
body {

View File

@@ -9,7 +9,13 @@
/>
</div>
<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">
{{ agentName }}
</p>
@@ -32,7 +38,22 @@ export default {
avatarUrl: String,
agentName: String,
showAvatar: Boolean,
createdAt: Number,
contentType: {
type: String,
default: '',
},
messageContentAttributes: {
type: Object,
default: () => {},
},
messageType: {
type: Number,
default: 1,
},
messageId: {
type: Number,
default: 0,
},
},
};
</script>

View File

@@ -1,20 +1,42 @@
<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>
<script>
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import EmailInput from './template/EmailInput';
export default {
name: 'AgentMessageBubble',
components: {
EmailInput,
},
mixins: [messageFormatterMixin],
props: {
message: String,
contentType: String,
messageType: Number,
messageId: Number,
messageContentAttributes: {
type: Object,
default: () => {},
},
},
computed: {
shouldShowInput() {
return this.contentType === 'input_email' && this.messageType === 3;
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss">
@import '~widget/assets/scss/variables.scss';

View File

@@ -7,9 +7,13 @@
<AgentMessage
v-else
: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"
:show-avatar="message.showAvatar"
:avatar-url="avatarUrl"
/>
</template>
@@ -32,9 +36,18 @@ export default {
return this.message.message_type === MESSAGE_TYPE.INCOMING;
},
agentName() {
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
return 'Bot';
}
return this.message.sender ? this.message.sender.name : '';
},
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 : '';
},
},

View File

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

View File

@@ -1,13 +1,15 @@
import Vue from 'vue';
import Vuex from 'vuex';
import conversation from 'widget/store/modules/conversation';
import appConfig from 'widget/store/modules/appConfig';
import contact from 'widget/store/modules/contact';
import conversation from 'widget/store/modules/conversation';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
appConfig,
contact,
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));
},
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes,
};
},
};
export default {

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
# account_id :integer not null
# assignee_id :integer
# contact_id :bigint
# contact_inbox_id :bigint
# display_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_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
@@ -38,6 +44,7 @@ class Conversation < ApplicationRecord
belongs_to :inbox
belongs_to :assignee, class_name: 'User', optional: true
belongs_to :contact
belongs_to :contact_inbox
has_many :messages, dependent: :destroy, autosave: true

View File

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

View File

@@ -2,18 +2,20 @@
#
# Table name: messages
#
# id :integer not null, primary key
# content :text
# message_type :integer not null
# private :boolean default(FALSE)
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# fb_id :string
# inbox_id :integer not null
# user_id :integer
# id :integer not null, primary key
# content :text
# content_attributes :json
# content_type :integer default("text")
# message_type :integer not null
# private :boolean default(FALSE)
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# fb_id :string
# inbox_id :integer not null
# user_id :integer
#
# Indexes
#
@@ -27,8 +29,10 @@ class Message < ApplicationRecord
validates :inbox_id, presence: true
validates :conversation_id, presence: true
enum message_type: [:incoming, :outgoing, :activity]
enum status: [:sent, :delivered, :read, :failed]
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
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
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,
:dispatch_event,
:send_reply
:send_reply,
:execute_message_template_hooks
def channel_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)
end
end
def execute_message_template_hooks
::MessageTemplates::HookExecutionService.new(message: self).perform
end
end

View File

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