Compare commits

...

19 commits

Author SHA1 Message Date
fayazara
228167823c Fix intendation 2022-12-12 23:21:04 +05:30
fayazara
c618064b13 Fix intendation 2022-12-12 23:21:04 +05:30
fayazara
d23b4a45ad Update commandbar with the builder 2022-12-12 23:21:04 +05:30
Tejaswini Chile
4af9d00d6f message json fix 2022-12-12 23:21:04 +05:30
Sivin Varghese
e8bd066f93 fix: Unable to save automation "send email to team" (#6052)
* fix: Unable to save automation "send email to team"

* chore: Minor fixes
2022-12-12 23:21:04 +05:30
Pranav Raj S
4dc691e29c feat: Allow wildcard URL in the campaigns (#6056) 2022-12-12 23:21:04 +05:30
Tejaswini Chile
6c07ab8bb6 fix: issue with current account not being present for some user 2022-12-12 23:21:04 +05:30
Tejaswini Chile
a98bffbe2b fix: migration for rebuilding multi model search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
4b674a167a fix: specs 2022-12-12 23:21:04 +05:30
Tejaswini Chile
bd4b051460 fix: JSON format 2022-12-12 23:21:04 +05:30
Tejaswini Chile
963980d13b fix: JSON format 2022-12-12 23:21:04 +05:30
Tejaswini Chile
c498668345 fix: new endpoint for the text search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
cb388d93e6 fix: new endpoint for the text search 2022-12-12 23:21:04 +05:30
Tejaswini Chile
7ba0666cb1 fix: search improvements for multiple model with separate results 2022-12-12 23:21:04 +05:30
Tejaswini Chile
75b7f8f0c4 feat: Search improvements 2022-12-12 23:21:04 +05:30
Pranav Raj S
9b8f0e0152 chore: Update analytics events (#6050) 2022-12-12 23:21:04 +05:30
Tejaswini Chile
92ea452680 fix: Automatically remove expired story mention (#5300)
When a user mentions the connected Instagram page in a story, the story's content is downloaded in Chatwoot, then if the user deletes the story, the content persists in the platform.

fixes: #5258
2022-12-12 23:21:04 +05:30
dependabot[bot]
65de030244 chore(deps): bump nokogiri from 1.13.9 to 1.13.10 (#6040)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.9 to 1.13.10.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.9...v1.13.10)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 23:21:04 +05:30
fayazara
54eab87806 test adding child keys 2022-12-08 15:48:25 +05:30
57 changed files with 1716 additions and 144 deletions

View file

@ -427,14 +427,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.9)
nokogiri (1.13.10)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.9-arm64-darwin)
nokogiri (1.13.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.9-x86_64-darwin)
nokogiri (1.13.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.9-x86_64-linux)
nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@ -459,7 +459,7 @@ GEM
pundit (2.2.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.0)
racc (1.6.1)
rack (2.2.4)
rack-attack (6.6.1)
rack (>= 1.0, < 3)

View file

@ -46,6 +46,7 @@ class Messages::Messenger::MessageBuilder
end
def update_attachment_file_type(attachment)
return if @message.reload.attachments.blank?
return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
attachment.file_type = file_type(attachment.file&.content_type)
@ -62,6 +63,7 @@ class Messages::Messenger::MessageBuilder
story_sender = result['from']['username']
message.content_attributes[:story_sender] = story_sender
message.content_attributes[:story_id] = story_id
message.content_attributes[:image_type] = 'story_mention'
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
message.save!
end
@ -74,6 +76,7 @@ class Messages::Messenger::MessageBuilder
raise
rescue Koala::Facebook::ClientError => e
# The exception occurs when we are trying fetch the deleted story or blocked story.
@message.attachments.destroy_all
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
Rails.logger.error e
{}

View file

@ -2,8 +2,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
before_action :conversation, except: [:index, :meta, :search, :create, :filter, :text_search]
before_action :inbox, :contact, :contact_inbox, :text_search, only: [:create]
def index
result = conversation_finder.perform
@ -11,6 +11,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
end
def text_search
@result = TextSearch.new(Current.user, params).perform
end
def meta
result = conversation_finder.perform
@conversations_count = result[:count]

View file

@ -24,7 +24,6 @@ class DashboardController < ActionController::Base
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL',
'ANALYTICS_TOKEN',
'ANALYTICS_HOST',
'DIRECT_UPLOADS_ENABLED',
'HCAPTCHA_SITE_KEY',
'LOGOUT_REDIRECT_LINK',

View file

@ -0,0 +1,36 @@
class TextSearch
attr_reader :current_user, :current_account, :params
DEFAULT_STATUS = 'open'.freeze
def initialize(current_user, params)
@current_account = current_user.account || Current.account
@params = params
end
def perform
{
messages: filter_messages,
conversations: filter_conversations,
contacts: filter_contacts
}
end
private
def filter_conversations
conversation_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account,
searchable_type: 'Conversation').pluck(:searchable_id)
@conversations = Conversation.where(id: conversation_ids)
end
def filter_messages
message_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Message').pluck(:searchable_id)
@messages = Message.where(id: message_ids)
end
def filter_contacts
contact_ids = PgSearch.multisearch("#{@params[:q]}%").where(account_id: @current_account, searchable_type: 'Contact').pluck(:searchable_id)
@contacts = Contact.where(id: contact_ids)
end
end

View file

@ -45,6 +45,12 @@ class ConversationApi extends ApiClient {
});
}
textSearch(query) {
return axios.post(`${this.url}/text_search`, null, {
params: { query },
});
}
toggleStatus({ conversationId, status, snoozedUntil = null }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status,

View file

@ -47,6 +47,9 @@ import {
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
@ -268,6 +271,7 @@ export default {
);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
AnalyticsHelper.track(ANALYTICS_EVENTS.USED_MENTIONS);
return false;
},
@ -297,6 +301,7 @@ export default {
this.emitOnChange();
tr.scrollIntoView();
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},

View file

@ -161,6 +161,9 @@ import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
@ -698,6 +701,7 @@ export default {
},
replaceText(message) {
setTimeout(() => {
AnalyticsHelper.track(ANALYTICS_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = message;
}, 100);
},

View file

@ -54,19 +54,6 @@
size="16"
/>
</button>
<a
v-if="hasInstagramStory && (isIncoming || isOutgoing) && linkToStory"
:href="linkToStory"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.LINK_TO_STORY')"
icon="open"
class="action--icon cursor-pointer"
size="16"
/>
</a>
<a
v-if="isATweet && (isOutgoing || isIncoming) && linkToTweet"
:href="linkToTweet"

View file

@ -0,0 +1,9 @@
export const EXECUTED_A_MACRO = 'Executed a macro';
export const SENT_MESSAGE = 'Sent a message';
export const SENT_PRIVATE_NOTE = 'Sent a private note';
export const INSERTED_A_CANNED_RESPONSE = 'Inserted a canned response';
export const USED_MENTIONS = 'Used mentions';
export const MERGED_CONTACTS = 'Used merge contact option';
export const ADDED_TO_CANNED_RESPONSE = 'Used added to canned response option';
export const ADDED_A_CUSTOM_ATTRIBUTE = 'Added a custom attribute';
export const ADDED_AN_INBOX = 'Added an inbox';

View file

@ -0,0 +1,67 @@
import { AnalyticsBrowser } from '@june-so/analytics-next';
class AnalyticsHelper {
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
async init() {
if (!this.analyticsToken) {
return;
}
let [analytics] = await AnalyticsBrowser.load({
writeKey: this.analyticsToken,
});
this.analytics = analytics;
}
identify(user) {
if (!this.analytics) {
return;
}
this.user = user;
this.analytics.identify(this.user.email, {
userId: this.user.id,
email: this.user.email,
name: this.user.name,
avatar: this.user.avatar_url,
});
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
this.analytics.group(currentAccount.id, this.user.id, {
name: currentAccount.name,
});
}
}
track(eventName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track({
userId: this.user.id,
event: eventName,
properties,
});
}
page(params) {
if (!this.analytics) {
return;
}
this.analytics.page(params);
}
}
export * as ANALYTICS_EVENTS from './events';
export default new AnalyticsHelper(window.analyticsConfig);

View file

@ -17,13 +17,22 @@ const formatArray = params => {
return params;
};
const generatePayloadForObject = item => {
if (item.action_params.id) {
item.action_params = [item.action_params.id];
} else {
item.action_params = [item.action_params];
}
return item.action_params;
};
const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => {
if (Array.isArray(item.action_params)) {
item.action_params = formatArray(item.action_params);
} else if (typeof item.action_params === 'object') {
item.action_params = [item.action_params.id];
item.action_params = generatePayloadForObject(item);
} else if (!item.action_params) {
item.action_params = [];
} else {

View file

@ -1,4 +1,4 @@
import posthog from 'posthog-js';
import AnalyticsHelper from './AnalyticsHelper';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@ -8,16 +8,9 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
if (window.analyticsConfig) {
posthog.identify(user.id, { name: user.name, email: user.email });
}
});
window.bus.$on(ANALYTICS_RESET, () => {
if (window.analyticsConfig) {
posthog.reset();
}
AnalyticsHelper.identify(user);
});
window.bus.$on(ANALYTICS_RESET, () => {});
};
export const initializeChatwootEvents = () => {

View file

@ -97,6 +97,7 @@
"SEARCH_PLACEHOLDER": "Search or jump to",
"SECTIONS": {
"GENERAL": "General",
"CHATWOOT": "Search Chatwoot",
"REPORTS": "Reports",
"CONVERSATION": "Conversation",
"CHANGE_ASSIGNEE": "Change Assignee",
@ -107,6 +108,7 @@
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
"SEARCH_EVEYTHING": "Search everything",
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
"GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview",
"GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports",

View file

@ -24,6 +24,9 @@ import MergeContact from 'dashboard/modules/contact/components/MergeContact';
import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
export default {
components: { MergeContact },
@ -72,6 +75,7 @@ export default {
}
},
async onMergeContacts(childContactId) {
AnalyticsHelper.track(ANALYTICS_EVENTS.MERGED_CONTACTS);
try {
await this.$store.dispatch('contacts/merge', {
childId: childContactId,

View file

@ -72,6 +72,9 @@ import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
export default {
components: {
@ -127,6 +130,7 @@ export default {
this.$emit('toggle', false);
},
showCannedResponseModal() {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true;
},
},

View file

@ -26,3 +26,4 @@ export const ICON_LABELS = `<svg role="img" class="ninja-icon ninja-icon--fluent
export const ICON_ACCOUNT_SETTINGS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 3h6.5a.75.75 0 0 1 .743.648L16 3.75V7h1.75A3.25 3.25 0 0 1 21 10.25v6.5A3.25 3.25 0 0 1 17.75 20H6.25A3.25 3.25 0 0 1 3 16.75v-6.5A3.25 3.25 0 0 1 6.25 7H8V3.75a.75.75 0 0 1 .648-.743L8.75 3h6.5-6.5Zm9 5.5H6.25a1.75 1.75 0 0 0-1.75 1.75v6.5c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-6.5a1.75 1.75 0 0 0-1.75-1.75Zm-3.25-4h-5V7h5V4.5Z" fill="currentColor"/></svg>`;
export const ICON_INBOXES = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z" fill="currentColor"/></svg>`;
export const ICON_APPS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18.492 2.33 3.179 3.179a2.25 2.25 0 0 1 0 3.182l-2.584 2.584A2.25 2.25 0 0 1 21 13.5v5.25A2.25 2.25 0 0 1 18.75 21H5.25A2.25 2.25 0 0 1 3 18.75V5.25A2.25 2.25 0 0 1 5.25 3h5.25a2.25 2.25 0 0 1 2.225 1.915L15.31 2.33a2.25 2.25 0 0 1 3.182 0ZM4.5 18.75c0 .414.336.75.75.75l5.999-.001.001-6.75H4.5v6Zm8.249.749h6.001a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-6.001v6.75Zm-2.249-15H5.25a.75.75 0 0 0-.75.75v6h6.75v-6a.75.75 0 0 0-.75-.75Zm2.25 4.81v1.94h1.94l-1.94-1.94Zm3.62-5.918-3.178 3.178a.75.75 0 0 0 0 1.061l3.179 3.179a.75.75 0 0 0 1.06 0l3.18-3.179a.75.75 0 0 0 0-1.06l-3.18-3.18a.75.75 0 0 0-1.06 0Z" fill="currentColor"/></svg>`;
export const SEARCH = `<svg class="ninja-icon ninja-icon--fluent" width="18" height="18" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10 2.75a7.25 7.25 0 0 1 5.63 11.819l4.9 4.9a.75.75 0 0 1-.976 1.134l-.084-.073l-4.901-4.9A7.25 7.25 0 1 1 10 2.75Zm0 1.5a5.75 5.75 0 1 0 0 11.5a5.75 5.75 0 0 0 0-11.5Z"/></svg>`;

View file

@ -6,6 +6,7 @@
hideBreadcrumbs
:placeholder="placeholder"
@selected="setCommandbarData"
@change="onChange"
/>
</template>
@ -17,6 +18,7 @@ import agentMixin from 'dashboard/mixins/agentMixin';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import conversationTeamMixin from 'dashboard/mixins/conversation/teamMixin';
import adminMixin from 'dashboard/mixins/isAdmin';
import { frontendURL } from '../../../helper/URLHelper';
export default {
mixins: [
@ -53,8 +55,28 @@ export default {
this.setCommandbarData();
},
methods: {
setCommandbarData() {
setCommandbarData(e) {
if (e && e.detail.action.id && e.detail.action.title) {
const action = e.detail.action;
if (action.type === 'contact') {
this.$refs.ninjakeys.close();
this.$router.push(
frontendURL(`accounts/${this.accountId}/contacts/${action.key}`)
);
} else if (action.type === 'message') {
this.$refs.ninjakeys.close();
this.$router.push(
frontendURL(
`accounts/${this.accountId}/conversations/${action.key}`
)
);
}
} else {
this.$refs.ninjakeys.data = this.hotKeys;
}
},
onChange(ninjaKeyInstance) {
// console.log(ninjaKeyInstance);
},
},
};

View file

@ -20,6 +20,41 @@ import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
const GO_TO_COMMANDS = [
{
id: 'search_everything',
title: 'COMMAND_BAR.COMMANDS.SEARCH_EVEYTHING',
section: 'COMMAND_BAR.SECTIONS.CHATWOOT',
icon: ICON_CONVERSATION_DASHBOARD,
builder: searchKey => {
return new Promise(resolve => {
fetch(`/api/v1/accounts/1/conversations/text_search?q=${searchKey}`, {
headers: {
'Content-Type': 'application/json',
api_access_token: '',
},
})
.then(res => res.json())
.then(data => {
const contacts = data.payload.contacts.map(result => ({
id: result.id,
title: result.name,
type: 'contact',
}));
const messages = data.payload.messages.map(result => ({
id: result.conversation_id,
title: result.content,
type: 'message',
}));
const flattened = [...contacts, ...messages];
return resolve(flattened);
})
.catch(error => {
resolve([]);
});
});
},
role: ['agent'],
},
{
id: 'goto_conversation_dashboard',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_DASHBOARD',
@ -180,18 +215,25 @@ export default {
}
return true;
});
if (!this.isAdmin) {
commands = commands.filter(command => command.role.includes('agent'));
}
return commands.map(command => ({
return commands.map(command => {
const hotKey = {
id: command.id,
section: this.$t(command.section),
title: this.$t(command.title),
icon: command.icon,
handler: () => this.openRoute(command.path(this.accountId)),
}));
};
if (command.builder) {
hotKey.builder = command.builder;
} else {
hotKey.handler = () => {
this.openRoute(command.path(this.accountId));
};
}
return hotKey;
});
},
},
methods: {

View file

@ -35,6 +35,9 @@
import alertMixin from 'shared/mixins/alertMixin';
import { mixin as clickaway } from 'vue-clickaway';
import MacroPreview from './MacroPreview';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../../helper/AnalyticsHelper';
export default {
components: {
MacroPreview,
@ -64,6 +67,7 @@ export default {
macroId: macro.id,
conversationIds: [this.conversationId],
});
AnalyticsHelper.track(ANALYTICS_EVENTS.EXECUTED_A_MACRO);
this.showAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
} catch (error) {
this.showAlert(this.$t('MACROS.ERROR'));

View file

@ -171,11 +171,12 @@
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import { required } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import campaignMixin from 'shared/mixins/campaignMixin';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
@ -221,8 +222,23 @@ export default {
},
endPoint: {
required,
minLength: minLength(7),
url,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return (
value.startsWith('https://') || value.startsWith('http://')
);
}
return false;
},
},
timeOnPage: {
required,

View file

@ -30,7 +30,7 @@
<label :class="{ error: $v.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
<select v-model="selectedInbox" @change="onChangeInbox($event)">
<option v-for="item in inboxes" :key="item.name" :value="item.id">
<option v-for="item in inboxes" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
@ -111,10 +111,12 @@
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import { required } from 'vuelidate/lib/validators';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import alertMixin from 'shared/mixins/alertMixin';
import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
@ -152,8 +154,21 @@ export default {
},
endPoint: {
required,
minLength: minLength(7),
url,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return value.startsWith('https://') || value.startsWith('http://');
}
return false;
},
},
timeOnPage: {
required,

View file

@ -7,6 +7,7 @@ import dashboard from './dashboard/dashboard.routes';
import login from './login/login.routes';
import store from '../store';
import { validateLoggedInRoutes } from '../helper/routeHelpers';
import AnalyticsHelper from '../helper/AnalyticsHelper';
const routes = [...login.routes, ...dashboard.routes, ...authRoute.routes];
@ -117,6 +118,11 @@ export const validateRouteAccess = (to, from, next, { getters }) => {
export const initalizeRouter = () => {
const userAuthentication = store.dispatch('setUser');
router.beforeEach((to, from, next) => {
AnalyticsHelper.page(to.name || '', {
path: to.path,
name: to.name,
});
if (validateSSOLoginParams(to)) {
clearBrowserSessionCookies();
next();

View file

@ -10,6 +10,9 @@ import {
isOnUnattendedView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../../helper/AnalyticsHelper';
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@ -171,6 +174,11 @@ const actions = {
status: MESSAGE_STATUS.PROGRESS,
});
const response = await MessageApi.create(pendingMessage);
AnalyticsHelper.track(
pendingMessage.private
? ANALYTICS_EVENTS.SENT_PRIVATE_NOTE
: ANALYTICS_EVENTS.SENT_MESSAGE
);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,

View file

@ -6,6 +6,9 @@ import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper, {
ANALYTICS_EVENTS,
} from '../../helper/AnalyticsHelper';
const buildInboxData = inboxParams => {
const formData = new FormData();
@ -117,6 +120,12 @@ export const getters = {
},
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ANALYTICS_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
@ -134,6 +143,8 @@ export const actions = {
const response = await WebChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
const { channel = {} } = params;
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -146,6 +157,7 @@ export const actions = {
const response = await WebChannel.create(buildInboxData(params));
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('website');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -158,6 +170,7 @@ export const actions = {
const response = await TwilioChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('twilio');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
@ -170,6 +183,7 @@ export const actions = {
const response = await FBChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('facebook');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });

View file

@ -1,8 +1,3 @@
/* eslint no-console: 0 */
/* eslint-env browser */
/* eslint-disable no-new */
/* Vue Core */
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import VueRouter from 'vue-router';
@ -32,7 +27,6 @@ import constants from '../dashboard/constants';
import * as Sentry from '@sentry/vue';
import 'vue-easytable/libs/theme-default/index.css';
import { Integrations } from '@sentry/tracing';
import posthog from 'posthog-js';
import {
initializeAnalyticsEvents,
initializeChatwootEvents,
@ -40,6 +34,7 @@ import {
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import AnalyticsHelper from '../dashboard/helper/AnalyticsHelper';
Vue.config.env = process.env;
@ -51,12 +46,6 @@ if (window.errorLoggingConfig) {
});
}
if (window.analyticsConfig) {
posthog.init(window.analyticsConfig.token, {
api_host: window.analyticsConfig.host,
});
}
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
Vue.use(VueRouter);
Vue.use(VueI18n);
@ -90,6 +79,7 @@ window.WootConstants = constants;
window.axios = createAxios(axios);
window.bus = new Vue();
initializeChatwootEvents();
AnalyticsHelper.init();
initializeAnalyticsEvents();
initalizeRouter();

View file

@ -14,6 +14,12 @@
<div v-if="error" class="text-red-400 mt-2 text-xs font-medium">
{{ error }}
</div>
<div
v-if="!error && helpText"
class="text-red-400 mt-2 text-xs font-medium"
>
{{ helpText }}
</div>
</label>
</template>
<script>
@ -41,6 +47,10 @@ export default {
type: String,
default: '',
},
helpText: {
type: String,
default: '',
},
},
computed: {
labelClass() {

View file

@ -1,5 +1,19 @@
export const stripTrailingSlash = ({ URL }) => {
return URL.replace(/\/$/, '');
import { URLPattern } from 'urlpattern-polyfill';
export const isPatternMatchingWithURL = (urlPattern, url) => {
let updatedUrlPattern = urlPattern;
const locationObj = new URL(url);
if (updatedUrlPattern.endsWith('/')) {
updatedUrlPattern = updatedUrlPattern.slice(0, -1) + '*\\?*\\#*';
}
if (locationObj.pathname.endsWith('/')) {
locationObj.pathname = locationObj.pathname.slice(0, -1);
}
const pattern = new URLPattern(updatedUrlPattern);
return pattern.test(locationObj.toString());
};
// Format all campaigns
@ -22,10 +36,7 @@ export const filterCampaigns = ({
isInBusinessHours,
}) => {
return campaigns.filter(campaign => {
const hasMatchingURL =
stripTrailingSlash({ URL: campaign.url }) ===
stripTrailingSlash({ URL: currentURL });
if (!hasMatchingURL) {
if (!isPatternMatchingWithURL(campaign.url, currentURL)) {
return false;
}
if (campaign.triggerOnlyDuringBusinessHours) {

View file

@ -1,7 +1,7 @@
import {
stripTrailingSlash,
formatCampaigns,
filterCampaigns,
isPatternMatchingWithURL,
} from '../campaignHelper';
import campaigns from './campaignFixtures';
@ -9,11 +9,35 @@ global.chatwootWebChannel = {
workingHoursEnabled: false,
};
describe('#Campaigns Helper', () => {
describe('stripTrailingSlash', () => {
it('should return striped trailing slash if url with trailing slash is passed', () => {
describe('#isPatternMatchingWithURL', () => {
it('returns correct value if a valid URL is passed', () => {
expect(
stripTrailingSlash({ URL: 'https://www.chatwoot.com/pricing/' })
).toBe('https://www.chatwoot.com/pricing');
isPatternMatchingWithURL(
'https://chatwoot.com/pricing*',
'https://chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://*.chatwoot.com/pricing/',
'https://app.chatwoot.com/pricing/'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing?test=true',
'https://app.chatwoot.com/pricing/?test=true'
)
).toBe(true);
expect(
isPatternMatchingWithURL(
'https://{*.}?chatwoot.com/pricing*\\?*',
'https://chatwoot.com/pricing/?test=true'
)
).toBe(true);
});
});

View file

@ -70,12 +70,15 @@ class Attachment < ApplicationRecord
private
def file_metadata
{
metadata = {
extension: extension,
data_url: file_url,
thumb_url: thumb_url,
file_size: file.byte_size
}
metadata[:data_url] = metadata[:thumb_url] = external_url if message.instagram_story_mention?
metadata
end
def location_metadata

View file

@ -86,7 +86,8 @@ class Campaign < ApplicationRecord
def validate_url
return unless trigger_rules['url']
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !url_valid?(trigger_rules['url'])
use_http_protocol = trigger_rules['url'].starts_with?('http://') || trigger_rules['url'].starts_with?('https://')
errors.add(:url, 'invalid') if inbox.inbox_type == 'Website' && !use_http_protocol
end
def prevent_completed_campaign_from_update

View file

@ -63,4 +63,21 @@ class Channel::FacebookPage < ApplicationRecord
Rails.logger.debug { "Rescued: #{e.inspect}" }
true
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
def fetch_instagram_story_link(message)
k = Koala::Facebook::API.new(page_access_token)
result = k.get_object(message.source_id, fields: %w[story]) || {}
story_link = result['story']['mention']['link']
# If the story is expired then it raises the ClientError and if the story is deleted with valid story-id it responses with nil
delete_instagram_story(message) if story_link.blank?
story_link
rescue Koala::Facebook::ClientError => e
delete_instagram_story(message)
end
def delete_instagram_story(message)
message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'), content_attributes: {})
message.attachments.destroy_all
end
end

View file

@ -16,4 +16,8 @@ module MessageFilterHelpers
def email_reply_summarizable?
incoming? || outgoing? || input_csat?
end
def instagram_story_mention?
inbox.instagram? && try(:content_attributes)[:image_type] == 'story_mention'
end
end

View file

@ -0,0 +1,48 @@
module MultiSearchableHelpers
extend ActiveSupport::Concern
included do
PgSearch.multisearch_options = {
using: {
tsearch: {
prefix: true,
any_word: true,
normalization: 3
}
}
}
def update_contact_search_document
return if contact_pg_search_record.present?
initialize_contact_pg_search_record.update!(
content: "#{contact.id} #{contact.email} #{contact.name} #{contact.phone_number}",
conversation_id: id
)
end
end
def contact_pg_search_record
contacts_pg_search_records.find_by(conversation_id: id)
end
def initialize_contact_pg_search_record
record = contacts_pg_search_records.find_by(conversation_id: nil)
return record if record.present?
PgSearch::Document.new(
searchable_type: 'Contact',
searchable_id: contact_id,
account_id: account_id
)
end
def contacts_pg_search_records
PgSearch::Document.where(
searchable_type: 'Contact',
searchable_id: contact_id,
account_id: account_id
)
end
end

View file

@ -26,6 +26,13 @@ class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
include Labelable
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:id, :email, :name, :phone_number],
additional_attributes: ->(contact) { { conversation_id: nil, account_id: contact.account_id } }
)
validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },
@ -140,6 +147,24 @@ class Contact < ApplicationRecord
email_format
end
def self.rebuild_pg_search_documents
return super unless name == 'Contact'
connection.execute <<~SQL.squish
INSERT INTO pg_search_documents (searchable_type, searchable_id, content, account_id, conversation_id, created_at, updated_at)
SELECT 'Contact' AS searchable_type,
contacts.id AS searchable_id,
CONCAT_WS(' ', contacts.email, contacts.name, contacts.phone_number, contacts.id, contacts.account_id) AS content,
contacts.account_id::int AS account_id,
conversations.id AS conversation_id,
now() AS created_at,
now() AS updated_at
FROM contacts
LEFT OUTER JOIN conversations
ON conversations.contact_id = contacts.id
SQL
end
private
def ip_lookup

View file

@ -48,7 +48,13 @@ class Conversation < ApplicationRecord
include ActivityMessageHandler
include UrlHelper
include SortHandler
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:display_id],
additional_attributes: ->(conversation) { { conversation_id: conversation.id, account_id: conversation.account_id } }
)
validates :account_id, presence: true
validates :inbox_id, presence: true
before_validation :validate_additional_attributes
@ -93,6 +99,7 @@ class Conversation < ApplicationRecord
after_update_commit :execute_after_update_commit_callbacks
after_create_commit :notify_conversation_creation
after_create_commit :update_contact_search_document, if: :contact_id?
after_commit :set_display_id, unless: :display_id?
delegate :auto_resolve_duration, to: :account

View file

@ -78,6 +78,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::FacebookPage'
end
def instagram?
facebook? && channel.instagram_id.present?
end
def web_widget?
channel_type == 'Channel::WebWidget'
end

View file

@ -33,6 +33,14 @@
class Message < ApplicationRecord
include MessageFilterHelpers
NUMBER_OF_PERMITTED_ATTACHMENTS = 15
include PgSearch::Model
include MultiSearchableHelpers
multisearchable(
against: [:content],
if: :allowed_message_types?,
additional_attributes: ->(message) { { conversation_id: message.conversation_id, account_id: message.account_id } }
)
before_validation :ensure_content_type
@ -107,10 +115,20 @@ class Message < ApplicationRecord
conversation: { assignee_id: conversation.assignee_id }
)
data.merge!(echo_id: echo_id) if echo_id.present?
validate_instagram_story if instagram_story_mention?
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
merge_sender_attributes(data)
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
# Better logic is to listen to webhook and remove stories proactively rather than trying
# a fetch every time a message is returned
def validate_instagram_story
inbox.channel.fetch_instagram_story_link(self)
# we want to reload the message in case the story has expired and data got removed
reload
end
def merge_sender_attributes(data)
data.merge!(sender: sender.push_event_data) if sender && !sender.is_a?(AgentBot)
data.merge!(sender: sender.push_event_data(inbox)) if sender.is_a?(AgentBot)
@ -264,4 +282,8 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def allowed_message_types?
incoming? || outgoing?
end
end

View file

@ -13,8 +13,10 @@
# display_name :string
# email :string
# encrypted_password :string default(""), not null
# failed_attempts :integer
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
# message_signature :text
# name :string not null
# provider :string default("email"), not null
@ -28,6 +30,7 @@
# ui_settings :jsonb
# uid :string default(""), not null
# unconfirmed_email :string
# unlock_token :string
# created_at :datetime not null
# updated_at :datetime not null
#

View file

@ -13,8 +13,10 @@
# display_name :string
# email :string
# encrypted_password :string default(""), not null
# failed_attempts :integer
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
# message_signature :text
# name :string not null
# provider :string default("email"), not null
@ -28,6 +30,7 @@
# ui_settings :jsonb
# uid :string default(""), not null
# unconfirmed_email :string
# unlock_token :string
# created_at :datetime not null
# updated_at :datetime not null
#

View file

@ -0,0 +1,19 @@
json.payload do
json.conversations do
json.array! @result[:conversations] do |conversation|
json.partial! 'api/v1/models/conversation', formats: [:json], conversation: conversation
end
end
json.contacts do
json.array! @result[:contacts] do |contact|
json.partial! 'api/v1/models/contact', formats: [:json], resource: contact
end
end
json.messages do
json.array! @result[:messages] do |message|
json.partial! 'api/v1/models/message', formats: [:json], message: message
end
end
end

View file

@ -2,7 +2,7 @@ json.id message.id
json.content message.content
json.inbox_id message.inbox_id
json.echo_id message.echo_id if message.echo_id
json.conversation_id message.conversation.display_id
json.conversation_id message.conversation.try(:display_id)
json.message_type message.message_type_before_type_cast
json.content_type message.content_type
json.status message.status

View file

@ -51,11 +51,10 @@
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_DSN', '')%>'
</script>
<% if @global_config['ANALYTICS_TOKEN'].present? && @global_config['ANALYTICS_HOST'].present? %>
<% if @global_config['ANALYTICS_TOKEN'].present? %>
<script>
window.analyticsConfig = {
token: '<%= @global_config['ANALYTICS_TOKEN'] %>',
host: '<%= @global_config['ANALYTICS_HOST'] %>',
}
</script>
<% end %>

View file

@ -42,8 +42,6 @@
value:
- name: ANALYTICS_TOKEN
value:
- name: ANALYTICS_HOST
value:
- name: DIRECT_UPLOADS_ENABLED
value: false
locked: false

View file

@ -71,6 +71,7 @@ Rails.application.routes.draw do
get :meta
get :search
post :filter
get :text_search
end
scope module: :conversations do
resources :messages, only: [:index, :create, :destroy]

View file

@ -0,0 +1,21 @@
class CreatePgSearchDocuments < ActiveRecord::Migration[6.1]
def up
say_with_time('Creating table for pg_search multisearch') do
create_table :pg_search_documents do |t|
t.text :content
t.bigint 'conversation_id'
t.bigint 'account_id'
t.belongs_to :searchable, polymorphic: true, index: true
t.timestamps null: false
end
add_index :pg_search_documents, :account_id
add_index :pg_search_documents, :conversation_id
end
end
def down
say_with_time('Dropping table for pg_search multisearch') do
drop_table :pg_search_documents
end
end
end

View file

@ -0,0 +1,11 @@
class EnableMultiSearchable < ActiveRecord::Migration[6.1]
def up
Contact.rebuild_pg_search_documents
PgSearch::Multisearch.rebuild(Conversation)
PgSearch::Multisearch.rebuild(Message)
end
def down
PgSearch::Document.delete_all
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_11_16_000514) do
ActiveRecord::Schema.define(version: 2022_12_12_061802) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
@ -399,7 +399,7 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.datetime "agent_last_seen_at"
t.jsonb "additional_attributes", default: {}
t.bigint "contact_inbox_id"
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
t.string "identifier"
t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false
t.bigint "team_id"
@ -674,6 +674,19 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.index ["user_id"], name: "index_notifications_on_user_id"
end
create_table "pg_search_documents", force: :cascade do |t|
t.text "content"
t.bigint "conversation_id"
t.bigint "account_id"
t.string "searchable_type"
t.bigint "searchable_id"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["account_id"], name: "index_pg_search_documents_on_account_id"
t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id"
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
end
create_table "platform_app_permissibles", force: :cascade do |t|
t.bigint "platform_app_id", null: false
t.string "permissible_type", null: false
@ -836,6 +849,9 @@ ActiveRecord::Schema.define(version: 2022_11_16_000514) do
t.jsonb "custom_attributes", default: {}
t.string "type"
t.text "message_signature"
t.datetime "locked_at"
t.integer "failed_attempts"
t.string "unlock_token"
t.index ["email"], name: "index_users_on_email"
t.index ["pubsub_token"], name: "index_users_on_pubsub_token", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -22,6 +22,7 @@
"@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git#7e8acadd10d7b932c0dc0bd0a18f804434f83517",
"@chatwoot/utils": "^0.0.10",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^1.36.5",
"@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.0.3-1",
"@rails/webpacker": "5.3.0",
@ -44,9 +45,8 @@
"js-cookie": "^2.2.1",
"marked": "4.0.10",
"md5": "^2.3.0",
"ninja-keys": "^1.1.9",
"ninja-keys": "github:fayazara/ninja-keys",
"opus-recorder": "^8.0.5",
"posthog-js": "^1.13.7",
"prosemirror-markdown": "1.5.1",
"prosemirror-state": "1.3.4",
"prosemirror-view": "1.18.4",
@ -55,6 +55,7 @@
"tailwindcss": "^1.9.6",
"turbolinks": "^5.2.0",
"url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2",
"v-tooltip": "~2.1.3",
"videojs-record": "^4.5.0",
"vue": "2.6.12",

View file

@ -68,7 +68,7 @@ describe ::Messages::Instagram::MessageBuilder do
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(1)
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do

View file

@ -6,5 +6,9 @@ FactoryBot.define do
user_access_token { SecureRandom.uuid }
page_id { SecureRandom.uuid }
account
before :create do |_channel|
WebMock::API.stub_request(:post, 'https://graph.facebook.com/v3.2/me/subscribed_apps')
end
end
end

View file

@ -8,6 +8,18 @@ FactoryBot.define do
content_type { 'text' }
account { create(:account) }
trait :instagram_story_mention do
content_attributes { { image_type: 'story_mention' } }
after(:build) do |message|
unless message.inbox.instagram?
message.inbox = create(:inbox, account: message.account,
channel: create(:channel_instagram_fb_page, account: message.account, instagram_id: 'instagram-123'))
end
attachment = message.attachments.new(account_id: message.account_id, file_type: :image, external_url: 'https://www.example.com/test.jpeg')
attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png')
end
end
after(:build) do |message|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)

View file

@ -0,0 +1,51 @@
require 'rails_helper'
describe ::TextSearch do
subject(:text_search) { described_class.new(user_1, params) }
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:user_2) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
before do
create(:inbox_member, user: user_1, inbox: inbox)
create(:inbox_member, user: user_2, inbox: inbox)
create(:contact, name: '1223', account_id: account.id)
create(:contact, name: 'Potter', account_id: account.id)
contact_2 = create(:contact, name: 'Harry Potter', account_id: account.id)
conversation_1 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 121)
conversation_2 = create(:conversation, account: account, inbox: inbox, assignee: user_1, display_id: 122)
create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'resolved', display_id: 13, contact_id: contact_2.id)
create(:conversation, account: account, inbox: inbox, assignee: user_2, display_id: 14)
create(:conversation, account: account, inbox: inbox, display_id: 15)
Current.account = account
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'Ask Lisa')
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_12')
create(:message, conversation_id: conversation_1.id, account_id: account.id, content: 'message_13')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'Pottery Barn order')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_22')
create(:message, conversation_id: conversation_2.id, account_id: account.id, content: 'message_23')
end
describe '#perform' do
context 'with text search' do
it 'filter conversations by number' do
params = { q: '12' }
result = described_class.new(user_1, params).perform
expect(result[:conversations].length).to be 2
expect(result[:contacts].length).to be 1
end
it 'filter conversations by string' do
params = { q: 'pot' }
result = described_class.new(user_1, params).perform
expect(result[:messages].length).to be 1
expect(result[:contacts].length).to be 2
end
end
end
end

View file

@ -113,6 +113,9 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
attachment = instagram_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end
it 'creates does not create contact or messages' do

View file

@ -9,4 +9,23 @@ RSpec.describe Attachment, type: :model do
expect(attachment.download_url).not_to be_nil
end
end
describe 'push_event_data for instagram story mentions' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'returns external url as data and thumb urls' do
external_url = instagram_message.attachments.first.external_url
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
expect(instagram_message.attachments.first.push_event_data[:thumb_url]).to eq external_url
end
end
end

View file

@ -180,4 +180,34 @@ RSpec.describe Message, type: :model do
expect(message.email_notifiable_message?).to be true
end
end
context 'when facebook channel with unavailable story link' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'deletes the attachment for deleted stories' do
expect(instagram_message.attachments.count).to eq 1
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 404)
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
it 'deletes the attachment for expired stories' do
expect(instagram_message.attachments.count).to eq 1
# for expired stories, the link will be empty
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: '', id: '17920786367196703' } }
}.to_json, headers: {})
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 0
end
end
end

1073
yarn.lock

File diff suppressed because it is too large Load diff