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

View File

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

View File

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

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 end
def update def update
@message.update!(input_submitted_email: contact_email) if @message.content_type == 'input_email'
update_contact(contact_email) @message.update!(submitted_email: 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

View File

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

View File

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

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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');

View File

@@ -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 }) => {

View File

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

View File

@@ -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;

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

View File

@@ -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;

View File

@@ -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)}...`;
},
}, },
}; };

View File

@@ -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') {

View File

@@ -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,
}; };

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

View File

@@ -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%;
}
} }

View File

@@ -1,42 +1,49 @@
<template> <template>
<div class="agent-message"> <div class="agent-bubble">
<div class="avatar-wrap"> <div class="agent-message">
<thumbnail <div class="avatar-wrap">
v-if="message.showAvatar" <thumbnail
:src="avatarUrl" v-if="message.showAvatar || hasRecordedResponse"
size="24px" :src="avatarUrl"
:username="agentName" 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> </div>
<p v-if="message.showAvatar" class="agent-name"> <div class="message-wrap">
{{ agentName }} <AgentMessageBubble
</p> 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> </div>
<UserMessage v-if="hasRecordedResponse" :message="responseMessage" />
</div> </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() {
// eslint-disable-next-line
const BotImage = require('dashboard/assets/images/chatwoot_bot.png')
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) { if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
// eslint-disable-next-line return BotImage;
return require('dashboard/assets/images/chatwoot_bot.png');
} }
return this.message.sender ? this.message.sender.avatar_url : ''; return this.message.sender ? this.message.sender.avatar_url : 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>

View File

@@ -1,21 +1,64 @@
<template> <template>
<div class="chat-bubble agent"> <div>
<span v-html="formatMessage(message)"></span> <div
<email-input v-if="!isCards && !isOptions && !isForm && !isArticle"
v-if="shouldShowInput" class="chat-bubble agent"
:message-id="messageId" >
:message-content-attributes="messageContentAttributes" <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> </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,
});
}, },
}, },
}; };

View File

@@ -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;
} }
} }

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

View File

@@ -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(

View File

@@ -4,5 +4,8 @@ export default {
DOWNLOAD: 'Download', DOWNLOAD: 'Download',
UPLOADING: 'Uploading...', 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 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,
}, },
}); });

View File

@@ -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,
},
}; };
}, },
}; };

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'; 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 }
); );

View File

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

View File

@@ -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)

View File

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

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

View File

@@ -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'

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

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

View File

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

View File

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

View File

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

View File

@@ -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
}
]
},
],
}
```

View File

@@ -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'

View File

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

View 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

View File

@@ -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 }

View 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

View 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

View File

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

View File

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

View File

@@ -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"]

View File

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

View File

@@ -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:

View File

@@ -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"
} }
} }
} }