Feature: Rich Message Types (#610)
Co-authored-by: Pranav Raj S <pranavrajs@gmail.com> Co-authored-by: Nithin David Thomas <webofnithin@gmail.com>
This commit is contained in:
@@ -3,11 +3,13 @@ class Messages::Outgoing::NormalBuilder
|
|||||||
attr_reader :message
|
attr_reader :message
|
||||||
|
|
||||||
def initialize(user, conversation, params)
|
def initialize(user, conversation, params)
|
||||||
@content = params[:message]
|
@content = params[:content]
|
||||||
@private = params[:private] || false
|
@private = params[:private] || false
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
@user = user
|
@user = user
|
||||||
@fb_id = params[:fb_id]
|
@fb_id = params[:fb_id]
|
||||||
|
@content_type = params[:content_type]
|
||||||
|
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
|
||||||
@attachment = params[:attachment]
|
@attachment = params[:attachment]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -34,7 +36,9 @@ class Messages::Outgoing::NormalBuilder
|
|||||||
content: @content,
|
content: @content,
|
||||||
private: @private,
|
private: @private,
|
||||||
user_id: @user&.id,
|
user_id: @user&.id,
|
||||||
source_id: @fb_id
|
source_id: @fb_id,
|
||||||
|
content_type: @content_type,
|
||||||
|
items: @items
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Api::V1::Accounts::ConversationsController < Api::BaseController
|
class Api::V1::Accounts::ConversationsController < Api::BaseController
|
||||||
before_action :conversation, except: [:index]
|
before_action :conversation, except: [:index]
|
||||||
|
before_action :contact_inbox, only: [:create]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
result = conversation_finder.perform
|
result = conversation_finder.perform
|
||||||
@@ -7,6 +8,10 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
|
|||||||
@conversations_count = result[:count]
|
@conversations_count = result[:count]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@conversation = ::Conversation.create!(conversation_params)
|
||||||
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
def toggle_status
|
def toggle_status
|
||||||
@@ -29,6 +34,19 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
|
|||||||
@conversation ||= current_account.conversations.find_by(display_id: params[:id])
|
@conversation ||= current_account.conversations.find_by(display_id: params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def contact_inbox
|
||||||
|
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_params
|
||||||
|
{
|
||||||
|
account_id: current_account.id,
|
||||||
|
inbox_id: @contact_inbox.inbox_id,
|
||||||
|
contact_id: @contact_inbox.contact_id,
|
||||||
|
contact_inbox_id: @contact_inbox.id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_finder
|
def conversation_finder
|
||||||
@conversation_finder ||= ConversationFinder.new(current_user, params)
|
@conversation_finder ||= ConversationFinder.new(current_user, params)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
@conversation ||= @contact_inbox.conversations.find_by(
|
@conversation ||= @contact_inbox.conversations.where(
|
||||||
inbox_id: auth_token_params[:inbox_id]
|
inbox_id: auth_token_params[:inbox_id]
|
||||||
)
|
).last
|
||||||
end
|
end
|
||||||
|
|
||||||
def auth_token_params
|
def auth_token_params
|
||||||
|
|||||||
16
app/controllers/api/v1/widget/events_controller.rb
Normal file
16
app/controllers/api/v1/widget/events_controller.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
|
||||||
|
include Events::Types
|
||||||
|
before_action :set_web_widget
|
||||||
|
before_action :set_contact
|
||||||
|
|
||||||
|
def create
|
||||||
|
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox)
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:name, :website_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,8 +15,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@message.update!(input_submitted_email: contact_email)
|
if @message.content_type == 'input_email'
|
||||||
|
@message.update!(submitted_email: contact_email)
|
||||||
update_contact(contact_email)
|
update_contact(contact_email)
|
||||||
|
else
|
||||||
|
@message.update!(message_update_params[:message])
|
||||||
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||||
end
|
end
|
||||||
@@ -116,6 +120,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
|||||||
contact_email.split('@')[0]
|
contact_email.split('@')[0]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def message_update_params
|
||||||
|
params.permit(message: [submitted_values: [:name, :title, :value]])
|
||||||
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
|
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module AccessTokenAuthHelper
|
module AccessTokenAuthHelper
|
||||||
BOT_ACCESSIBLE_ENDPOINTS = {
|
BOT_ACCESSIBLE_ENDPOINTS = {
|
||||||
'api/v1/accounts/conversations' => ['toggle_status'],
|
'api/v1/accounts/conversations' => %w[toggle_status create],
|
||||||
'api/v1/accounts/conversations/messages' => ['create']
|
'api/v1/accounts/conversations/messages' => ['create']
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class MessageApi extends ApiClient {
|
|||||||
|
|
||||||
create({ conversationId, message, private: isPrivate }) {
|
create({ conversationId, message, private: isPrivate }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/messages`, {
|
return axios.post(`${this.url}/${conversationId}/messages`, {
|
||||||
message,
|
content: message,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export default {
|
|||||||
DOWNLOAD: 'Herunterladen',
|
DOWNLOAD: 'Herunterladen',
|
||||||
UPLOADING: 'Hochladen...',
|
UPLOADING: 'Hochladen...',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
FORM_BUBBLE: {
|
||||||
|
SUBMIT: 'Einreichen',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
CONFIRM_EMAIL: 'Überprüfen...',
|
CONFIRM_EMAIL: 'Überprüfen...',
|
||||||
SETTINGS: {
|
SETTINGS: {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export default {
|
|||||||
DOWNLOAD: 'Download',
|
DOWNLOAD: 'Download',
|
||||||
UPLOADING: 'Uploading...',
|
UPLOADING: 'Uploading...',
|
||||||
},
|
},
|
||||||
|
FORM_BUBBLE: {
|
||||||
|
SUBMIT: 'Submit',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
CONFIRM_EMAIL: 'Verifying...',
|
CONFIRM_EMAIL: 'Verifying...',
|
||||||
SETTINGS: {
|
SETTINGS: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
import { IFrameHelper } from '../sdk/IFrameHelper';
|
import { IFrameHelper } from '../sdk/IFrameHelper';
|
||||||
import { onBubbleClick } from '../sdk/bubbleHelpers';
|
|
||||||
|
|
||||||
const runSDK = ({ baseUrl, websiteToken }) => {
|
const runSDK = ({ baseUrl, websiteToken }) => {
|
||||||
const chatwootSettings = window.chatwootSettings || {};
|
const chatwootSettings = window.chatwootSettings || {};
|
||||||
@@ -13,7 +12,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
|||||||
websiteToken,
|
websiteToken,
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
onBubbleClick();
|
IFrameHelper.events.toggleBubble();
|
||||||
},
|
},
|
||||||
|
|
||||||
setUser(identifier, user) {
|
setUser(identifier, user) {
|
||||||
@@ -39,7 +38,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
if (window.$chatwoot.isOpen) {
|
if (window.$chatwoot.isOpen) {
|
||||||
onBubbleClick();
|
IFrameHelper.events.toggleBubble();
|
||||||
}
|
}
|
||||||
|
|
||||||
Cookies.remove('cw_conversation');
|
Cookies.remove('cw_conversation');
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export const IFrameHelper = {
|
|||||||
|
|
||||||
toggleBubble: () => {
|
toggleBubble: () => {
|
||||||
onBubbleClick();
|
onBubbleClick();
|
||||||
|
if (window.$chatwoot.isOpen) {
|
||||||
|
IFrameHelper.pushEvent('webwidget.triggered');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onLoad: ({ widget_color: widgetColor }) => {
|
onLoad: ({ widget_color: widgetColor }) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
:key="action.uri"
|
:key="action.uri"
|
||||||
class="action-button button"
|
class="action-button button"
|
||||||
:href="action.uri"
|
:href="action.uri"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener nofollow noreferrer"
|
||||||
>
|
>
|
||||||
{{ action.text }}
|
{{ action.text }}
|
||||||
</a>
|
</a>
|
||||||
@@ -44,11 +46,14 @@ export default {
|
|||||||
@import '~dashboard/assets/scss/mixins.scss';
|
@import '~dashboard/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
width: 100%;
|
align-items: center;
|
||||||
padding: 0;
|
|
||||||
max-height: 34px;
|
|
||||||
margin-top: $space-smaller;
|
|
||||||
border-radius: $space-micro;
|
border-radius: $space-micro;
|
||||||
|
display: flex;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: $space-smaller;
|
||||||
|
max-height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export default {
|
|||||||
@import '~dashboard/assets/scss/mixins.scss';
|
@import '~dashboard/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.card-message {
|
.card-message {
|
||||||
@include border-normal;
|
|
||||||
background: white;
|
background: white;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
padding: $space-small;
|
padding: $space-small;
|
||||||
|
|||||||
114
app/javascript/shared/components/ChatForm.vue
Normal file
114
app/javascript/shared/components/ChatForm.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form chat-bubble agent">
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div v-for="item in items" :key="item.key" class="form-block">
|
||||||
|
<label>{{ item.label }}</label>
|
||||||
|
<input
|
||||||
|
v-if="item.type === 'email' || item.type === 'text'"
|
||||||
|
v-model="formValues[item.name]"
|
||||||
|
:type="item.type"
|
||||||
|
:name="item.name"
|
||||||
|
:placeholder="item.placeholder"
|
||||||
|
:disabled="!!submittedValues.length"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-else-if="item.type === 'text_area'"
|
||||||
|
v-model="formValues[item.name]"
|
||||||
|
:name="item.name"
|
||||||
|
:placeholder="item.placeholder"
|
||||||
|
:disabled="!!submittedValues.length"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!submittedValues.length"
|
||||||
|
class="button small block"
|
||||||
|
type="submit"
|
||||||
|
:disabled="!isFormValid"
|
||||||
|
>
|
||||||
|
{{ $t('COMPONENTS.FORM_BUBBLE.SUBMIT') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
submittedValues: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
formValues: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isFormValid() {
|
||||||
|
return this.items.reduce((acc, { name }) => {
|
||||||
|
return !!this.formValues[name] && acc;
|
||||||
|
}, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.updateFormValues();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSubmit() {
|
||||||
|
if (!this.isFormValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$emit('submit', this.formValues);
|
||||||
|
},
|
||||||
|
updateFormValues() {
|
||||||
|
this.formValues = this.submittedValues.reduce((acc, obj) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[obj.name]: obj.value,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: $space-normal;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.form-block {
|
||||||
|
max-width: 100%;
|
||||||
|
padding-bottom: $space-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
padding: $space-smaller 0;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
border-radius: $space-smaller;
|
||||||
|
border: 1px solid $color-border;
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-default;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: $space-smaller $space-small;
|
||||||
|
width: 90%;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $color-background-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<button class="option-button button" @click="onClick">
|
<button class="option-button button" @click="onClick">
|
||||||
<span v-if="isSelected" class="icon ion-checkmark-circled" />
|
<span v-if="isSelected" class="icon ion-checkmark-circled" />
|
||||||
<span v-else class="icon ion-android-radio-button-off" />
|
<span v-else class="icon ion-android-radio-button-off" />
|
||||||
<span>{{ action.text }}</span>
|
<span>{{ action.title }}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -23,7 +23,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onClick() {
|
onClick() {
|
||||||
// Do postback here
|
this.$emit('click', this.action);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
<h4 class="title">
|
<h4 class="title">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="options" :class="{ 'has-selected': !!selected }">
|
<ul
|
||||||
|
v-if="!hideFields"
|
||||||
|
class="options"
|
||||||
|
:class="{ 'has-selected': !!selected }"
|
||||||
|
>
|
||||||
<chat-option
|
<chat-option
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
:action="option"
|
:action="option"
|
||||||
:is-selected="isSelected(option)"
|
:is-selected="isSelected(option)"
|
||||||
|
@click="onClick"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,11 +41,18 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
hideFields: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
isSelected(option) {
|
isSelected(option) {
|
||||||
return this.selected === option.id;
|
return this.selected === option.id;
|
||||||
},
|
},
|
||||||
|
onClick(selectedOption) {
|
||||||
|
this.$emit('click', selectedOption);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -59,7 +71,6 @@ export default {
|
|||||||
@import '~dashboard/assets/scss/mixins.scss';
|
@import '~dashboard/assets/scss/mixins.scss';
|
||||||
|
|
||||||
.options-message {
|
.options-message {
|
||||||
@include border-normal;
|
|
||||||
background: white;
|
background: white;
|
||||||
width: 60%;
|
width: 60%;
|
||||||
max-width: 17rem;
|
max-width: 17rem;
|
||||||
|
|||||||
@@ -6,5 +6,12 @@ export default {
|
|||||||
const messageFormatter = new MessageFormatter(message);
|
const messageFormatter = new MessageFormatter(message);
|
||||||
return messageFormatter.formattedMessage;
|
return messageFormatter.formattedMessage;
|
||||||
},
|
},
|
||||||
|
truncateMessage(description = '') {
|
||||||
|
if (description.length < 100) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${description.slice(0, 97)}...`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export default {
|
|||||||
window.refererURL = message.refererURL;
|
window.refererURL = message.refererURL;
|
||||||
} else if (message.event === 'toggle-close-button') {
|
} else if (message.event === 'toggle-close-button') {
|
||||||
this.isMobile = message.showClose;
|
this.isMobile = message.showClose;
|
||||||
|
} else if (message.event === 'push-event') {
|
||||||
|
this.$store.dispatch('events/create', { name: message.eventName });
|
||||||
} else if (message.event === 'set-label') {
|
} else if (message.event === 'set-label') {
|
||||||
this.$store.dispatch('conversationLabels/create', message.label);
|
this.$store.dispatch('conversationLabels/create', message.label);
|
||||||
} else if (message.event === 'remove-label') {
|
} else if (message.event === 'remove-label') {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const getConversation = ({ before }) => ({
|
|||||||
params: { before },
|
params: { before },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateContact = id => ({
|
const updateMessage = id => ({
|
||||||
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +45,6 @@ export default {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
sendAttachmnet,
|
sendAttachmnet,
|
||||||
getConversation,
|
getConversation,
|
||||||
updateContact,
|
updateMessage,
|
||||||
getAvailableAgents,
|
getAvailableAgents,
|
||||||
};
|
};
|
||||||
|
|||||||
7
app/javascript/widget/api/events.js
Normal file
7
app/javascript/widget/api/events.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
create(name) {
|
||||||
|
return API.post(`/api/v1/widget/events${window.location.search}`, { name });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,10 +2,11 @@ import authEndPoint from 'widget/api/endPoints';
|
|||||||
import { API } from 'widget/helpers/axios';
|
import { API } from 'widget/helpers/axios';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
update: ({ messageId, email }) => {
|
update: ({ messageId, email, values }) => {
|
||||||
const urlData = authEndPoint.updateContact(messageId);
|
const urlData = authEndPoint.updateMessage(messageId);
|
||||||
return API.patch(urlData.url, {
|
return API.patch(urlData.url, {
|
||||||
contact: { email },
|
contact: { email },
|
||||||
|
message: { submitted_values: values },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,4 +54,8 @@ $button-border-width: 1px;
|
|||||||
height: $space-larger;
|
height: $space-larger;
|
||||||
padding: $space-small $space-medium;
|
padding: $space-small $space-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="agent-bubble">
|
||||||
<div class="agent-message">
|
<div class="agent-message">
|
||||||
<div class="avatar-wrap">
|
<div class="avatar-wrap">
|
||||||
<thumbnail
|
<thumbnail
|
||||||
v-if="message.showAvatar"
|
v-if="message.showAvatar || hasRecordedResponse"
|
||||||
:src="avatarUrl"
|
:src="avatarUrl"
|
||||||
size="24px"
|
size="24px"
|
||||||
:username="agentName"
|
:username="agentName"
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="message-wrap">
|
<div class="message-wrap">
|
||||||
<AgentMessageBubble
|
<AgentMessageBubble
|
||||||
v-if="showTextBubble"
|
v-if="showTextBubble && shouldDisplayAgentMessage"
|
||||||
:content-type="contentType"
|
:content-type="contentType"
|
||||||
:message-content-attributes="messageContentAttributes"
|
:message-content-attributes="messageContentAttributes"
|
||||||
:message-id="message.id"
|
:message-id="message.id"
|
||||||
@@ -19,7 +20,9 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="hasAttachment" class="chat-bubble has-attachment agent">
|
<div v-if="hasAttachment" class="chat-bubble has-attachment agent">
|
||||||
<file-bubble
|
<file-bubble
|
||||||
v-if="message.attachment && message.attachment.file_type !== 'image'"
|
v-if="
|
||||||
|
message.attachment && message.attachment.file_type !== 'image'
|
||||||
|
"
|
||||||
:url="message.attachment.data_url"
|
:url="message.attachment.data_url"
|
||||||
/>
|
/>
|
||||||
<image-bubble
|
<image-bubble
|
||||||
@@ -29,14 +32,18 @@
|
|||||||
:readable-time="readableTime"
|
:readable-time="readableTime"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="message.showAvatar" class="agent-name">
|
<p v-if="message.showAvatar || hasRecordedResponse" class="agent-name">
|
||||||
{{ agentName }}
|
{{ agentName }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UserMessage v-if="hasRecordedResponse" :message="responseMessage" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import UserMessage from 'widget/components/UserMessage';
|
||||||
import AgentMessageBubble from 'widget/components/AgentMessageBubble';
|
import AgentMessageBubble from 'widget/components/AgentMessageBubble';
|
||||||
import timeMixin from 'dashboard/mixins/time';
|
import timeMixin from 'dashboard/mixins/time';
|
||||||
import ImageBubble from 'widget/components/ImageBubble';
|
import ImageBubble from 'widget/components/ImageBubble';
|
||||||
@@ -48,8 +55,9 @@ export default {
|
|||||||
name: 'AgentMessage',
|
name: 'AgentMessage',
|
||||||
components: {
|
components: {
|
||||||
AgentMessageBubble,
|
AgentMessageBubble,
|
||||||
Thumbnail,
|
|
||||||
ImageBubble,
|
ImageBubble,
|
||||||
|
Thumbnail,
|
||||||
|
UserMessage,
|
||||||
FileBubble,
|
FileBubble,
|
||||||
},
|
},
|
||||||
mixins: [timeMixin],
|
mixins: [timeMixin],
|
||||||
@@ -60,12 +68,22 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
shouldDisplayAgentMessage() {
|
||||||
|
if (
|
||||||
|
this.contentType === 'input_select' &&
|
||||||
|
this.messageContentAttributes.submitted_values &&
|
||||||
|
!this.message.content
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
hasAttachment() {
|
hasAttachment() {
|
||||||
return !!this.message.attachment;
|
return !!this.message.attachment;
|
||||||
},
|
},
|
||||||
showTextBubble() {
|
showTextBubble() {
|
||||||
const { message } = this;
|
const { message } = this;
|
||||||
return !!message.content;
|
return !message.attachment;
|
||||||
},
|
},
|
||||||
readableTime() {
|
readableTime() {
|
||||||
const { created_at: createdAt = '' } = this.message;
|
const { created_at: createdAt = '' } = this.message;
|
||||||
@@ -88,25 +106,59 @@ export default {
|
|||||||
return 'Bot';
|
return 'Bot';
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.message.sender ? this.message.sender.name : '';
|
return this.message.sender ? this.message.sender.name : 'Bot';
|
||||||
},
|
},
|
||||||
avatarUrl() {
|
avatarUrl() {
|
||||||
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
return require('dashboard/assets/images/chatwoot_bot.png');
|
const BotImage = require('dashboard/assets/images/chatwoot_bot.png')
|
||||||
|
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||||
|
return BotImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.message.sender ? this.message.sender.avatar_url : '';
|
return this.message.sender ? this.message.sender.avatar_url : BotImage;
|
||||||
|
},
|
||||||
|
hasRecordedResponse() {
|
||||||
|
return (
|
||||||
|
this.messageContentAttributes.submitted_email ||
|
||||||
|
(this.messageContentAttributes.submitted_values &&
|
||||||
|
this.contentType !== 'form')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
responseMessage() {
|
||||||
|
if (this.messageContentAttributes.submitted_email) {
|
||||||
|
return { content: this.messageContentAttributes.submitted_email };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.messageContentAttributes.submitted_values) {
|
||||||
|
if (this.contentType === 'input_select') {
|
||||||
|
const [
|
||||||
|
selectionOption = {},
|
||||||
|
] = this.messageContentAttributes.submitted_values;
|
||||||
|
return { content: selectionOption.title || selectionOption.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
<style lang="scss" scoped>
|
<style lang="scss">
|
||||||
@import '~widget/assets/scss/variables.scss';
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
|
||||||
.conversation-wrap {
|
.conversation-wrap {
|
||||||
|
.agent-bubble {
|
||||||
|
margin-bottom: $space-micro;
|
||||||
|
& + .agent-bubble {
|
||||||
|
.agent-message {
|
||||||
|
.chat-bubble {
|
||||||
|
border-top-left-radius: $space-smaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.agent-message {
|
.agent-message {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -115,6 +167,10 @@ export default {
|
|||||||
margin: 0 0 $space-micro $space-small;
|
margin: 0 0 $space-micro $space-small;
|
||||||
max-width: 88%;
|
max-width: 88%;
|
||||||
|
|
||||||
|
& + .user-message {
|
||||||
|
margin-top: $space-one;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap {
|
||||||
height: $space-medium;
|
height: $space-medium;
|
||||||
width: $space-medium;
|
width: $space-medium;
|
||||||
@@ -147,22 +203,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style lang="scss">
|
|
||||||
@import '~widget/assets/scss/variables.scss';
|
|
||||||
|
|
||||||
.conversation-wrap {
|
|
||||||
.agent-message {
|
|
||||||
+ .agent-message {
|
|
||||||
margin-bottom: $space-micro;
|
|
||||||
|
|
||||||
.chat-bubble {
|
|
||||||
border-top-left-radius: $space-smaller;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+ .user-message {
|
|
||||||
margin-top: $space-normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,21 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-bubble agent">
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="!isCards && !isOptions && !isForm && !isArticle"
|
||||||
|
class="chat-bubble agent"
|
||||||
|
>
|
||||||
<span v-html="formatMessage(message)"></span>
|
<span v-html="formatMessage(message)"></span>
|
||||||
<email-input
|
<email-input
|
||||||
v-if="shouldShowInput"
|
v-if="isTemplateEmail"
|
||||||
:message-id="messageId"
|
:message-id="messageId"
|
||||||
:message-content-attributes="messageContentAttributes"
|
:message-content-attributes="messageContentAttributes"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isOptions">
|
||||||
|
<chat-options
|
||||||
|
:title="message"
|
||||||
|
:options="messageContentAttributes.items"
|
||||||
|
:hide-fields="!!messageContentAttributes.submitted_values"
|
||||||
|
@click="onOptionSelect"
|
||||||
|
>
|
||||||
|
</chat-options>
|
||||||
|
</div>
|
||||||
|
<chat-form
|
||||||
|
v-if="isForm"
|
||||||
|
:items="messageContentAttributes.items"
|
||||||
|
:submitted-values="messageContentAttributes.submitted_values"
|
||||||
|
@submit="onFormSubmit"
|
||||||
|
>
|
||||||
|
</chat-form>
|
||||||
|
<div v-if="isCards">
|
||||||
|
<chat-card
|
||||||
|
v-for="item in messageContentAttributes.items"
|
||||||
|
:key="item.title"
|
||||||
|
:media-url="item.media_url"
|
||||||
|
:title="item.title"
|
||||||
|
:description="item.description"
|
||||||
|
:actions="item.actions"
|
||||||
|
>
|
||||||
|
</chat-card>
|
||||||
|
</div>
|
||||||
|
<div v-if="isArticle">
|
||||||
|
<chat-article :items="messageContentAttributes.items"></chat-article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
|
import ChatCard from 'shared/components/ChatCard';
|
||||||
|
import ChatForm from 'shared/components/ChatForm';
|
||||||
|
import ChatOptions from 'shared/components/ChatOptions';
|
||||||
|
import ChatArticle from './template/Article';
|
||||||
import EmailInput from './template/EmailInput';
|
import EmailInput from './template/EmailInput';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AgentMessageBubble',
|
name: 'AgentMessageBubble',
|
||||||
components: {
|
components: {
|
||||||
|
ChatArticle,
|
||||||
|
ChatCard,
|
||||||
|
ChatForm,
|
||||||
|
ChatOptions,
|
||||||
EmailInput,
|
EmailInput,
|
||||||
},
|
},
|
||||||
mixins: [messageFormatterMixin],
|
mixins: [messageFormatterMixin],
|
||||||
@@ -30,8 +73,44 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
shouldShowInput() {
|
isTemplate() {
|
||||||
return this.contentType === 'input_email' && this.messageType === 3;
|
return this.messageType === 3;
|
||||||
|
},
|
||||||
|
isTemplateEmail() {
|
||||||
|
return this.contentType === 'input_email';
|
||||||
|
},
|
||||||
|
isCards() {
|
||||||
|
return this.contentType === 'cards';
|
||||||
|
},
|
||||||
|
isOptions() {
|
||||||
|
return this.contentType === 'input_select';
|
||||||
|
},
|
||||||
|
isForm() {
|
||||||
|
return this.contentType === 'form';
|
||||||
|
},
|
||||||
|
isArticle() {
|
||||||
|
return this.contentType === 'article';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onResponse(messageResponse) {
|
||||||
|
this.$store.dispatch('message/update', messageResponse);
|
||||||
|
},
|
||||||
|
onOptionSelect(selectedOption) {
|
||||||
|
this.onResponse({
|
||||||
|
submittedValues: [selectedOption],
|
||||||
|
messageId: this.messageId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFormSubmit(formValues) {
|
||||||
|
const formValuesAsArray = Object.keys(formValues).map(key => ({
|
||||||
|
name: key,
|
||||||
|
value: formValues[key],
|
||||||
|
}));
|
||||||
|
this.onResponse({
|
||||||
|
submittedValues: formValuesAsArray,
|
||||||
|
messageId: this.messageId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default {
|
|||||||
padding: $space-small $space-normal;
|
padding: $space-small $space-normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
a {
|
> a {
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ export default {
|
|||||||
&.user {
|
&.user {
|
||||||
border-bottom-right-radius: $space-smaller;
|
border-bottom-right-radius: $space-smaller;
|
||||||
|
|
||||||
a {
|
> a {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/javascript/widget/components/template/Article.vue
Normal file
62
app/javascript/widget/components/template/Article.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!!items.length" class="chat-bubble agent">
|
||||||
|
<div v-for="item in items" :key="item.link" class="article-item">
|
||||||
|
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow">
|
||||||
|
<span class="title">
|
||||||
|
<i class="ion-link icon"></i>{{ item.title }}
|
||||||
|
</span>
|
||||||
|
<span class="description">{{ truncateMessage(item.description) }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mixins: [messageFormatterMixin],
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '~widget/assets/scss/variables.scss';
|
||||||
|
|
||||||
|
.article-item {
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
font-size: $font-size-default;
|
||||||
|
padding: $space-small 0;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-body;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $color-woot;
|
||||||
|
display: block;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: $color-body;
|
||||||
|
font-size: $font-size-medium;
|
||||||
|
padding-right: $space-small;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: block;
|
||||||
|
margin-top: $space-smaller;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,9 +21,6 @@
|
|||||||
<spinner v-else />
|
<spinner v-else />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<span v-else>
|
|
||||||
<i>{{ messageContentAttributes.submitted_email }}</i>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -71,7 +68,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.$store.dispatch('message/updateContactAttributes', {
|
this.$store.dispatch('message/update', {
|
||||||
email: this.email,
|
email: this.email,
|
||||||
messageId: this.messageId,
|
messageId: this.messageId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const refreshActionCableConnector = pubsubToken => {
|
export const refreshActionCableConnector = pubsubToken => {
|
||||||
|
if (!pubsubToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.chatwootPubsubToken = pubsubToken;
|
window.chatwootPubsubToken = pubsubToken;
|
||||||
window.actionCable.disconnect();
|
window.actionCable.disconnect();
|
||||||
window.actionCable = new ActionCableConnector(
|
window.actionCable = new ActionCableConnector(
|
||||||
|
|||||||
@@ -4,5 +4,8 @@ export default {
|
|||||||
DOWNLOAD: 'Download',
|
DOWNLOAD: 'Download',
|
||||||
UPLOADING: 'Uploading...',
|
UPLOADING: 'Uploading...',
|
||||||
},
|
},
|
||||||
|
FORM_BUBBLE: {
|
||||||
|
SUBMIT: 'Submit',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import appConfig from 'widget/store/modules/appConfig';
|
|||||||
import contacts from 'widget/store/modules/contacts';
|
import contacts from 'widget/store/modules/contacts';
|
||||||
import conversation from 'widget/store/modules/conversation';
|
import conversation from 'widget/store/modules/conversation';
|
||||||
import conversationLabels from 'widget/store/modules/conversationLabels';
|
import conversationLabels from 'widget/store/modules/conversationLabels';
|
||||||
|
import events from 'widget/store/modules/events';
|
||||||
import message from 'widget/store/modules/message';
|
import message from 'widget/store/modules/message';
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
@@ -13,9 +14,10 @@ export default new Vuex.Store({
|
|||||||
modules: {
|
modules: {
|
||||||
agent,
|
agent,
|
||||||
appConfig,
|
appConfig,
|
||||||
message,
|
|
||||||
contacts,
|
contacts,
|
||||||
conversation,
|
conversation,
|
||||||
conversationLabels,
|
conversationLabels,
|
||||||
|
events,
|
||||||
|
message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -188,7 +188,10 @@ export const mutations = {
|
|||||||
updateMessage($state, { id, content_attributes }) {
|
updateMessage($state, { id, content_attributes }) {
|
||||||
$state.conversations[id] = {
|
$state.conversations[id] = {
|
||||||
...$state.conversations[id],
|
...$state.conversations[id],
|
||||||
content_attributes,
|
content_attributes: {
|
||||||
|
...($state.conversations[id].content_attributes || {}),
|
||||||
|
...content_attributes,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
19
app/javascript/widget/store/modules/events.js
Normal file
19
app/javascript/widget/store/modules/events.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import events from 'widget/api/events';
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
create: async (_, { name }) => {
|
||||||
|
try {
|
||||||
|
await events.create(name);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
namespaced: true,
|
||||||
|
state: {},
|
||||||
|
getters: {},
|
||||||
|
actions,
|
||||||
|
mutations: {},
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import MessageAPI from 'widget/api/message';
|
import MessageAPI from '../../api/message';
|
||||||
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
@@ -12,19 +12,24 @@ const getters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
updateContactAttributes: async ({ commit }, { email, messageId }) => {
|
update: async ({ commit }, { email, messageId, submittedValues }) => {
|
||||||
commit('toggleUpdateStatus', true);
|
commit('toggleUpdateStatus', true);
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: {
|
data: { contact: { pubsub_token: pubsubToken } = {} },
|
||||||
contact: { pubsub_token: pubsubToken },
|
} = await MessageAPI.update({
|
||||||
},
|
email,
|
||||||
} = await MessageAPI.update({ email, messageId });
|
messageId,
|
||||||
|
values: submittedValues,
|
||||||
|
});
|
||||||
commit(
|
commit(
|
||||||
'conversation/updateMessage',
|
'conversation/updateMessage',
|
||||||
{
|
{
|
||||||
id: messageId,
|
id: messageId,
|
||||||
content_attributes: { submitted_email: email },
|
content_attributes: {
|
||||||
|
submitted_email: email,
|
||||||
|
submitted_values: email ? null : submittedValues,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ root: true }
|
{ root: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,4 +10,28 @@ class AgentBotListener < BaseListener
|
|||||||
payload = message.webhook_data.merge(event: __method__.to_s)
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
|
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def message_updated(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
inbox = message.inbox
|
||||||
|
return unless message.reportable? && inbox.agent_bot_inbox.present?
|
||||||
|
return unless inbox.agent_bot_inbox.active?
|
||||||
|
|
||||||
|
agent_bot = inbox.agent_bot_inbox.agent_bot
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def webwidget_triggered(event)
|
||||||
|
contact_inbox = event.data[:contact_inbox]
|
||||||
|
inbox = contact_inbox.inbox
|
||||||
|
return if inbox.agent_bot_inbox.blank?
|
||||||
|
return unless inbox.agent_bot_inbox.active?
|
||||||
|
|
||||||
|
agent_bot = inbox.agent_bot_inbox.agent_bot
|
||||||
|
|
||||||
|
payload = contact_inbox.webhook_data.merge(event: __method__.to_s)
|
||||||
|
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ class WebhookListener < BaseListener
|
|||||||
return unless message.reportable?
|
return unless message.reportable?
|
||||||
|
|
||||||
payload = message.webhook_data.merge(event: __method__.to_s)
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_webhook_payloads(payload, inbox)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_updated(event)
|
||||||
|
message = extract_message_and_account(event)[0]
|
||||||
|
inbox = message.inbox
|
||||||
|
|
||||||
|
return unless message.reportable?
|
||||||
|
|
||||||
|
payload = message.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_webhook_payloads(payload, inbox)
|
||||||
|
end
|
||||||
|
|
||||||
|
def webwidget_triggered(event)
|
||||||
|
contact_inbox = event.data[:contact_inbox]
|
||||||
|
inbox = contact_inbox.inbox
|
||||||
|
|
||||||
|
payload = contact_inbox.webhook_data.merge(event: __method__.to_s)
|
||||||
|
deliver_webhook_payloads(payload, inbox)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def deliver_webhook_payloads(payload, inbox)
|
||||||
# Account webhooks
|
# Account webhooks
|
||||||
inbox.account.webhooks.account.each do |webhook|
|
inbox.account.webhooks.account.each do |webhook|
|
||||||
WebhookJob.perform_later(webhook.url, payload)
|
WebhookJob.perform_later(webhook.url, payload)
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ class Channel::WebWidget < ApplicationRecord
|
|||||||
def create_contact_inbox
|
def create_contact_inbox
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000))
|
contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000))
|
||||||
::ContactInbox.create!(
|
contact_inbox = ::ContactInbox.create!(
|
||||||
contact_id: contact.id,
|
contact_id: contact.id,
|
||||||
inbox_id: inbox.id,
|
inbox_id: inbox.id,
|
||||||
source_id: SecureRandom.uuid
|
source_id: SecureRandom.uuid
|
||||||
)
|
)
|
||||||
|
contact_inbox
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger e
|
Rails.logger e
|
||||||
end
|
end
|
||||||
|
|||||||
52
app/models/concerns/content_attribute_validator.rb
Normal file
52
app/models/concerns/content_attribute_validator.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class ContentAttributeValidator < ActiveModel::Validator
|
||||||
|
ALLOWED_SELECT_ITEM_KEYS = [:title, :value].freeze
|
||||||
|
ALLOWED_CARD_ITEM_KEYS = [:title, :description, :media_url, :actions].freeze
|
||||||
|
ALLOWED_CARD_ITEM_ACTION_KEYS = [:text, :type, :payload, :uri].freeze
|
||||||
|
ALLOWED_FORM_ITEM_KEYS = [:type, :placeholder, :label, :name, :options].freeze
|
||||||
|
ALLOWED_ARTICLE_KEYS = [:title, :description, :link].freeze
|
||||||
|
|
||||||
|
def validate(record)
|
||||||
|
case record.content_type
|
||||||
|
when 'input_select'
|
||||||
|
validate_items!(record)
|
||||||
|
validate_item_attributes!(record, ALLOWED_SELECT_ITEM_KEYS)
|
||||||
|
when 'cards'
|
||||||
|
validate_items!(record)
|
||||||
|
validate_item_attributes!(record, ALLOWED_CARD_ITEM_KEYS)
|
||||||
|
validate_item_actions!(record)
|
||||||
|
when 'form'
|
||||||
|
validate_items!(record)
|
||||||
|
validate_item_attributes!(record, ALLOWED_FORM_ITEM_KEYS)
|
||||||
|
when 'article'
|
||||||
|
validate_items!(record)
|
||||||
|
validate_item_attributes!(record, ALLOWED_ARTICLE_KEYS)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_items!(record)
|
||||||
|
record.errors.add(:content_attributes, 'At least one item is required.') if record.items.blank?
|
||||||
|
record.errors.add(:content_attributes, 'Items should be a hash.') if record.items.reject { |item| item.is_a?(Hash) }.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_item_attributes!(record, valid_keys)
|
||||||
|
item_keys = record.items.collect(&:keys).flatten.map(&:to_sym).compact
|
||||||
|
invalid_keys = item_keys - valid_keys
|
||||||
|
record.errors.add(:content_attributes, "contains invalid keys for items : #{invalid_keys}") if invalid_keys.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_item_actions!(record)
|
||||||
|
if record.items.select { |item| item[:actions].blank? }.present?
|
||||||
|
record.errors.add(:content_attributes, 'contains items missing actions') && return
|
||||||
|
end
|
||||||
|
|
||||||
|
validate_item_action_attributes!(record)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_item_action_attributes!(record)
|
||||||
|
item_action_keys = record.items.collect { |item| item[:actions].collect(&:keys) }
|
||||||
|
invalid_keys = item_action_keys.flatten.compact.map(&:to_sym) - ALLOWED_CARD_ITEM_ACTION_KEYS
|
||||||
|
record.errors.add(:content_attributes, "contains invalid keys for actions: #{invalid_keys}") if invalid_keys.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,4 +31,19 @@ class ContactInbox < ApplicationRecord
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
|
|
||||||
has_many :conversations, dependent: :destroy
|
has_many :conversations, dependent: :destroy
|
||||||
|
|
||||||
|
def webhook_data
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
contact: contact.try(:webhook_data),
|
||||||
|
inbox: inbox.webhook_data,
|
||||||
|
account: inbox.account.webhook_data,
|
||||||
|
current_conversation: current_conversation.try(:webhook_data),
|
||||||
|
source_id: source_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_conversation
|
||||||
|
conversations.last
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ class Message < ApplicationRecord
|
|||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
validates :inbox_id, presence: true
|
validates :inbox_id, presence: true
|
||||||
validates :conversation_id, presence: true
|
validates :conversation_id, presence: true
|
||||||
|
validates_with ContentAttributeValidator
|
||||||
|
|
||||||
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
|
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 content_type: {
|
||||||
|
text: 0,
|
||||||
|
input_text: 1,
|
||||||
|
input_textarea: 2,
|
||||||
|
input_email: 3,
|
||||||
|
input_select: 4,
|
||||||
|
cards: 5,
|
||||||
|
form: 6,
|
||||||
|
article: 7
|
||||||
|
}
|
||||||
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
|
||||||
store :content_attributes, accessors: [:submitted_email], coder: JSON, prefix: :input
|
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values], coder: JSON
|
||||||
|
|
||||||
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
|
# .succ is a hack to avoid https://makandracards.com/makandra/1057-why-two-ruby-time-objects-are-not-equal-although-they-appear-to-be
|
||||||
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
|
scope :unread_since, ->(datetime) { where('EXTRACT(EPOCH FROM created_at) > (?)', datetime.to_i.succ) }
|
||||||
@@ -63,6 +73,8 @@ class Message < ApplicationRecord
|
|||||||
:execute_message_template_hooks,
|
:execute_message_template_hooks,
|
||||||
:notify_via_mail
|
:notify_via_mail
|
||||||
|
|
||||||
|
after_update :dispatch_update_event
|
||||||
|
|
||||||
def channel_token
|
def channel_token
|
||||||
@token ||= inbox.channel.try(:page_access_token)
|
@token ||= inbox.channel.try(:page_access_token)
|
||||||
end
|
end
|
||||||
@@ -88,6 +100,8 @@ class Message < ApplicationRecord
|
|||||||
content: content,
|
content: content,
|
||||||
created_at: created_at,
|
created_at: created_at,
|
||||||
message_type: message_type,
|
message_type: message_type,
|
||||||
|
content_type: content_type,
|
||||||
|
content_attributes: content_attributes,
|
||||||
source_id: source_id,
|
source_id: source_id,
|
||||||
sender: user.try(:webhook_data),
|
sender: user.try(:webhook_data),
|
||||||
contact: contact.try(:webhook_data),
|
contact: contact.try(:webhook_data),
|
||||||
@@ -107,6 +121,10 @@ class Message < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def dispatch_update_event
|
||||||
|
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self)
|
||||||
|
end
|
||||||
|
|
||||||
def send_reply
|
def send_reply
|
||||||
channel_name = conversation.inbox.channel.class.to_s
|
channel_name = conversation.inbox.channel.class.to_s
|
||||||
if channel_name == 'Channel::FacebookPage'
|
if channel_name == 'Channel::FacebookPage'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/conversations/partials/conversation.json.jbuilder', conversation: @conversation
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
json.id @message.id
|
json.id @message.id
|
||||||
json.content @message.content
|
json.content @message.content
|
||||||
json.inbox_id @message.inbox_id
|
json.inbox_id @message.inbox_id
|
||||||
json.conversation_id @message.conversation_id
|
json.conversation_id @message.conversation.display_id
|
||||||
json.message_type @message.message_type_before_type_cast
|
json.message_type @message.message_type_before_type_cast
|
||||||
|
json.content_type @message.content_type
|
||||||
|
json.content_attributes @message.content_attributes
|
||||||
json.created_at @message.created_at.to_i
|
json.created_at @message.created_at.to_i
|
||||||
json.private @message.private
|
json.private @message.private
|
||||||
json.attachment @message.attachment.push_event_data if @message.attachment
|
json.attachment @message.attachment.push_event_data if @message.attachment
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ json.user_last_seen_at conversation.user_last_seen_at.to_i
|
|||||||
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
|
||||||
json.unread_count conversation.unread_incoming_messages.count
|
json.unread_count conversation.unread_incoming_messages.count
|
||||||
json.additional_attributes conversation.additional_attributes
|
json.additional_attributes conversation.additional_attributes
|
||||||
|
json.account_id conversation.account_id
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
json.contact @contact
|
json.contact @contact if @contact
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Rails.application.routes.draw do
|
|||||||
namespace :channels do
|
namespace :channels do
|
||||||
resource :twilio_channel, only: [:create]
|
resource :twilio_channel, only: [:create]
|
||||||
end
|
end
|
||||||
resources :conversations, only: [:index, :show] do
|
resources :conversations, only: [:index, :create, :show] do
|
||||||
scope module: :conversations do
|
scope module: :conversations do
|
||||||
resources :messages, only: [:index, :create]
|
resources :messages, only: [:index, :create]
|
||||||
resources :assignments, only: [:create]
|
resources :assignments, only: [:create]
|
||||||
@@ -109,10 +109,11 @@ Rails.application.routes.draw do
|
|||||||
resources :agent_bots, only: [:index]
|
resources :agent_bots, only: [:index]
|
||||||
|
|
||||||
namespace :widget do
|
namespace :widget do
|
||||||
|
resources :events, only: [:create]
|
||||||
|
resources :messages, only: [:index, :create, :update]
|
||||||
resource :contact, only: [:update]
|
resource :contact, only: [:update]
|
||||||
resources :inbox_members, only: [:index]
|
resources :inbox_members, only: [:index]
|
||||||
resources :labels, only: [:create, :destroy]
|
resources :labels, only: [:create, :destroy]
|
||||||
resources :messages, only: [:index, :create, :update]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :webhooks, only: [] do
|
resources :webhooks, only: [] do
|
||||||
|
|||||||
@@ -287,9 +287,11 @@ ActiveRecord::Schema.define(version: 2020_04_04_135009) do
|
|||||||
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
|
t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context"
|
||||||
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
|
t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy"
|
||||||
t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
|
t.index ["taggable_id"], name: "index_taggings_on_taggable_id"
|
||||||
|
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id"
|
||||||
t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
|
t.index ["taggable_type"], name: "index_taggings_on_taggable_type"
|
||||||
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
|
t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type"
|
||||||
t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
|
t.index ["tagger_id"], name: "index_taggings_on_tagger_id"
|
||||||
|
t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "tags", id: :serial, force: :cascade do |t|
|
create_table "tags", id: :serial, force: :cascade do |t|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ Chatwoot currently supports webhooks for message creation only. Once a new messa
|
|||||||
"id": "1", // Message ID
|
"id": "1", // Message ID
|
||||||
"content": "Hi", // Content of the message
|
"content": "Hi", // Content of the message
|
||||||
"created_at": "2020-03-03 13:05:57 UTC", // Time at which the message was sent
|
"created_at": "2020-03-03 13:05:57 UTC", // Time at which the message was sent
|
||||||
"message_type": "incoming", // This will have a type incoming or outgoing. Incoming messages are sent by the user from the widget, Outgoing messages are sent by the agent to the user.
|
"message_type": "incoming", // This will have a type incoming, outgoing or template. Incoming messages are sent by the user from the widget, Outgoing messages are sent by the agent to the user.
|
||||||
|
"content_type": "enum", // This is an enum, it can be input_select, cards, form or text. The message_type will be template if content_type is one og these. Default value is text
|
||||||
|
"content_attributes": {} // This will an object, different values are defined below
|
||||||
"source_id": "", // This would the external id if the inbox is a Twitter or Facebook integration.
|
"source_id": "", // This would the external id if the inbox is a Twitter or Facebook integration.
|
||||||
"sender": { // This would provide the details of the agent who sent this message
|
"sender": { // This would provide the details of the agent who sent this message
|
||||||
"id": "1",
|
"id": "1",
|
||||||
@@ -59,3 +61,70 @@ Chatwoot currently supports webhooks for message creation only. Once a new messa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Content Attributes
|
||||||
|
|
||||||
|
#### 1. Options
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Option 1",
|
||||||
|
"value": "option_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Option 2",
|
||||||
|
"value": "option_2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"submitted_values": [
|
||||||
|
{
|
||||||
|
"title": "Option 1",
|
||||||
|
"value": "option_1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Form
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "text/text_area/email",
|
||||||
|
"placeholder": "Placeholder",
|
||||||
|
"name": "unique_name_of_the_field",
|
||||||
|
"label": "Label"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"submitted_values": [
|
||||||
|
{
|
||||||
|
"name": "unique_name_of_the_field 1",
|
||||||
|
"value": "sample_value"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Cards
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"media_url": "", // Url of the image to be displayed
|
||||||
|
"title": "", // Title of the card
|
||||||
|
"description": "", // Description of the card
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"text": "View More",
|
||||||
|
"uri": "" // Link to the website
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ module Events::Types
|
|||||||
CONVERSATION_CREATED = 'conversation.created'
|
CONVERSATION_CREATED = 'conversation.created'
|
||||||
CONVERSATION_RESOLVED = 'conversation.resolved'
|
CONVERSATION_RESOLVED = 'conversation.resolved'
|
||||||
CONVERSATION_READ = 'conversation.read'
|
CONVERSATION_READ = 'conversation.read'
|
||||||
|
WEBWIDGET_TRIGGERED = 'webwidget.triggered'
|
||||||
|
|
||||||
MESSAGE_CREATED = 'message.created'
|
MESSAGE_CREATED = 'message.created'
|
||||||
FIRST_REPLY_CREATED = 'first.reply.created'
|
FIRST_REPLY_CREATED = 'first.reply.created'
|
||||||
|
MESSAGE_UPDATED = 'message.updated'
|
||||||
CONVERSATION_REOPENED = 'conversation.reopened'
|
CONVERSATION_REOPENED = 'conversation.reopened'
|
||||||
CONVERSATION_LOCK_TOGGLE = 'conversation.lock_toggle'
|
CONVERSATION_LOCK_TOGGLE = 'conversation.lock_toggle'
|
||||||
ASSIGNEE_CHANGED = 'assignee.changed'
|
ASSIGNEE_CHANGED = 'assignee.changed'
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
|||||||
let(:agent) { create(:user, account: account, role: :agent) }
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
it 'creates a new outgoing message' do
|
it 'creates a new outgoing message' do
|
||||||
params = { message: 'test-message', private: true }
|
params = { content: 'test-message', private: true }
|
||||||
|
|
||||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
params: params,
|
params: params,
|
||||||
@@ -28,12 +28,12 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(conversation.messages.count).to eq(1)
|
expect(conversation.messages.count).to eq(1)
|
||||||
expect(conversation.messages.first.content).to eq(params[:message])
|
expect(conversation.messages.first.content).to eq(params[:content])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new outgoing message with attachment' do
|
it 'creates a new outgoing message with attachment' do
|
||||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||||
params = { message: 'test-message', attachment: { file: file } }
|
params = { content: 'test-message', attachment: { file: file } }
|
||||||
|
|
||||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
params: params,
|
params: params,
|
||||||
@@ -50,7 +50,7 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
|||||||
|
|
||||||
it 'creates a new outgoing message' do
|
it 'creates a new outgoing message' do
|
||||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||||
params = { message: 'test-message' }
|
params = { content: 'test-message' }
|
||||||
|
|
||||||
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
params: params,
|
params: params,
|
||||||
@@ -59,7 +59,39 @@ RSpec.describe 'Conversation Messages API', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(conversation.messages.count).to eq(1)
|
expect(conversation.messages.count).to eq(1)
|
||||||
expect(conversation.messages.first.content).to eq(params[:message])
|
expect(conversation.messages.first.content).to eq(params[:content])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a new outgoing input select message' do
|
||||||
|
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||||
|
select_item1 = build(:bot_message_select)
|
||||||
|
select_item2 = build(:bot_message_select)
|
||||||
|
params = { content_type: 'input_select', content_attributes: { items: [select_item1, select_item2] } }
|
||||||
|
|
||||||
|
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
|
params: params,
|
||||||
|
headers: { api_access_token: agent_bot.access_token.token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(conversation.messages.count).to eq(1)
|
||||||
|
expect(conversation.messages.first.content_type).to eq(params[:content_type])
|
||||||
|
expect(conversation.messages.first.content).to eq nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a new outgoing cards message' do
|
||||||
|
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||||
|
card = build(:bot_message_card)
|
||||||
|
params = { content_type: 'cards', content_attributes: { items: [card] } }
|
||||||
|
|
||||||
|
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
|
||||||
|
params: params,
|
||||||
|
headers: { api_access_token: agent_bot.access_token.token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(conversation.messages.count).to eq(1)
|
||||||
|
expect(conversation.messages.first.content_type).to eq(params[:content_type])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
37
spec/controllers/api/v1/widget/events_controller_spec.rb
Normal file
37
spec/controllers/api/v1/widget/events_controller_spec.rb
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe '/api/v1/widget/events', 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(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
|
||||||
|
let(:token) { ::Widget::TokenService.new(payload: payload).generate_token }
|
||||||
|
|
||||||
|
describe 'POST /api/v1/widget/events' do
|
||||||
|
let(:params) { { website_token: web_widget.website_token, name: 'webwidget.triggered' } }
|
||||||
|
|
||||||
|
context 'with invalid website token' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
post '/api/v1/widget/events', params: { website_token: '' }
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with correct website token' do
|
||||||
|
before do
|
||||||
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'dispatches the webwidget event' do
|
||||||
|
post '/api/v1/widget/events',
|
||||||
|
params: params,
|
||||||
|
headers: { 'X-Auth-Token' => token },
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(params[:name], anything, contact_inbox: contact_inbox)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -65,7 +65,7 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
|||||||
describe 'PUT /api/v1/widget/messages' do
|
describe 'PUT /api/v1/widget/messages' do
|
||||||
context 'when put request is made with non existing email' do
|
context 'when put request is made with non existing email' do
|
||||||
it 'updates message in conversation and creates a new contact' do
|
it 'updates message in conversation and creates a new contact' do
|
||||||
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||||
email = Faker::Internet.email
|
email = Faker::Internet.email
|
||||||
contact_params = { email: email }
|
contact_params = { email: email }
|
||||||
put api_v1_widget_message_url(message.id),
|
put api_v1_widget_message_url(message.id),
|
||||||
@@ -75,14 +75,14 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
message.reload
|
message.reload
|
||||||
expect(message.input_submitted_email).to eq(email)
|
expect(message.submitted_email).to eq(email)
|
||||||
expect(message.conversation.contact.email).to eq(email)
|
expect(message.conversation.contact.email).to eq(email)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when put request is made with invalid email' do
|
context 'when put request is made with invalid email' do
|
||||||
it 'rescues the error' do
|
it 'rescues the error' do
|
||||||
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
message = create(:message, account: account, content_type: 'input_email', inbox: web_widget.inbox, conversation: conversation)
|
||||||
contact_params = { email: nil }
|
contact_params = { email: nil }
|
||||||
put api_v1_widget_message_url(message.id),
|
put api_v1_widget_message_url(message.id),
|
||||||
params: { website_token: web_widget.website_token, contact: contact_params },
|
params: { website_token: web_widget.website_token, contact: contact_params },
|
||||||
@@ -95,7 +95,7 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
|||||||
|
|
||||||
context 'when put request is made with existing email' do
|
context 'when put request is made with existing email' do
|
||||||
it 'updates message in conversation and deletes the current contact' do
|
it 'updates message in conversation and deletes the current contact' do
|
||||||
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
message = create(:message, account: account, content_type: 'input_email', inbox: web_widget.inbox, conversation: conversation)
|
||||||
email = Faker::Internet.email
|
email = Faker::Internet.email
|
||||||
create(:contact, account: account, email: email)
|
create(:contact, account: account, email: email)
|
||||||
contact_params = { email: email }
|
contact_params = { email: email }
|
||||||
@@ -110,7 +110,7 @@ RSpec.describe '/api/v1/widget/messages', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'ignores the casing of email, updates message in conversation and deletes the current contact' do
|
it 'ignores the casing of email, updates message in conversation and deletes the current contact' do
|
||||||
message = create(:message, account: account, inbox: web_widget.inbox, conversation: conversation)
|
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
|
||||||
email = Faker::Internet.email
|
email = Faker::Internet.email
|
||||||
create(:contact, account: account, email: email)
|
create(:contact, account: account, email: email)
|
||||||
contact_params = { email: email.upcase }
|
contact_params = { email: email.upcase }
|
||||||
|
|||||||
22
spec/factories/bot_message/bot_message_card.rb
Normal file
22
spec/factories/bot_message/bot_message_card.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :bot_message_card, class: Hash do
|
||||||
|
title { Faker::Book.name }
|
||||||
|
description { Faker::Movie.quote }
|
||||||
|
media_url { 'https://via.placeholder.com/250x250.png' }
|
||||||
|
actions do
|
||||||
|
[{
|
||||||
|
text: 'Select',
|
||||||
|
type: 'postback',
|
||||||
|
payload: 'TACOS'
|
||||||
|
}, {
|
||||||
|
text: 'More info',
|
||||||
|
type: 'link',
|
||||||
|
uri: 'http://example.org'
|
||||||
|
}]
|
||||||
|
end
|
||||||
|
|
||||||
|
initialize_with { attributes }
|
||||||
|
end
|
||||||
|
end
|
||||||
10
spec/factories/bot_message/bot_message_select.rb
Normal file
10
spec/factories/bot_message/bot_message_select.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :bot_message_select, class: Hash do
|
||||||
|
title { Faker::Book.name }
|
||||||
|
value { Faker::Book.name }
|
||||||
|
|
||||||
|
initialize_with { attributes }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,10 +4,16 @@ properties:
|
|||||||
type: number
|
type: number
|
||||||
description: ID of the conversation
|
description: ID of the conversation
|
||||||
required: true
|
required: true
|
||||||
message:
|
content:
|
||||||
type: string
|
type: string
|
||||||
description: The content of the message
|
description: The content of the message
|
||||||
required: true
|
required: true
|
||||||
private:
|
private:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Flag to identify if it is a private note
|
description: Flag to identify if it is a private note
|
||||||
|
content_type:
|
||||||
|
type: string
|
||||||
|
enum: ['input_select', 'form', 'cards']
|
||||||
|
content_attributes:
|
||||||
|
type: object
|
||||||
|
description: options/form object
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ properties:
|
|||||||
description: ID of the conversation
|
description: ID of the conversation
|
||||||
messages:
|
messages:
|
||||||
type: array
|
type: array
|
||||||
|
account_id:
|
||||||
|
type: number
|
||||||
|
description: Account Id
|
||||||
inbox_id:
|
inbox_id:
|
||||||
type: number
|
type: number
|
||||||
description: ID of the inbox
|
description: ID of the inbox
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ properties:
|
|||||||
content:
|
content:
|
||||||
type: string
|
type: string
|
||||||
description: The text content of the message
|
description: The text content of the message
|
||||||
|
content_type:
|
||||||
|
type: string
|
||||||
|
enum: ["text", "input_select", "cards", "form"]
|
||||||
|
description: The type of the template message
|
||||||
|
content_attributes:
|
||||||
|
type: object
|
||||||
|
description: The content attributes for each content_type
|
||||||
message_type:
|
message_type:
|
||||||
type: string
|
type: string
|
||||||
enum: ["incoming", "outgoing", "activity", "template"]
|
enum: ["incoming", "outgoing", "activity", "template"]
|
||||||
|
|||||||
@@ -37,3 +37,39 @@ get:
|
|||||||
description: Bad Request Error
|
description: Bad Request Error
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/bad_request_error'
|
$ref: '#/definitions/bad_request_error'
|
||||||
|
description: Access denied
|
||||||
|
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Conversation
|
||||||
|
operationId: newConversation
|
||||||
|
summary: Create New Conversation
|
||||||
|
description: Create conversation
|
||||||
|
parameters:
|
||||||
|
- name: data
|
||||||
|
in: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
source_id:
|
||||||
|
type: string
|
||||||
|
description: Contact Source Id
|
||||||
|
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Success
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
description: ID of the conversation
|
||||||
|
account_id:
|
||||||
|
type: number
|
||||||
|
description: Account Id
|
||||||
|
inbox_id:
|
||||||
|
type: number
|
||||||
|
description: ID of the inbox
|
||||||
|
403:
|
||||||
|
description: Access denied
|
||||||
@@ -10,15 +10,14 @@
|
|||||||
/accounts/{account_id}/inboxes/{id}:
|
/accounts/{account_id}/inboxes/{id}:
|
||||||
$ref: ./inboxes/update.yml
|
$ref: ./inboxes/update.yml
|
||||||
/accounts/{account_id}/inboxes/{id}/set_agent_bot:
|
/accounts/{account_id}/inboxes/{id}/set_agent_bot:
|
||||||
$ref: ./inboxes/update.yml
|
$ref: ./inboxes/set_agent_bot.yml
|
||||||
|
|
||||||
/agent_bots:
|
/agent_bots:
|
||||||
$ref: ./agent_bots/index.yml
|
$ref: ./agent_bots/index.yml
|
||||||
|
|
||||||
|
|
||||||
# Conversations
|
# Conversations
|
||||||
/accounts/{account_id}/conversations:
|
/accounts/{account_id}/conversations:
|
||||||
$ref: ./conversation/list.yml
|
$ref: ./conversation/index_or_create.yml
|
||||||
/accounts/{account_id}/conversations/{id}:
|
/accounts/{account_id}/conversations/{id}:
|
||||||
$ref: ./conversation/crud.yml
|
$ref: ./conversation/crud.yml
|
||||||
/accounts/{account_id}/conversations/{id}/toggle_status:
|
/accounts/{account_id}/conversations/{id}/toggle_status:
|
||||||
|
|||||||
@@ -201,13 +201,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/accounts/{account_id}/inboxes/{id}/set_agent_bot": {
|
"/accounts/{account_id}/inboxes/{id}/set_agent_bot": {
|
||||||
"patch": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Inbox"
|
"Inbox"
|
||||||
],
|
],
|
||||||
"operationId": "updateInbox",
|
"operationId": "updateAgentBot",
|
||||||
"summary": "Update Inbox",
|
"summary": "Add or remove agent bot",
|
||||||
"description": "Add avatar and disable auto assignment for an inbox",
|
"description": "To add an agent bot pass agent_bot id, to remove agent bot from an inbox pass null",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -223,29 +223,21 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"enable_auto_assignment": {
|
"agent_bot": {
|
||||||
"type": "boolean",
|
"type": "number",
|
||||||
"required": true,
|
"required": true,
|
||||||
"description": "Enable Auto Assignment"
|
"description": "Agent bot ID"
|
||||||
},
|
|
||||||
"avatar": {
|
|
||||||
"type": "file",
|
|
||||||
"required": false,
|
|
||||||
"description": "Image file for avatar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"204": {
|
||||||
"description": "Success",
|
"description": "Success"
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/inbox"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
"description": "Inbox not found"
|
"description": "Inbox not found, Agent bot not found"
|
||||||
},
|
},
|
||||||
"403": {
|
"403": {
|
||||||
"description": "Access denied"
|
"description": "Access denied"
|
||||||
@@ -340,6 +332,56 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/bad_request_error"
|
"$ref": "#/definitions/bad_request_error"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"description": "Access denied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Conversation"
|
||||||
|
],
|
||||||
|
"operationId": "newConversation",
|
||||||
|
"summary": "Create New Conversation",
|
||||||
|
"description": "Create conversation",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "data",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Contact Source Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "ID of the conversation"
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account Id"
|
||||||
|
},
|
||||||
|
"inbox_id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "ID of the inbox"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Access denied"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -881,6 +923,10 @@
|
|||||||
"messages": {
|
"messages": {
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"account_id": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Account Id"
|
||||||
|
},
|
||||||
"inbox_id": {
|
"inbox_id": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "ID of the inbox"
|
"description": "ID of the inbox"
|
||||||
@@ -921,6 +967,20 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The text content of the message"
|
"description": "The text content of the message"
|
||||||
},
|
},
|
||||||
|
"content_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"text",
|
||||||
|
"input_select",
|
||||||
|
"cards",
|
||||||
|
"form"
|
||||||
|
],
|
||||||
|
"description": "The type of the template message"
|
||||||
|
},
|
||||||
|
"content_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "The content attributes for each content_type"
|
||||||
|
},
|
||||||
"message_type": {
|
"message_type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -1350,7 +1410,7 @@
|
|||||||
"description": "ID of the conversation",
|
"description": "ID of the conversation",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
"message": {
|
"content": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The content of the message",
|
"description": "The content of the message",
|
||||||
"required": true
|
"required": true
|
||||||
@@ -1358,6 +1418,18 @@
|
|||||||
"private": {
|
"private": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Flag to identify if it is a private note"
|
"description": "Flag to identify if it is a private note"
|
||||||
|
},
|
||||||
|
"content_type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"input_select",
|
||||||
|
"form",
|
||||||
|
"cards"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content_attributes": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "options/form object"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user