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:
Sojan Jose
2020-04-10 16:42:37 +05:30
committed by GitHub
parent 48f603798b
commit b0950d6880
58 changed files with 997 additions and 146 deletions

View File

@@ -3,11 +3,13 @@ class Messages::Outgoing::NormalBuilder
attr_reader :message
def initialize(user, conversation, params)
@content = params[:message]
@content = params[:content]
@private = params[:private] || false
@conversation = conversation
@user = user
@fb_id = params[:fb_id]
@content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachment = params[:attachment]
end
@@ -34,7 +36,9 @@ class Messages::Outgoing::NormalBuilder
content: @content,
private: @private,
user_id: @user&.id,
source_id: @fb_id
source_id: @fb_id,
content_type: @content_type,
items: @items
}
end
end

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConversationsController < Api::BaseController
before_action :conversation, except: [:index]
before_action :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@@ -7,6 +8,10 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversations_count = result[:count]
end
def create
@conversation = ::Conversation.create!(conversation_params)
end
def show; end
def toggle_status
@@ -29,6 +34,19 @@ class Api::V1::Accounts::ConversationsController < Api::BaseController
@conversation ||= current_account.conversations.find_by(display_id: params[:id])
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
@conversation_finder ||= ConversationFinder.new(current_user, params)
end

View File

@@ -2,9 +2,9 @@ class Api::V1::Widget::BaseController < ApplicationController
private
def conversation
@conversation ||= @contact_inbox.conversations.find_by(
@conversation ||= @contact_inbox.conversations.where(
inbox_id: auth_token_params[:inbox_id]
)
).last
end
def auth_token_params

View 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

View File

@@ -15,8 +15,12 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def update
@message.update!(input_submitted_email: contact_email)
update_contact(contact_email)
if @message.content_type == 'input_email'
@message.update!(submitted_email: contact_email)
update_contact(contact_email)
else
@message.update!(message_update_params[:message])
end
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end
@@ -116,6 +120,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
contact_email.split('@')[0]
end
def message_update_params
params.permit(message: [submitted_values: [:name, :title, :value]])
end
def permitted_params
params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp])
end

View File

@@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => ['toggle_status'],
'api/v1/accounts/conversations' => %w[toggle_status create],
'api/v1/accounts/conversations/messages' => ['create']
}.freeze

View File

