[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
inherit_from: .rubocop_todo.yml
Metrics/LineLength:
Layout/LineLength:
Max: 150
Metrics/ClassLength:
Max: 125
RSpec/ExampleLength:
Max: 15
Documentation:
Style/Documentation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false

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

View file

@ -29,7 +29,7 @@ Rails.application.configure do
config.cache_store = :null_store
# 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.
config.action_controller.allow_forgery_protection = false

View file

@ -50,3 +50,7 @@ en:
assignee:
assigned: "Assigned to %{assignee_name} 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]
resources :widgets, only: [:index]
resource :widget, only: [:show]
namespace :api, defaults: { format: 'json' } do
namespace :v1 do
@ -25,7 +25,7 @@ Rails.application.routes.draw do
end
namespace :widget do
resources :messages, only: [:index, :create]
resources :messages, only: [:index, :create, :update]
resources :inboxes, only: [:create, :update]
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.
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
enable_extension "plpgsql"
@ -123,8 +123,10 @@ ActiveRecord::Schema.define(version: 2019_12_27_191631) do
t.datetime "agent_last_seen_at"
t.boolean "locked", default: false
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"], name: "index_conversations_on_account_id"
t.index ["contact_inbox_id"], name: "index_conversations_on_contact_inbox_id"
end
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 "status", default: 0
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"
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", "tagger_id", "context"], name: "taggings_idy"
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 ["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_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
end
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 "contact_inboxes", "contacts"
add_foreign_key "contact_inboxes", "inboxes"
add_foreign_key "conversations", "contact_inboxes"
add_foreign_key "users", "users", column: "inviter_id", on_delete: :nullify
end

View file

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

View file

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

View file

@ -1,47 +1,43 @@
# frozen_string_literal: true
module Integrations
module Facebook
class MessageCreator
attr_reader :response
class Integrations::Facebook::MessageCreator
attr_reader :response
def initialize(response)
@response = response
end
def initialize(response)
@response = response
end
def perform
# begin
if outgoing_message_via_echo?
create_outgoing_message
else
create_incoming_message
end
# rescue => e
# Raven.capture_exception(e)
# end
end
def perform
# begin
if outgoing_message_via_echo?
create_outgoing_message
else
create_incoming_message
end
# rescue => e
# Raven.capture_exception(e)
# end
end
private
private
def outgoing_message_via_echo?
response.echo? && !response.sent_from_chatwoot_app?
# 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
end
def outgoing_message_via_echo?
response.echo? && !response.sent_from_chatwoot_app?
# 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
end
def create_outgoing_message
Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
mb = Messages::Outgoing::EchoBuilder.new(response, page.inbox, true)
mb.perform
end
end
def create_outgoing_message
Channel::FacebookPage.where(page_id: response.sender_id).each do |page|
mb = Messages::Outgoing::EchoBuilder.new(response, page.inbox, true)
mb.perform
end
end
def create_incoming_message
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
mb = Messages::IncomingMessageBuilder.new(response, page.inbox)
mb.perform
end
end
def create_incoming_message
Channel::FacebookPage.where(page_id: response.recipient_id).each do |page|
mb = Messages::IncomingMessageBuilder.new(response, page.inbox)
mb.perform
end
end
end

View file

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

View file

@ -1,61 +1,57 @@
# frozen_string_literal: true
module Integrations
module Widget
class Integrations::Widget::IncomingMessageBuilder
# params = {
# contact_id: 1,
# inbox_id: 1,
# content: "Hello world"
# }
class Integrations::Widget::IncomingMessageBuilder
# params = {
# contact_id: 1,
# inbox_id: 1,
# content: "Hello world"
# }
attr_accessor :options, :message
attr_accessor :options, :message
def initialize(options)
@options = options
end
def initialize(options)
@options = options
end
def perform
ActiveRecord::Base.transaction do
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
def perform
ActiveRecord::Base.transaction do
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

View file

@ -9,9 +9,7 @@ describe ::ContactMergeAction do
before do
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(:contact_inbox, contact: mergee_contact) }
end
describe '#perform' do

View file

@ -1,6 +1,6 @@
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 }
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'
describe WidgetTestsController, type: :controller do
let(:channel_widget) { create(:channel_widget) }
describe '/widget_tests', type: :request do
before do
create(:channel_widget)
end
describe '#index' do
describe 'GET /widget_tests' do
it 'renders the page correctly' do
get :index
expect(response.status).to eq 200
get widget_tests_url
expect(response).to be_successful
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(:widget_color, &:to_s)
account
after(:create) do |channel_widget|
create(:inbox, channel: channel_widget, account: channel_widget.account)
end
end
end

View file

@ -16,6 +16,7 @@ FactoryBot.define do
channel: create(:channel_widget, 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

View file

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

View file

@ -5,9 +5,13 @@ FactoryBot.define do
content { 'Message' }
status { 'sent' }
message_type { 'incoming' }
account
inbox
conversation
user
content_type { 'text' }
account { create(:account) }
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

View file

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

View file

@ -57,19 +57,17 @@ RSpec.describe Conversation, type: :model do
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.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
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?
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
describe '.after_create' do
@ -169,7 +167,7 @@ RSpec.describe Conversation, type: :model do
end
it 'returns unread messages' do
expect(unread_messages).to contain_exactly(message)
expect(unread_messages).to include(message)
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_inbox) { create(:inbox, channel: facebook_channel, 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
context 'without reply' do
@ -41,7 +42,6 @@ describe Facebook::SendReplyService do
context 'with reply' 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: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
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