feat: Ability to reply to specific tweets (#1117)

Ability to choose a specific tweet to reply to

Fixes #982
Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
Sojan Jose 2020-08-11 09:57:42 +05:30 committed by GitHub
parent a6a62d92bf
commit 4216d63311
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 290 additions and 38 deletions

View file

@ -46,5 +46,6 @@ module.exports = {
}, },
globals: { globals: {
__WEBPACK_ENV__: true, __WEBPACK_ENV__: true,
bus: true,
}, },
}; };

View file

@ -11,6 +11,7 @@ class Messages::MessageBuilder
@content_type = params[:content_type] @content_type = params[:content_type]
@items = params.to_unsafe_h&.dig(:content_attributes, :items) @items = params.to_unsafe_h&.dig(:content_attributes, :items)
@attachments = params[:attachments] @attachments = params[:attachments]
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
end end
def perform def perform
@ -51,7 +52,8 @@ class Messages::MessageBuilder
private: @private, private: @private,
sender: sender, sender: sender,
content_type: @content_type, content_type: @content_type,
items: @items items: @items,
in_reply_to: @in_reply_to
} }
end end
end end

View file

@ -7,10 +7,11 @@ class MessageApi extends ApiClient {
super('conversations', { accountScoped: true }); super('conversations', { accountScoped: true });
} }
create({ conversationId, message, private: isPrivate }) { create({ conversationId, message, private: isPrivate, contentAttributes }) {
return axios.post(`${this.url}/${conversationId}/messages`, { return axios.post(`${this.url}/${conversationId}/messages`, {
content: message, content: message,
private: isPrivate, private: isPrivate,
content_attributes: contentAttributes,
}); });
} }

View file

