chore: Migrate PubSub Token to contact inbox (#3434)
At present, the websocket pubsub tokens are present at the contact objects in chatwoot. A better approach would be to have these tokens at the contact_inbox object instead. This helps chatwoot to deliver the websocket events targetted to the specific widget connection, stop contact events from leaking into other chat sessions from the same contact. Fixes #1682 Fixes #1664 Co-authored-by: Pranav Raj S <pranav@chatwoot.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
parent
01577acb2e
commit
791d90c6b7
38 changed files with 211 additions and 95 deletions
|
@ -48,11 +48,10 @@ class ContactMergeAction
|
||||||
|
|
||||||
# attributes in base contact are given preference
|
# attributes in base contact are given preference
|
||||||
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
merged_attributes = mergee_contact_attributes.deep_merge(base_contact_attributes)
|
||||||
# retaining old pubsub token to notify the contacts that are listening
|
|
||||||
mergee_pubsub_token = mergee_contact.pubsub_token
|
|
||||||
|
|
||||||
@mergee_contact.destroy!
|
@mergee_contact.destroy!
|
||||||
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact, tokens: [mergee_pubsub_token])
|
Rails.configuration.dispatcher.dispatch(CONTACT_MERGED, Time.zone.now, contact: @base_contact,
|
||||||
|
tokens: [@base_contact.contact_inboxes.filter_map(&:pubsub_token)])
|
||||||
@base_contact.update!(merged_attributes)
|
@base_contact.update!(merged_attributes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,7 +31,7 @@ class RoomChannel < ApplicationCable::Channel
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
@current_user ||= if params[:user_id].blank?
|
@current_user ||= if params[:user_id].blank?
|
||||||
Contact.find_by!(pubsub_token: @pubsub_token)
|
ContactInbox.find_by!(pubsub_token: @pubsub_token).contact
|
||||||
else
|
else
|
||||||
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
User.find_by!(pubsub_token: @pubsub_token, id: params[:user_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -29,21 +29,21 @@ class WidgetsController < ActionController::Base
|
||||||
def set_contact
|
def set_contact
|
||||||
return if @auth_token_params[:source_id].nil?
|
return if @auth_token_params[:source_id].nil?
|
||||||
|
|
||||||
contact_inbox = ::ContactInbox.find_by(
|
@contact_inbox = ::ContactInbox.find_by(
|
||||||
inbox_id: @web_widget.inbox.id,
|
inbox_id: @web_widget.inbox.id,
|
||||||
source_id: @auth_token_params[:source_id]
|
source_id: @auth_token_params[:source_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
@contact = contact_inbox ? contact_inbox.contact : nil
|
@contact = @contact_inbox ? @contact_inbox.contact : nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_contact
|
def build_contact
|
||||||
return if @contact.present?
|
return if @contact.present?
|
||||||
|
|
||||||
contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
@contact_inbox = @web_widget.create_contact_inbox(additional_attributes)
|
||||||
@contact = contact_inbox.contact
|
@contact = @contact_inbox.contact
|
||||||
|
|
||||||
payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
payload = { source_id: @contact_inbox.source_id, inbox_id: @web_widget.inbox.id }
|
||||||
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
@token = ::Widget::TokenService.new(payload: payload).generate_token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -121,7 +121,7 @@ class ConversationFinder
|
||||||
|
|
||||||
def conversations
|
def conversations
|
||||||
@conversations = @conversations.includes(
|
@conversations = @conversations.includes(
|
||||||
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team
|
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
|
||||||
)
|
)
|
||||||
@conversations.latest.page(current_page)
|
@conversations.latest.page(current_page)
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,10 @@
|
||||||
margin-right: var(--space-small);
|
margin-right: var(--space-small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.margin-right-smaller {
|
||||||
|
margin-right: var(--space-smaller);
|
||||||
|
}
|
||||||
|
|
||||||
.fs-small {
|
.fs-small {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
}
|
}
|
||||||
|
@ -42,3 +46,7 @@
|
||||||
.bg-white {
|
.bg-white {
|
||||||
background-color: var(--white);
|
background-color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-y-800 {
|
||||||
|
color: var(--y-800);
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,12 @@
|
||||||
/>
|
/>
|
||||||
<div class="user--profile__meta">
|
<div class="user--profile__meta">
|
||||||
<h3 class="user--name text-truncate">
|
<h3 class="user--name text-truncate">
|
||||||
{{ currentContact.name }}
|
<span class="margin-right-smaller">{{ currentContact.name }}</span>
|
||||||
|
<i
|
||||||
|
v-if="!isHMACVerified"
|
||||||
|
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||||
|
class="ion-android-alert text-y-800 fs-default"
|
||||||
|
/>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="conversation--header--actions">
|
<div class="conversation--header--actions">
|
||||||
<inbox-name :inbox="inbox" class="margin-right-small" />
|
<inbox-name :inbox="inbox" class="margin-right-small" />
|
||||||
|
@ -73,11 +78,15 @@ export default {
|
||||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
chatMetadata() {
|
chatMetadata() {
|
||||||
return this.chat.meta;
|
return this.chat.meta;
|
||||||
},
|
},
|
||||||
|
isHMACVerified() {
|
||||||
|
if (!this.isAWebWidgetInbox) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.chatMetadata.hmac_verified;
|
||||||
|
},
|
||||||
currentContact() {
|
currentContact() {
|
||||||
return this.$store.getters['contacts/getContact'](
|
return this.$store.getters['contacts/getContact'](
|
||||||
this.chat.meta.sender.id
|
this.chat.meta.sender.id
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"CONVERSATION": {
|
"CONVERSATION": {
|
||||||
"404": "Please select a conversation from left pane",
|
"404": "Please select a conversation from left pane",
|
||||||
|
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||||
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
||||||
"NO_MESSAGE_2": " to send a message to your page!",
|
"NO_MESSAGE_2": " to send a message to your page!",
|
||||||
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.",
|
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.",
|
||||||
|
|
|
@ -41,17 +41,17 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" v-if="isAnEmailInbox">
|
<div v-if="isAnEmailInbox" class="row">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<label :class="{ error: $v.message.$error }">
|
<label :class="{ error: $v.subject.$error }">
|
||||||
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }}
|
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }}
|
||||||
<input
|
<input
|
||||||
v-model="subject"
|
v-model="subject"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="$t('NEW_CONVERSATION.FORM.SUBJECT.PLACEHOLDER')"
|
:placeholder="$t('NEW_CONVERSATION.FORM.SUBJECT.PLACEHOLDER')"
|
||||||
@input="$v.message.$touch"
|
@input="$v.subject.$touch"
|
||||||
/>
|
/>
|
||||||
<span v-if="$v.message.$error" class="message">
|
<span v-if="$v.subject.$error" class="message">
|
||||||
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }}
|
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
@ -93,7 +93,7 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||||
import alertMixin from 'shared/mixins/alertMixin';
|
import alertMixin from 'shared/mixins/alertMixin';
|
||||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||||
import { required } from 'vuelidate/lib/validators';
|
import { required, requiredIf } from 'vuelidate/lib/validators';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
@ -120,7 +120,7 @@ export default {
|
||||||
},
|
},
|
||||||
validations: {
|
validations: {
|
||||||
subject: {
|
subject: {
|
||||||
required,
|
required: requiredIf('isAnEmailInbox'),
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
required,
|
required,
|
||||||
|
|
|
@ -15,18 +15,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static refreshConnector = pubsubToken => {
|
|
||||||
if (!pubsubToken || window.chatwootPubsubToken === pubsubToken) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.chatwootPubsubToken = pubsubToken;
|
|
||||||
window.actionCable.disconnect();
|
|
||||||
window.actionCable = new ActionCableConnector(
|
|
||||||
window.WOOT_WIDGET,
|
|
||||||
window.chatwootPubsubToken
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
onStatusChange = data => {
|
onStatusChange = data => {
|
||||||
this.app.$store.dispatch('conversationAttributes/update', data);
|
this.app.$store.dispatch('conversationAttributes/update', data);
|
||||||
};
|
};
|
||||||
|
@ -57,7 +45,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||||
|
|
||||||
onTypingOn = data => {
|
onTypingOn = data => {
|
||||||
if (data.is_private) {
|
if (data.is_private) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
this.clearTimer();
|
this.clearTimer();
|
||||||
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
||||||
|
@ -88,7 +76,4 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const refreshActionCableConnector =
|
|
||||||
ActionCableConnector.refreshConnector;
|
|
||||||
|
|
||||||
export default ActionCableConnector;
|
export default ActionCableConnector;
|
||||||
|
|
|
@ -21,10 +21,16 @@ export const filterCampaigns = ({
|
||||||
currentURL,
|
currentURL,
|
||||||
isInBusinessHours,
|
isInBusinessHours,
|
||||||
}) => {
|
}) => {
|
||||||
return campaigns.filter(item =>
|
return campaigns.filter(campaign => {
|
||||||
item.triggerOnlyDuringBusinessHours
|
const hasMatchingURL =
|
||||||
? isInBusinessHours
|
stripTrailingSlash({ URL: campaign.url }) ===
|
||||||
: stripTrailingSlash({ URL: item.url }) ===
|
stripTrailingSlash({ URL: currentURL });
|
||||||
stripTrailingSlash({ URL: currentURL })
|
if (!hasMatchingURL) {
|
||||||
);
|
return false;
|
||||||
|
}
|
||||||
|
if (campaign.triggerOnlyDuringBusinessHours) {
|
||||||
|
return isInBusinessHours;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -44,11 +44,13 @@ describe('#Campaigns Helper', () => {
|
||||||
id: 1,
|
id: 1,
|
||||||
timeOnPage: 3,
|
timeOnPage: 3,
|
||||||
url: 'https://www.chatwoot.com/pricing',
|
url: 'https://www.chatwoot.com/pricing',
|
||||||
|
triggerOnlyDuringBusinessHours: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
timeOnPage: 6,
|
timeOnPage: 6,
|
||||||
url: 'https://www.chatwoot.com/about',
|
url: 'https://www.chatwoot.com/about',
|
||||||
|
triggerOnlyDuringBusinessHours: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
currentURL: 'https://www.chatwoot.com/about/',
|
currentURL: 'https://www.chatwoot.com/about/',
|
||||||
|
@ -58,8 +60,60 @@ describe('#Campaigns Helper', () => {
|
||||||
id: 2,
|
id: 2,
|
||||||
timeOnPage: 6,
|
timeOnPage: 6,
|
||||||
url: 'https://www.chatwoot.com/about',
|
url: 'https://www.chatwoot.com/about',
|
||||||
|
triggerOnlyDuringBusinessHours: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
it('should return filtered campaigns if formatted campaigns are passed and business hours enabled', () => {
|
||||||
|
expect(
|
||||||
|
filterCampaigns({
|
||||||
|
campaigns: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
timeOnPage: 3,
|
||||||
|
url: 'https://www.chatwoot.com/pricing',
|
||||||
|
triggerOnlyDuringBusinessHours: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
timeOnPage: 6,
|
||||||
|
url: 'https://www.chatwoot.com/about',
|
||||||
|
triggerOnlyDuringBusinessHours: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentURL: 'https://www.chatwoot.com/about/',
|
||||||
|
isInBusinessHours: true,
|
||||||
|
})
|
||||||
|
).toStrictEqual([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
timeOnPage: 6,
|
||||||
|
url: 'https://www.chatwoot.com/about',
|
||||||
|
triggerOnlyDuringBusinessHours: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('should return empty campaigns if formatted campaigns are passed and business hours disabled', () => {
|
||||||
|
expect(
|
||||||
|
filterCampaigns({
|
||||||
|
campaigns: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
timeOnPage: 3,
|
||||||
|
url: 'https://www.chatwoot.com/pricing',
|
||||||
|
triggerOnlyDuringBusinessHours: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
timeOnPage: 6,
|
||||||
|
url: 'https://www.chatwoot.com/about',
|
||||||
|
triggerOnlyDuringBusinessHours: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
currentURL: 'https://www.chatwoot.com/about/',
|
||||||
|
isInBusinessHours: false,
|
||||||
|
})
|
||||||
|
).toStrictEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import ContactsAPI from '../../api/contacts';
|
import ContactsAPI from '../../api/contacts';
|
||||||
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
currentUser: {},
|
currentUser: {},
|
||||||
|
@ -31,17 +30,13 @@ export const actions = {
|
||||||
identifier_hash: userObject.identifier_hash,
|
identifier_hash: userObject.identifier_hash,
|
||||||
phone_number: userObject.phone_number,
|
phone_number: userObject.phone_number,
|
||||||
};
|
};
|
||||||
const {
|
await ContactsAPI.update(identifier, user);
|
||||||
data: { pubsub_token: pubsubToken },
|
|
||||||
} = await ContactsAPI.update(identifier, user);
|
|
||||||
|
|
||||||
dispatch('get');
|
dispatch('get');
|
||||||
if (userObject.identifier_hash) {
|
if (userObject.identifier_hash) {
|
||||||
dispatch('conversation/clearConversations', {}, { root: true });
|
dispatch('conversation/clearConversations', {}, { root: true });
|
||||||
dispatch('conversation/fetchOldConversations', {}, { root: true });
|
dispatch('conversation/fetchOldConversations', {}, { root: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshActionCableConnector(pubsubToken);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
toggleTyping,
|
toggleTyping,
|
||||||
setUserLastSeenAt,
|
setUserLastSeenAt,
|
||||||
} from 'widget/api/conversation';
|
} from 'widget/api/conversation';
|
||||||
import { refreshActionCableConnector } from '../../../helpers/actionCable';
|
|
||||||
|
|
||||||
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
|
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
|
||||||
|
|
||||||
|
@ -15,13 +14,9 @@ export const actions = {
|
||||||
commit('setConversationUIFlag', { isCreating: true });
|
commit('setConversationUIFlag', { isCreating: true });
|
||||||
try {
|
try {
|
||||||
const { data } = await createConversationAPI(params);
|
const { data } = await createConversationAPI(params);
|
||||||
const {
|
const { messages } = data;
|
||||||
contact: { pubsub_token: pubsubToken },
|
|
||||||
messages,
|
|
||||||
} = data;
|
|
||||||
const [message = {}] = messages;
|
const [message = {}] = messages;
|
||||||
commit('pushMessageToConversation', message);
|
commit('pushMessageToConversation', message);
|
||||||
refreshActionCableConnector(pubsubToken);
|
|
||||||
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
dispatch('conversationAttributes/getAttributes', {}, { root: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import MessageAPI from '../../api/message';
|
import MessageAPI from '../../api/message';
|
||||||
import { refreshActionCableConnector } from '../../helpers/actionCable';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
uiFlags: {
|
uiFlags: {
|
||||||
|
@ -18,9 +17,7 @@ export const actions = {
|
||||||
) => {
|
) => {
|
||||||
commit('toggleUpdateStatus', true);
|
commit('toggleUpdateStatus', true);
|
||||||
try {
|
try {
|
||||||
const {
|
await MessageAPI.update({
|
||||||
data: { contact: { pubsub_token: pubsubToken } = {} },
|
|
||||||
} = await MessageAPI.update({
|
|
||||||
email,
|
email,
|
||||||
messageId,
|
messageId,
|
||||||
values: submittedValues,
|
values: submittedValues,
|
||||||
|
@ -37,7 +34,6 @@ export const actions = {
|
||||||
{ root: true }
|
{ root: true }
|
||||||
);
|
);
|
||||||
dispatch('contacts/get', {}, { root: true });
|
dispatch('contacts/get', {}, { root: true });
|
||||||
refreshActionCableConnector(pubsubToken);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ class ActionCableListener < BaseListener
|
||||||
def message_created(event)
|
def message_created(event)
|
||||||
message, account = extract_message_and_account(event)
|
message, account = extract_message_and_account(event)
|
||||||
conversation = message.conversation
|
conversation = message.conversation
|
||||||
tokens = user_tokens(account, conversation.inbox.members) + contact_token(conversation.contact, message)
|
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
|
||||||
|
|
||||||
broadcast(account, tokens, MESSAGE_CREATED, message.push_event_data)
|
broadcast(account, tokens, MESSAGE_CREATED, message.push_event_data)
|
||||||
end
|
end
|
||||||
|
@ -27,7 +27,7 @@ class ActionCableListener < BaseListener
|
||||||
message, account = extract_message_and_account(event)
|
message, account = extract_message_and_account(event)
|
||||||
conversation = message.conversation
|
conversation = message.conversation
|
||||||
contact = conversation.contact
|
contact = conversation.contact
|
||||||
tokens = user_tokens(account, conversation.inbox.members) + contact_token(conversation.contact, message)
|
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
|
||||||
|
|
||||||
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data)
|
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data)
|
||||||
end
|
end
|
||||||
|
@ -132,12 +132,14 @@ class ActionCableListener < BaseListener
|
||||||
(agent_tokens + admin_tokens).uniq
|
(agent_tokens + admin_tokens).uniq
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_token(contact, message)
|
def contact_tokens(contact_inbox, message)
|
||||||
return [] if message.private?
|
return [] if message.private?
|
||||||
return [] if message.activity?
|
return [] if message.activity?
|
||||||
return [] if contact.nil?
|
return [] if contact_inbox.nil?
|
||||||
|
|
||||||
[contact.pubsub_token]
|
contact = contact_inbox.contact
|
||||||
|
|
||||||
|
contact_inbox.hmac_verified? ? contact.contact_inboxes.where(hmac_verified: true).filter_map(&:pubsub_token) : [contact_inbox.pubsub_token]
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast(account, tokens, event_name, data)
|
def broadcast(account, tokens, event_name, data)
|
||||||
|
|
|
@ -7,4 +7,10 @@ module Pubsubable
|
||||||
# Used by the actionCable/PubSub Service we use for real time communications
|
# Used by the actionCable/PubSub Service we use for real time communications
|
||||||
has_secure_token :pubsub_token
|
has_secure_token :pubsub_token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def pubsub_token
|
||||||
|
# backfills tokens for existing records
|
||||||
|
regenerate_pubsub_token if self[:pubsub_token].blank?
|
||||||
|
self[:pubsub_token]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
class Contact < ApplicationRecord
|
class Contact < ApplicationRecord
|
||||||
include Pubsubable
|
# TODO: remove the pubsub_token attribute from this model in future.
|
||||||
include Avatarable
|
include Avatarable
|
||||||
include AvailabilityStatusable
|
include AvailabilityStatusable
|
||||||
include Labelable
|
include Labelable
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# hmac_verified :boolean default(FALSE)
|
# hmac_verified :boolean default(FALSE)
|
||||||
|
# pubsub_token :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# contact_id :bigint
|
# contact_id :bigint
|
||||||
|
@ -15,6 +16,7 @@
|
||||||
# index_contact_inboxes_on_contact_id (contact_id)
|
# index_contact_inboxes_on_contact_id (contact_id)
|
||||||
# index_contact_inboxes_on_inbox_id (inbox_id)
|
# index_contact_inboxes_on_inbox_id (inbox_id)
|
||||||
# index_contact_inboxes_on_inbox_id_and_source_id (inbox_id,source_id) UNIQUE
|
# index_contact_inboxes_on_inbox_id_and_source_id (inbox_id,source_id) UNIQUE
|
||||||
|
# index_contact_inboxes_on_pubsub_token (pubsub_token) UNIQUE
|
||||||
# index_contact_inboxes_on_source_id (source_id)
|
# index_contact_inboxes_on_source_id (source_id)
|
||||||
#
|
#
|
||||||
# Foreign Keys
|
# Foreign Keys
|
||||||
|
@ -24,6 +26,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
class ContactInbox < ApplicationRecord
|
class ContactInbox < ApplicationRecord
|
||||||
|
include Pubsubable
|
||||||
validates :inbox_id, presence: true
|
validates :inbox_id, presence: true
|
||||||
validates :contact_id, presence: true
|
validates :contact_id, presence: true
|
||||||
validates :source_id, presence: true
|
validates :source_id, presence: true
|
||||||
|
|
|
@ -23,7 +23,11 @@ class Conversations::EventDataPresenter < SimpleDelegator
|
||||||
end
|
end
|
||||||
|
|
||||||
def push_meta
|
def push_meta
|
||||||
{ sender: contact.push_event_data, assignee: assignee&.push_event_data }
|
{
|
||||||
|
sender: contact.push_event_data,
|
||||||
|
assignee: assignee&.push_event_data,
|
||||||
|
hmac_verified: contact_inbox&.hmac_verified
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def push_timestamps
|
def push_timestamps
|
||||||
|
|
|
@ -13,6 +13,7 @@ json.meta do
|
||||||
json.partial! 'api/v1/models/team.json.jbuilder', resource: conversation.team
|
json.partial! 'api/v1/models/team.json.jbuilder', resource: conversation.team
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
json.hmac_verified conversation.contact_inbox&.hmac_verified
|
||||||
end
|
end
|
||||||
|
|
||||||
json.id conversation.display_id
|
json.id conversation.display_id
|
||||||
|
|
|
@ -22,7 +22,7 @@ json.chatwoot_widget_defaults do
|
||||||
json.use_inbox_avatar_for_bot ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false))
|
json.use_inbox_avatar_for_bot ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false))
|
||||||
end
|
end
|
||||||
json.contact do
|
json.contact do
|
||||||
json.pubsub_token @contact.pubsub_token
|
json.pubsub_token @contact_inbox.pubsub_token
|
||||||
end
|
end
|
||||||
json.auth_token @token
|
json.auth_token @token
|
||||||
json.global_config @global_config
|
json.global_config @global_config
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
json.source_id @contact_inbox.source_id
|
json.source_id @contact_inbox.source_id
|
||||||
|
json.pubsub_token @contact_inbox.pubsub_token
|
||||||
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
json.source_id @contact_inbox.source_id
|
json.source_id @contact_inbox.source_id
|
||||||
|
json.pubsub_token @contact_inbox.pubsub_token
|
||||||
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
json.source_id @contact_inbox.source_id
|
json.source_id @contact_inbox.source_id
|
||||||
|
json.pubsub_token @contact_inbox.pubsub_token
|
||||||
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
json.partial! 'public/api/v1/models/contact.json.jbuilder', resource: @contact_inbox.contact
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.name resource.name
|
json.name resource.name
|
||||||
json.email resource.email
|
json.email resource.email
|
||||||
json.pubsub_token resource.pubsub_token
|
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
window.chatwootWidgetDefaults = {
|
window.chatwootWidgetDefaults = {
|
||||||
useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>,
|
useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>,
|
||||||
}
|
}
|
||||||
window.chatwootPubsubToken = '<%= @contact.pubsub_token %>'
|
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
|
||||||
window.authToken = '<%= @token %>'
|
window.authToken = '<%= @token %>'
|
||||||
window.globalConfig = <%= raw @global_config.to_json %>
|
window.globalConfig = <%= raw @global_config.to_json %>
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
shared: &shared
|
shared: &shared
|
||||||
version: '1.22.0'
|
version: '1.22.1'
|
||||||
|
|
||||||
development:
|
development:
|
||||||
<<: *shared
|
<<: *shared
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AddPubSubTokenToContactInbox < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :contact_inboxes, :pubsub_token, :string
|
||||||
|
add_index :contact_inboxes, :pubsub_token, unique: true
|
||||||
|
end
|
||||||
|
end
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2021_09_29_150415) do
|
ActiveRecord::Schema.define(version: 2021_11_22_061012) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
|
@ -273,9 +273,11 @@ ActiveRecord::Schema.define(version: 2021_09_29_150415) do
|
||||||
t.datetime "created_at", precision: 6, null: false
|
t.datetime "created_at", precision: 6, null: false
|
||||||
t.datetime "updated_at", precision: 6, null: false
|
t.datetime "updated_at", precision: 6, null: false
|
||||||
t.boolean "hmac_verified", default: false
|
t.boolean "hmac_verified", default: false
|
||||||
|
t.string "pubsub_token"
|
||||||
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
|
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
|
||||||
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
|
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
|
||||||
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
|
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
|
||||||
|
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
|
||||||
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
|
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@chatwoot/chatwoot",
|
"name": "@chatwoot/chatwoot",
|
||||||
"version": "1.22.0",
|
"version": "1.22.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"eslint": "eslint app/javascript --fix",
|
"eslint": "eslint app/javascript --fix",
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe RoomChannel, type: :channel do
|
RSpec.describe RoomChannel, type: :channel do
|
||||||
let!(:contact) { create(:contact) }
|
let!(:contact_inbox) { create(:contact_inbox) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_connection
|
stub_connection
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'subscribes to a stream when pubsub_token is provided' do
|
it 'subscribes to a stream when pubsub_token is provided' do
|
||||||
subscribe(pubsub_token: contact.pubsub_token)
|
subscribe(pubsub_token: contact_inbox.pubsub_token)
|
||||||
expect(subscription).to be_confirmed
|
expect(subscription).to be_confirmed
|
||||||
expect(subscription).to have_stream_for(contact.pubsub_token)
|
expect(subscription).to have_stream_for(contact_inbox.pubsub_token)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -45,7 +45,7 @@ RSpec.describe '/api/v1/widget/config', type: :request do
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
response_data = JSON.parse(response.body)
|
response_data = JSON.parse(response.body)
|
||||||
expect(response_data.keys).to include(*response_keys)
|
expect(response_data.keys).to include(*response_keys)
|
||||||
expect(response_data['contact']['pubsub_token']).to eq(contact.pubsub_token)
|
expect(response_data['contact']['pubsub_token']).to eq(contact_inbox.pubsub_token)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ RSpec.describe 'Public Inbox Contacts API', type: :request do
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
data = JSON.parse(response.body)
|
data = JSON.parse(response.body)
|
||||||
expect(data['source_id']).to eq contact_inbox.source_id
|
expect(data['source_id']).to eq contact_inbox.source_id
|
||||||
expect(data['pubsub_token']).to eq contact.pubsub_token
|
expect(data['pubsub_token']).to eq contact_inbox.pubsub_token
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,23 @@ describe ActionCableListener do
|
||||||
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
||||||
|
|
||||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||||
[agent.pubsub_token, admin.pubsub_token, conversation.contact.pubsub_token],
|
[agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token],
|
||||||
|
'message.created',
|
||||||
|
message.push_event_data.merge(account_id: account.id)
|
||||||
|
)
|
||||||
|
listener.message_created(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends message to all hmac verified contact inboxes' do
|
||||||
|
# HACK: to reload conversation inbox members
|
||||||
|
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
||||||
|
conversation.contact_inbox.update(hmac_verified: true)
|
||||||
|
# creating a non verified contact inbox to ensure the events are not sent to it
|
||||||
|
create(:contact_inbox, contact: conversation.contact, inbox: inbox)
|
||||||
|
verified_contact_inbox = create(:contact_inbox, contact: conversation.contact, inbox: inbox, hmac_verified: true)
|
||||||
|
|
||||||
|
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||||
|
[agent.pubsub_token, admin.pubsub_token, conversation.contact_inbox.pubsub_token, verified_contact_inbox.pubsub_token],
|
||||||
'message.created',
|
'message.created',
|
||||||
message.push_event_data.merge(account_id: account.id)
|
message.push_event_data.merge(account_id: account.id)
|
||||||
)
|
)
|
||||||
|
|
40
spec/models/contact_inbox_spec.rb
Normal file
40
spec/models/contact_inbox_spec.rb
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ContactInbox do
|
||||||
|
describe 'pubsub_token' do
|
||||||
|
let(:contact_inbox) { create(:contact_inbox) }
|
||||||
|
|
||||||
|
it 'gets created on object create' do
|
||||||
|
obj = contact_inbox
|
||||||
|
expect(obj.pubsub_token).not_to eq(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not get updated on object update' do
|
||||||
|
obj = contact_inbox
|
||||||
|
old_token = obj.pubsub_token
|
||||||
|
obj.update(source_id: '234234323')
|
||||||
|
expect(obj.pubsub_token).to eq(old_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'backfills pubsub_token on call for older objects' do
|
||||||
|
obj = create(:contact_inbox)
|
||||||
|
# to replicate an object with out pubsub_token
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
obj.update_column(:pubsub_token, nil)
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
obj.reload
|
||||||
|
|
||||||
|
# ensure the column is nil in database
|
||||||
|
results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;')
|
||||||
|
expect(results.first['pubsub_token']).to eq(nil)
|
||||||
|
|
||||||
|
new_token = obj.pubsub_token
|
||||||
|
obj.update(source_id: '234234323')
|
||||||
|
# the generated token shoul be persisted in db
|
||||||
|
expect(obj.pubsub_token).to eq(new_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,20 +11,4 @@ RSpec.describe Contact do
|
||||||
it { is_expected.to belong_to(:account) }
|
it { is_expected.to belong_to(:account) }
|
||||||
it { is_expected.to have_many(:conversations).dependent(:destroy) }
|
it { is_expected.to have_many(:conversations).dependent(:destroy) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'pubsub_token' do
|
|
||||||
let(:user) { create(:user) }
|
|
||||||
|
|
||||||
it 'gets created on object create' do
|
|
||||||
obj = user
|
|
||||||
expect(obj.pubsub_token).not_to eq(nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not get updated on object update' do
|
|
||||||
obj = user
|
|
||||||
old_token = obj.pubsub_token
|
|
||||||
obj.update(name: 'test')
|
|
||||||
expect(obj.pubsub_token).to eq(old_token)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -375,7 +375,8 @@ RSpec.describe Conversation, type: :model do
|
||||||
additional_attributes: {},
|
additional_attributes: {},
|
||||||
meta: {
|
meta: {
|
||||||
sender: conversation.contact.push_event_data,
|
sender: conversation.contact.push_event_data,
|
||||||
assignee: conversation.assignee
|
assignee: conversation.assignee,
|
||||||
|
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||||
},
|
},
|
||||||
id: conversation.display_id,
|
id: conversation.display_id,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
|
@ -12,7 +12,8 @@ RSpec.describe Conversations::EventDataPresenter do
|
||||||
additional_attributes: {},
|
additional_attributes: {},
|
||||||
meta: {
|
meta: {
|
||||||
sender: conversation.contact.push_event_data,
|
sender: conversation.contact.push_event_data,
|
||||||
assignee: conversation.assignee
|
assignee: conversation.assignee,
|
||||||
|
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||||
},
|
},
|
||||||
id: conversation.display_id,
|
id: conversation.display_id,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
Loading…
Reference in a new issue