@@ -9,7 +9,7 @@ class MessageApi extends ApiClient {
create({ conversationId, message, private: isPrivate }) {
return axios.post(`${this.url}/${conversationId}/messages`, {
message,
content: message,
private: isPrivate,
});
}

View File

@@ -15,6 +15,10 @@ export default {
DOWNLOAD: 'Herunterladen',
UPLOADING: 'Hochladen...',
},
FORM_BUBBLE: {
SUBMIT: 'Einreichen',
},
},
CONFIRM_EMAIL: 'Überprüfen...',
SETTINGS: {

View File

@@ -15,6 +15,9 @@ export default {
DOWNLOAD: 'Download',
UPLOADING: 'Uploading...',
},
FORM_BUBBLE: {
SUBMIT: 'Submit',
},
},
CONFIRM_EMAIL: 'Verifying...',
SETTINGS: {

View File

@@ -1,6 +1,5 @@
import Cookies from 'js-cookie';
import { IFrameHelper } from '../sdk/IFrameHelper';
import { onBubbleClick } from '../sdk/bubbleHelpers';
const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};
@@ -13,7 +12,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
websiteToken,
toggle() {
onBubbleClick();
IFrameHelper.events.toggleBubble();
},
setUser(identifier, user) {
@@ -39,7 +38,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
reset() {
if (window.$chatwoot.isOpen) {
onBubbleClick();
IFrameHelper.events.toggleBubble();
}
Cookies.remove('cw_conversation');

View File

@@ -88,6 +88,9 @@ export const IFrameHelper = {
toggleBubble: () => {
onBubbleClick();
if (window.$chatwoot.isOpen) {
IFrameHelper.pushEvent('webwidget.triggered');
}
},
},
onLoad: ({ widget_color: widgetColor }) => {

View File

@@ -4,6 +4,8 @@
:key="action.uri"
class="action-button button"
:href="action.uri"
target="_blank"
rel="noopener nofollow noreferrer"
>
{{ action.text }}
</a>
@@ -44,11 +46,14 @@ export default {
@import '~dashboard/assets/scss/mixins.scss';
.action-button {
width: 100%;
padding: 0;
max-height: 34px;
margin-top: $space-smaller;
align-items: center;
border-radius: $space-micro;
display: flex;
font-weight: $font-weight-medium;
justify-content: center;
margin-top: $space-smaller;
max-height: 34px;
padding: 0;
width: 100%;
}
</style>

View File

@@ -52,7 +52,6 @@ export default {
@import '~dashboard/assets/scss/mixins.scss';
.card-message {
@include border-normal;
background: white;
max-width: 220px;
padding: $space-small;

View 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>

View File

@@ -3,7 +3,7 @@
<button class="option-button button" @click="onClick">
<span v-if="isSelected" class="icon ion-checkmark-circled" />
<span v-else class="icon ion-android-radio-button-off" />
<span>{{ action.text }}</span>
<span>{{ action.title }}</span>
</button>
</li>
</template>
@@ -23,7 +23,7 @@ export default {
},
methods: {
onClick() {
// Do postback here
this.$emit('click', this.action);
},
},
};

View File

@@ -4,12 +4,17 @@
<h4 class="title">
{{ title }}
</h4>
<ul class="options" :class="{ 'has-selected': !!selected }">
<ul
v-if="!hideFields"
class="options"
:class="{ 'has-selected': !!selected }"
>
<chat-option
v-for="option in options"
:key="option.id"
:action="option"
:is-selected="isSelected(option)"
@click="onClick"
/>
</ul>
</div>
@@ -36,11 +41,18 @@ export default {
type: String,
default: '',
},
hideFields: {
type: Boolean,
default: false,
},
},
methods: {
isSelected(option) {
return this.selected === option.id;
},
onClick(selectedOption) {
this.$emit('click', selectedOption);
},
},
};
</script>
@@ -59,7 +71,6 @@ export default {
@import '~dashboard/assets/scss/mixins.scss';
.options-message {
@include border-normal;
background: white;
width: 60%;
max-width: 17rem;

View File

@@ -6,5 +6,12 @@ export default {
const messageFormatter = new MessageFormatter(message);
return messageFormatter.formattedMessage;
},
truncateMessage(description = '') {
if (description.length < 100) {
return description;
}
return `${description.slice(0, 97)}...`;
},
},
};

View File

@@ -47,6 +47,8 @@ export default {
window.refererURL = message.refererURL;
} else if (message.event === 'toggle-close-button') {
this.isMobile = message.showClose;
} else if (message.event === 'push-event') {
this.$store.dispatch('events/create', { name: message.eventName });
} else if (message.event === 'set-label') {
this.$store.dispatch('conversationLabels/create', message.label);
} else if (message.event === 'remove-label') {

View File

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

View 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 });
},
};

View File

@@ -2,10 +2,11 @@ import authEndPoint from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
export default {
update: ({ messageId, email }) => {
const urlData = authEndPoint.updateContact(messageId);
update: ({ messageId, email, values }) => {
const urlData = authEndPoint.updateMessage(messageId);
return API.patch(urlData.url, {
contact: { email },
message: { submitted_values: values },
});
},
};

View File

@@ -54,4 +54,8 @@ $button-border-width: 1px;
height: $space-larger;
padding: $space-small $space-medium;
}
&.block {
width: 100%;
}
}

View File