@ -23,11 +23,27 @@
</span> </span>
</span> </span>
<bubble-actions <bubble-actions
:id="data.id"
:sender="data.sender"
:is-a-tweet="isATweet"
:is-email="isEmailContentType" :is-email="isEmailContentType"
:readable-time="readableTime"
:is-private="data.private" :is-private="data.private"
:message-type="data.message_type"
:readable-time="readableTime"
:source-id="data.source_id"
/> />
</p> </p>
<div v-if="isATweet && isIncoming && sender" class="sender--info">
<woot-thumbnail
:src="sender.thumbnail"
:username="sender.name"
size="16px"
/>
<div class="sender--available-name">
{{ sender.available_name || sender.name }}
</div>
</div>
</div> </div>
</li> </li>
</template> </template>
@ -40,6 +56,8 @@ import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File'; import BubbleFile from './bubble/File';
import contentTypeMixin from 'shared/mixins/contentTypeMixin'; import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import BubbleActions from './bubble/Actions'; import BubbleActions from './bubble/Actions';
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
export default { export default {
components: { components: {
BubbleActions, BubbleActions,
@ -53,6 +71,10 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
isATweet: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -61,7 +83,10 @@ export default {
}, },
computed: { computed: {
message() { message() {
return this.formatMessage(this.data.content); return this.formatMessage(this.data.content, this.isATweet);
},
sender() {
return this.data.sender || {};
}, },
contentType() { contentType() {
const { const {
@ -78,6 +103,9 @@ export default {
isBubble() { isBubble() {
return [0, 1, 3].includes(this.data.message_type); return [0, 1, 3].includes(this.data.message_type);
}, },
isIncoming() {
return this.data.message_type === MESSAGE_TYPE.INCOMING;
},
hasAttachments() { hasAttachments() {
return !!(this.data.attachments && this.data.attachments.length > 0); return !!(this.data.attachments && this.data.attachments.length > 0);
}, },
@ -90,7 +118,7 @@ export default {
return false; return false;
}, },
sentByMessage() { sentByMessage() {
const { sender } = this.data; const { sender } = this;
return this.data.message_type === 1 && !this.isHovered && sender return this.data.message_type === 1 && !this.isHovered && sender
? { ? {
@ -128,4 +156,15 @@ export default {
padding: 0; padding: 0;
} }
} }
.sender--info {
display: flex;
align-items: center;
padding: var(--space-smaller) 0;
.sender--available-name {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
}
</style> </style>

View file

@ -5,7 +5,7 @@
:is-contact-panel-open="isContactPanelOpen" :is-contact-panel-open="isContactPanelOpen"
@contactPanelToggle="onToggleContactPanel" @contactPanelToggle="onToggleContactPanel"
/> />
<div v-if="!currentChat.can_reply" class="messenger-policy--banner"> <div v-if="!currentChat.can_reply" class="banner messenger-policy--banner">
<span> <span>
{{ $t('CONVERSATION.CANNOT_REPLY') }} {{ $t('CONVERSATION.CANNOT_REPLY') }}
<a <a
@ -17,6 +17,23 @@
</a> </a>
</span> </span>
</div> </div>
<div v-if="isATweet" class="banner">
<span v-if="!selectedTweetId">
{{ $t('CONVERSATION.LAST_INCOMING_TWEET') }}
</span>
<span v-else>
{{ $t('CONVERSATION.REPLYING_TO') }}
{{ selectedTweet }}
</span>
<button
v-if="selectedTweetId"
class="banner-close-button"
@click="removeTweetSelection"
>
<i v-tooltip="$t('CONVERSATION.REMOVE_SELECTION')" class="ion-close" />
</button>
</div>
<ul class="conversation-panel"> <ul class="conversation-panel">
<transition name="slide-up"> <transition name="slide-up">
<li class="spinner--container"> <li class="spinner--container">
@ -27,6 +44,7 @@
v-for="message in getReadMessages" v-for="message in getReadMessages"
:key="message.id" :key="message.id"
:data="message" :data="message"
:is-a-tweet="isATweet"
/> />
<li v-show="getUnreadCount != 0" class="unread--toast"> <li v-show="getUnreadCount != 0" class="unread--toast">
<span> <span>
@ -37,6 +55,7 @@
v-for="message in getUnReadMessages" v-for="message in getUnReadMessages"
:key="message.id" :key="message.id"
:data="message" :data="message"
:is-a-tweet="isATweet"
/> />
</ul> </ul>
<div class="conversation-footer"> <div class="conversation-footer">
@ -52,6 +71,7 @@
</div> </div>
<ReplyBox <ReplyBox
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:in-reply-to="selectedTweetId"
@scrollToMessage="scrollToBottom" @scrollToMessage="scrollToBottom"
/> />
</div> </div>
@ -59,7 +79,6 @@
</template> </template>
<script> <script>
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import ConversationHeader from './ConversationHeader'; import ConversationHeader from './ConversationHeader';
@ -67,6 +86,7 @@ import ReplyBox from './ReplyBox';
import Message from './Message'; import Message from './Message';
import conversationMixin from '../../../mixins/conversations'; import conversationMixin from '../../../mixins/conversations';
import { getTypingUsersText } from '../../../helper/commons'; import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default { export default {
components: { components: {
@ -93,6 +113,7 @@ export default {
isLoadingPrevious: true, isLoadingPrevious: true,
heightBeforeLoad: null, heightBeforeLoad: null,
conversationPanel: null, conversationPanel: null,
selectedTweetId: null,
}; };
}, },
@ -151,6 +172,36 @@ export default {
shouldLoadMoreChats() { shouldLoadMoreChats() {
return !this.listLoadingStatus && !this.isLoadingPrevious; return !this.listLoadingStatus && !this.isLoadingPrevious;
}, },
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
return type || '';
},
isATweet() {
return this.conversationType === 'tweet';
},
selectedTweet() {
if (this.selectedTweetId) {
const { messages = [] } = this.getMessages;
const [selectedMessage = {}] = messages.filter(
message => message.id === this.selectedTweetId
);
return selectedMessage.content || '';
}
return '';
},
},
watch: {
currentChat(newChat, oldChat) {
if (newChat.id === oldChat.id) {
return;
}
this.selectedTweetId = null;
},
}, },
created() { created() {
@ -158,6 +209,10 @@ export default {
setTimeout(() => this.scrollToBottom(), 0); setTimeout(() => this.scrollToBottom(), 0);
this.makeMessagesRead(); this.makeMessagesRead();
}); });
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, selectedTweetId => {
this.selectedTweetId = selectedTweetId;
});
}, },
mounted() { mounted() {
@ -220,23 +275,37 @@ export default {
makeMessagesRead() { makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id }); this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
}, },
removeTweetSelection() {
this.selectedTweetId = null;
},
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.messenger-policy--banner { .banner {
background: var(--r-400); background: var(--b-500);
color: var(--white); color: var(--white);
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal); padding: var(--space-slab) var(--space-normal);
text-align: center; text-align: center;
position: relative;
a { a {
text-decoration: underline; text-decoration: underline;
color: var(--white); color: var(--white);
font-size: var(--font-size-mini); font-size: var(--font-size-mini);
} }
&.messenger-policy--banner {
background: var(--r-400);
}
.banner-close-button {
cursor: pointer;
margin-left: var(--space--two);
color: var(--white);
}
} }
.spinner--container { .spinner--container {

View file

@ -46,14 +46,14 @@
}}</a> }}</a>
</li> </li>
<li class="tabs-title is-private" :class="{ 'is-active': isPrivate }"> <li class="tabs-title is-private" :class="{ 'is-active': isPrivate }">
<a href="#" @click="setPrivateReplyMode">{{ <a href="#" @click="setPrivateReplyMode">
$t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') {{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
}}</a> </a>
</li> </li>
<li v-if="message.length" class="tabs-title message-length"> <li v-if="message.length" class="tabs-title message-length">
<a :class="{ 'message-error': isMessageLengthReachingThreshold }">{{ <a :class="{ 'message-error': isMessageLengthReachingThreshold }">
characterCountIndicator {{ characterCountIndicator }}
}}</a> </a>
</li> </li>
</ul> </ul>
<button <button
@ -106,6 +106,12 @@ export default {
ResizableTextArea, ResizableTextArea,
}, },
mixins: [clickaway, inboxMixin], mixins: [clickaway, inboxMixin],
props: {
inReplyTo: {
type: [String, Number],
default: '',
},
},
data() { data() {
return { return {
message: '', message: '',
@ -248,11 +254,15 @@ export default {
if (!this.showCannedResponsesList) { if (!this.showCannedResponsesList) {
this.clearMessage(); this.clearMessage();
try { try {
await this.$store.dispatch('sendMessage', { const messagePayload = {
conversationId: this.currentChat.id, conversationId: this.currentChat.id,
message: newMessage, message: newMessage,
private: this.isPrivate, private: this.isPrivate,
}); };
if (this.inReplyTo) {
messagePayload.contentAttributes = { in_reply_to: this.inReplyTo };
}
await this.$store.dispatch('sendMessage', messagePayload);
this.$emit('scrollToMessage'); this.$emit('scrollToMessage');
} catch (error) { } catch (error) {
// Error // Error

View file

@ -13,12 +13,32 @@
@mouseenter="isHovered = true" @mouseenter="isHovered = true"
@mouseleave="isHovered = false" @mouseleave="isHovered = false"
/> />
<i
v-if="isATweet && isIncoming"
v-tooltip.top-start="$t('CHAT_LIST.REPLY_TO_TWEET')"
class="icon ion-reply cursor-pointer"
@click="onTweetReply"
/>
<a :href="linkToTweet" target="_blank" rel="noopener noreferrer nofollow">
<i
v-if="isATweet && isIncoming"
v-tooltip.top-start="$t('CHAT_LIST.VIEW_TWEET_IN_TWITTER')"
class="icon ion-android-open cursor-pointer"
/>
</a>
</div> </div>
</template> </template>
<script> <script>
import { MESSAGE_TYPE } from 'shared/constants/messageTypes';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default { export default {
props: { props: {
sender: {
type: Object,
default: () => ({}),
},
readableTime: { readableTime: {
type: String, type: String,
default: '', default: '',
@ -31,6 +51,41 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isATweet: {
type: Boolean,
default: true,
},
messageType: {
type: Number,
default: 1,
},
sourceId: {
type: String,
default: '',
},
id: {
type: [String, Number],
default: '',
},
},
computed: {
isIncoming() {
return MESSAGE_TYPE.INCOMING === this.messageType;
},
screenName() {
const { additional_attributes: additionalAttributes = {} } =
this.sender || {};
return additionalAttributes?.screen_name || '';
},
linkToTweet() {
const { screenName, sourceId } = this;
return `https://twitter.com/${screenName}/status/${sourceId}`;
},
},
methods: {
onTweetReply() {
bus.$emit(BUS_EVENTS.SET_TWEET_REPLY, this.id);
},
}, },
}; };
</script> </script>
@ -65,6 +120,13 @@ export default {
i { i {
line-height: 1.4; line-height: 1.4;
padding-right: var(--space-small);
padding-left: var(--space-small);
color: var(--s-900);
}
a {
color: var(--s-900);
} }
} }

View file

@ -1,6 +1,5 @@
import AuthAPI from '../api/auth'; import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
/* global bus */
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {

View file

@ -10,7 +10,8 @@
"SEARCH": { "SEARCH": {
"INPUT": "Search for People, Chats, Saved Replies .." "INPUT": "Search for People, Chats, Saved Replies .."
}, },
"STATUS_TABS": [{ "STATUS_TABS": [
{
"NAME": "Open", "NAME": "Open",
"KEY": "openCount" "KEY": "openCount"
}, },
@ -19,8 +20,8 @@
"KEY": "allConvCount" "KEY": "allConvCount"
} }
], ],
"ASSIGNEE_TYPE_TABS": [
"ASSIGNEE_TYPE_TABS": [{ {
"NAME": "Mine", "NAME": "Mine",
"KEY": "me", "KEY": "me",
"COUNT_KEY": "mineCount" "COUNT_KEY": "mineCount"
@ -36,8 +37,8 @@
"COUNT_KEY": "allCount" "COUNT_KEY": "allCount"
} }
], ],
"CHAT_STATUS_ITEMS": [
"CHAT_STATUS_ITEMS": [{ {
"TEXT": "Open", "TEXT": "Open",
"VALUE": "open" "VALUE": "open"
}, },
@ -50,7 +51,6 @@
"VALUE": "bot" "VALUE": "bot"
} }
], ],
"ATTACHMENTS": { "ATTACHMENTS": {
"image": { "image": {
"ICON": "ion-image", "ICON": "ion-image",
@ -77,6 +77,8 @@
"CONTENT": "has shared a url" "CONTENT": "has shared a url"
} }
}, },
"RECEIVED_VIA_EMAIL": "Received via email" "RECEIVED_VIA_EMAIL": "Received via email",
"VIEW_TWEET_IN_TWITTER": "View tweet in Twitter",
"REPLY_TO_TWEET": "Reply to this tweet"
} }
} }

View file

@ -11,6 +11,9 @@
"LOADING_CONVERSATIONS": "Loading Conversations", "LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to", "CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction", "24_HOURS_WINDOW": "24 hour message window restriction",
"LAST_INCOMING_TWEET": "You are replying to the last incoming tweet",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"HEADER": { "HEADER": {
"RESOLVE_ACTION": "Resolve", "RESOLVE_ACTION": "Resolve",

View file

@ -0,0 +1,3 @@
export const BUS_EVENTS = {
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
};

View file

@ -0,0 +1,6 @@
export const MESSAGE_TYPE = {
INCOMING: 0,
OUTGOING: 1,
ACTIVITY: 2,
TEMPLATE: 3,
};

View file

@ -1,13 +1,32 @@
import { escapeHtml } from './HTMLSanitizer'; import { escapeHtml } from './HTMLSanitizer';
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT =
'$1<a href="http://twitter.com/$2" target="_blank" rel="noreferrer nofollow noopener">@$2</a>';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
const TWITTER_HASH_REPLACEMENT =
'$1<a href="https://twitter.com/hashtag/$2" target="_blank" rel="noreferrer nofollow noopener">#$2</a>';
class MessageFormatter { class MessageFormatter {
constructor(message) { constructor(message, isATweet = false) {
this.message = escapeHtml(message || '') || ''; this.message = escapeHtml(message || '') || '';
this.isATweet = isATweet;
} }
formatMessage() { formatMessage() {
const linkifiedMessage = this.linkify(); const linkifiedMessage = this.linkify();
return linkifiedMessage.replace(/\n/g, '<br>'); const messageWithNextLines = linkifiedMessage.replace(/\n/g, '<br>');
if (this.isATweet) {
const messageWithUserName = messageWithNextLines.replace(
TWITTER_USERNAME_REGEX,
TWITTER_USERNAME_REPLACEMENT
);
return messageWithUserName.replace(
TWITTER_HASH_REGEX,
TWITTER_HASH_REPLACEMENT
);
}
return messageWithNextLines;
} }
linkify() { linkify() {

View file

@ -10,4 +10,26 @@ describe('#MessageFormatter', () => {
); );
}); });
}); });
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
expect(new MessageFormatter(message).formattedMessage).toEqual(message);
});
it('should add links to @mentions', () => {
const message =
'@chatwootapp is an opensource tool thanks @longnonexistenttwitterusername';
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
'<a href="http://twitter.com/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">@chatwootapp</a> is an opensource tool thanks @longnonexistenttwitterusername'
);
});
it('should add links to #tags', () => {
const message = '#chatwootapp is an opensource tool';
expect(new MessageFormatter(message, true).formattedMessage).toEqual(
'<a href="https://twitter.com/hashtag/chatwootapp" target="_blank" rel="noreferrer nofollow noopener">#chatwootapp</a> is an opensource tool'
);
});
});
}); });

View file

@ -1,4 +1,3 @@
/* global bus */
export default { export default {
methods: { methods: {
showAlert(message) { showAlert(message) {

View file

@ -2,8 +2,8 @@ import MessageFormatter from '../helpers/MessageFormatter';
export default { export default {
methods: { methods: {
formatMessage(message) { formatMessage(message, isATweet) {
const messageFormatter = new MessageFormatter(message); const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.formattedMessage; return messageFormatter.formattedMessage;
}, },
truncateMessage(description = '') { truncateMessage(description = '') {

View file

@ -50,3 +50,7 @@ body {
} }
} }
} }
.cursor-pointer {
cursor: pointer;
}

View file

@ -4,7 +4,7 @@
v-if="!isCards && !isOptions && !isForm && !isArticle" v-if="!isCards && !isOptions && !isForm && !isArticle"
class="chat-bubble agent" class="chat-bubble agent"
> >
<span v-html="formatMessage(message)"></span> <span v-html="formatMessage(message, false)"></span>
<email-input <email-input
v-if="isTemplateEmail" v-if="isTemplateEmail"
:message-id="messageId" :message-id="messageId"

View file

@ -2,7 +2,7 @@
<div <div
class="chat-bubble user" class="chat-bubble user"
:style="{ background: widgetColor }" :style="{ background: widgetColor }"
v-html="formatMessage(message)" v-html="formatMessage(message, false)"
/> />
</template> </template>

View file

@ -50,7 +50,10 @@ class Message < ApplicationRecord
incoming_email: 8 incoming_email: 8
} }
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, :items, :submitted_values, :email], coder: JSON # [:submitted_email, :items, :submitted_values] : Used for bot message types
# [:email] : Used by conversation_continuity incoming email messages
# [:in_reply_to] : Used to reply to a particular tweet in threads
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to], 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) }

View file

@ -29,7 +29,7 @@ class Twitter::SendOnTwitterService < Base::SendOnChannelService
end end
def screen_name def screen_name
"@#{additional_attributes ? additional_attributes['screen_name'] : ''} " "@#{reply_to_message.sender&.additional_attributes.try(:[], 'screen_name') || ''}"
end end
def send_direct_message def send_direct_message
@ -39,10 +39,18 @@ class Twitter::SendOnTwitterService < Base::SendOnChannelService
) )
end end
def reply_to_message
@reply_to_message ||= if message.in_reply_to
conversation.messages.find(message.in_reply_to)
else
conversation.messages.incoming.last
end
end
def send_tweet_reply def send_tweet_reply
response = twitter_client.send_tweet_reply( response = twitter_client.send_tweet_reply(
reply_to_tweet_id: conversation.additional_attributes['tweet_id'], reply_to_tweet_id: reply_to_message.source_id,
tweet: screen_name + message.content tweet: "#{screen_name} #{message.content}"
) )
if response.status == '200' if response.status == '200'
tweet_data = response.body tweet_data = response.body

View file

@ -9,7 +9,7 @@ FactoryBot.define do
account { create(:account) } account { create(:account) }
after(:build) do |message| after(:build) do |message|
message.sender ||= create(:user, account: message.account) message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
message.inbox ||= create(:inbox, account: message.account) message.inbox ||= create(:inbox, account: message.account)
message.conversation ||= create(:conversation, account: message.account, inbox: message.inbox) message.conversation ||= create(:conversation, account: message.account, inbox: message.inbox)
end end

View file

@ -20,7 +20,7 @@ describe Integrations::Slack::SendOnSlackService do
expect(slack_client).to receive(:chat_postMessage).with( expect(slack_client).to receive(:chat_postMessage).with(
channel: hook.reference_id, channel: hook.reference_id,
text: message.content, text: message.content,
username: "Agent: #{message.sender.name}", username: "Contact: #{message.sender.name}",
thread_ts: conversation.identifier, thread_ts: conversation.identifier,
icon_url: anything icon_url: anything
) )