@@ -1,42 +1,49 @@
<template>
<div class="agent-message">
<div class="avatar-wrap">
<thumbnail
v-if="message.showAvatar"
:src="avatarUrl"
size="24px"
:username="agentName"
/>
</div>
<div class="message-wrap">
<AgentMessageBubble
v-if="showTextBubble"
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="message.id"
:message-type="messageType"
:message="message.content"
/>
<div v-if="hasAttachment" class="chat-bubble has-attachment agent">
<file-bubble
v-if="message.attachment && message.attachment.file_type !== 'image'"
:url="message.attachment.data_url"
/>
<image-bubble
v-else
:url="message.attachment.data_url"
:thumb="message.attachment.thumb_url"
:readable-time="readableTime"
<div class="agent-bubble">
<div class="agent-message">
<div class="avatar-wrap">
<thumbnail
v-if="message.showAvatar || hasRecordedResponse"
:src="avatarUrl"
size="24px"
:username="agentName"
/>
</div>
<p v-if="message.showAvatar" class="agent-name">
{{ agentName }}
</p>
<div class="message-wrap">
<AgentMessageBubble
v-if="showTextBubble && shouldDisplayAgentMessage"
:content-type="contentType"
:message-content-attributes="messageContentAttributes"
:message-id="message.id"
:message-type="messageType"
:message="message.content"
/>
<div v-if="hasAttachment" class="chat-bubble has-attachment agent">
<file-bubble
v-if="
message.attachment && message.attachment.file_type !== 'image'
"
:url="message.attachment.data_url"
/>
<image-bubble
v-else
:url="message.attachment.data_url"
:thumb="message.attachment.thumb_url"
:readable-time="readableTime"
/>
</div>
<p v-if="message.showAvatar || hasRecordedResponse" class="agent-name">
{{ agentName }}
</p>
</div>
</div>
<UserMessage v-if="hasRecordedResponse" :message="responseMessage" />
</div>
</template>
<script>
import UserMessage from 'widget/components/UserMessage';
import AgentMessageBubble from 'widget/components/AgentMessageBubble';
import timeMixin from 'dashboard/mixins/time';
import ImageBubble from 'widget/components/ImageBubble';
@@ -48,8 +55,9 @@ export default {
name: 'AgentMessage',
components: {
AgentMessageBubble,
Thumbnail,
ImageBubble,
Thumbnail,
UserMessage,
FileBubble,
},
mixins: [timeMixin],
@@ -60,12 +68,22 @@ export default {
},
},
computed: {
shouldDisplayAgentMessage() {
if (
this.contentType === 'input_select' &&
this.messageContentAttributes.submitted_values &&
!this.message.content
) {
return false;
}
return true;
},
hasAttachment() {
return !!this.message.attachment;
},
showTextBubble() {
const { message } = this;
return !!message.content;
return !message.attachment;
},
readableTime() {
const { created_at: createdAt = '' } = this.message;
@@ -88,25 +106,59 @@ export default {
return 'Bot';
}
return this.message.sender ? this.message.sender.name : '';
return this.message.sender ? this.message.sender.name : 'Bot';
},
avatarUrl() {
// eslint-disable-next-line
const BotImage = require('dashboard/assets/images/chatwoot_bot.png')
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
// eslint-disable-next-line
return require('dashboard/assets/images/chatwoot_bot.png');
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>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
<style lang="scss">
@import '~widget/assets/scss/variables.scss';
.conversation-wrap {
.agent-bubble {
margin-bottom: $space-micro;
& + .agent-bubble {
.agent-message {
.chat-bubble {
border-top-left-radius: $space-smaller;
}
}
}
}
.agent-message {
align-items: flex-end;
display: flex;
@@ -115,6 +167,10 @@ export default {
margin: 0 0 $space-micro $space-small;
max-width: 88%;
& + .user-message {
margin-top: $space-one;
}
.avatar-wrap {
height: $space-medium;
width: $space-medium;
@@ -147,22 +203,3 @@ export default {
}
}
</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>

View File

@@ -1,21 +1,64 @@
<template>
<div class="chat-bubble agent">
<span v-html="formatMessage(message)"></span>
<email-input
v-if="shouldShowInput"
:message-id="messageId"
:message-content-attributes="messageContentAttributes"
/>
<div>
<div
v-if="!isCards && !isOptions && !isForm && !isArticle"
class="chat-bubble agent"
>
<span v-html="formatMessage(message)"></span>
<email-input
v-if="isTemplateEmail"
:message-id="messageId"
:message-content-attributes="messageContentAttributes"
/>
</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>
<script>
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';
export default {
name: 'AgentMessageBubble',
components: {
ChatArticle,
ChatCard,
ChatForm,
ChatOptions,
EmailInput,
},
mixins: [messageFormatterMixin],
@@ -30,8 +73,44 @@ export default {
},
},
computed: {
shouldShowInput() {
return this.contentType === 'input_email' && this.messageType === 3;
isTemplate() {
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,
});
},
},
};

View File

@@ -48,7 +48,7 @@ export default {
padding: $space-small $space-normal;
text-align: left;
a {
> a {
color: $color-primary;
word-break: break-all;
}
@@ -56,7 +56,7 @@ export default {
&.user {
border-bottom-right-radius: $space-smaller;
a {
> a {
color: $color-white;
}
}

View 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>

View File

@@ -21,9 +21,6 @@
<spinner v-else />
</button>
</form>
<span v-else>
<i>{{ messageContentAttributes.submitted_email }}</i>
</span>
</div>
</template>
@@ -71,7 +68,7 @@ export default {
},
methods: {
onSubmit() {
this.$store.dispatch('message/updateContactAttributes', {
this.$store.dispatch('message/update', {
email: this.email,
messageId: this.messageId,
});

View File

@@ -14,6 +14,9 @@ class ActionCableConnector extends BaseActionCableConnector {
}
export const refreshActionCableConnector = pubsubToken => {
if (!pubsubToken) {
return;
}
window.chatwootPubsubToken = pubsubToken;
window.actionCable.disconnect();
window.actionCable = new ActionCableConnector(

View File

@@ -4,5 +4,8 @@ export default {
DOWNLOAD: 'Download',
UPLOADING: 'Uploading...',
},
FORM_BUBBLE: {
SUBMIT: 'Submit',
},
},
};

View File

@@ -5,6 +5,7 @@ import appConfig from 'widget/store/modules/appConfig';
import contacts from 'widget/store/modules/contacts';
import conversation from 'widget/store/modules/conversation';
import conversationLabels from 'widget/store/modules/conversationLabels';
import events from 'widget/store/modules/events';
import message from 'widget/store/modules/message';
Vue.use(Vuex);
@@ -13,9 +14,10 @@ export default new Vuex.Store({
modules: {
agent,
appConfig,
message,
contacts,
conversation,
conversationLabels,
events,
message,
},
});

View File

@@ -188,7 +188,10 @@ export const mutations = {
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes,
content_attributes: {
...($state.conversations[id].content_attributes || {}),
...content_attributes,
},
};
},
};

View 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: {},
};

View File

@@ -1,4 +1,4 @@
import MessageAPI from 'widget/api/message';
import MessageAPI from '../../api/message';
import { refreshActionCableConnector } from '../../helpers/actionCable';
const state = {
@@ -12,19 +12,24 @@ const getters = {
};
const actions = {
updateContactAttributes: async ({ commit }, { email, messageId }) => {
update: async ({ commit }, { email, messageId, submittedValues }) => {
commit('toggleUpdateStatus', true);
try {
const {
data: {
contact: { pubsub_token: pubsubToken },
},
} = await MessageAPI.update({ email, messageId });
data: { contact: { pubsub_token: pubsubToken } = {} },
} = await MessageAPI.update({
email,
messageId,
values: submittedValues,
});
commit(
'conversation/updateMessage',
{
id: messageId,
content_attributes: { submitted_email: email },
content_attributes: {
submitted_email: email,
submitted_values: email ? null : submittedValues,
},
},
{ root: true }
);

View File

@@ -10,4 +10,28 @@ class AgentBotListener < BaseListener
payload = message.webhook_data.merge(event: __method__.to_s)
AgentBotJob.perform_later(agent_bot.outgoing_url, payload)
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

View File

@@ -6,6 +6,30 @@ class WebhookListener < BaseListener
return unless message.reportable?
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
inbox.account.webhooks.account.each do |webhook|
WebhookJob.perform_later(webhook.url, payload)

View File

@@ -51,11 +51,12 @@ class Channel::WebWidget < ApplicationRecord
def create_contact_inbox
ActiveRecord::Base.transaction do
contact = inbox.account.contacts.create!(name: ::Haikunator.haikunate(1000))
::ContactInbox.create!(
contact_inbox = ::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: SecureRandom.uuid
)
contact_inbox
rescue StandardError => e
Rails.logger e
end

View 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

View File

@@ -31,4 +31,19 @@ class ContactInbox < ApplicationRecord
belongs_to :inbox
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

View File

@@ -38,11 +38,21 @@ class Message < ApplicationRecord
validates :account_id, presence: true
validates :inbox_id, presence: true
validates :conversation_id, presence: true
validates_with ContentAttributeValidator
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 }
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
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,
:notify_via_mail
after_update :dispatch_update_event
def channel_token
@token ||= inbox.channel.try(:page_access_token)
end
@@ -88,6 +100,8 @@ class Message < ApplicationRecord
content: content,
created_at: created_at,
message_type: message_type,
content_type: content_type,
content_attributes: content_attributes,
source_id: source_id,
sender: user.try(:webhook_data),
contact: contact.try(:webhook_data),
@@ -107,6 +121,10 @@ class Message < ApplicationRecord
end
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self)
end
def send_reply
channel_name = conversation.inbox.channel.class.to_s
if channel_name == 'Channel::FacebookPage'

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/conversations/partials/conversation.json.jbuilder', conversation: @conversation

View File

@@ -1,8 +1,10 @@
json.id @message.id
json.content @message.content
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.content_type @message.content_type
json.content_attributes @message.content_attributes
json.created_at @message.created_at.to_i
json.private @message.private
json.attachment @message.attachment.push_event_data if @message.attachment

View File

@@ -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.unread_count conversation.unread_incoming_messages.count
json.additional_attributes conversation.additional_attributes
json.account_id conversation.account_id

View File

@@ -1 +1 @@
json.contact @contact
json.contact @contact if @contact