feat: Read/Delivery status for Whatsapp Cloud API (#5157)

Process field statuses received in webhook WhatsApp cloud API

ref: #1021

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Nithin David <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
Clairton Rodrigo Heinzen 2022-11-29 09:51:37 -03:00 committed by GitHub
parent a397f01692
commit edcbd53425
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 148 additions and 17 deletions

View file

@ -59,10 +59,12 @@
:story-sender="storySender" :story-sender="storySender"
:story-id="storyId" :story-id="storyId"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:is-email="isEmailContentType" :is-email="isEmailContentType"
:is-private="data.private" :is-private="data.private"
:message-type="data.message_type" :message-type="data.message_type"
:message-status="status"
:readable-time="readableTime" :readable-time="readableTime"
:source-id="data.source_id" :source-id="data.source_id"
:inbox-id="data.inbox_id" :inbox-id="data.inbox_id"
@ -157,6 +159,10 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
hasInstagramStory: { hasInstagramStory: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -231,6 +237,9 @@ export default {
sender() { sender() {
return this.data.sender || {}; return this.data.sender || {};
}, },
status() {
return this.data.status;
},
storySender() { storySender() {
return this.contentAttributes.story_sender || null; return this.contentAttributes.story_sender || null;
}, },

View file

@ -38,6 +38,7 @@
class="message--read ph-no-capture" class="message--read ph-no-capture"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:has-user-read-message=" :has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt) hasUserReadMessage(message.created_at, getLastSeenAt)
@ -60,6 +61,7 @@
class="message--unread ph-no-capture" class="message--unread ph-no-capture"
:data="message" :data="message"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
:has-instagram-story="hasInstagramStory" :has-instagram-story="hasInstagramStory"
:has-user-read-message=" :has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt) hasUserReadMessage(message.created_at, getLastSeenAt)

View file

@ -3,20 +3,30 @@
<span class="time" :class="{ delivered: messageRead }">{{ <span class="time" :class="{ delivered: messageRead }">{{
readableTime readableTime
}}</span> }}</span>
<span v-if="showSentIndicator" class="time"> <span v-if="showReadIndicator" class="read-indicator-wrap">
<fluent-icon <fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')" v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark" icon="checkmark-double"
class="action--icon read-tick read-indicator"
size="14"
/>
</span>
<span v-if="showDeliveredIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.DELIVERED')"
icon="checkmark-double"
class="action--icon read-tick"
size="14"
/>
</span>
<span v-if="showSentIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.SENT')"
icon="checkmark"
class="action--icon read-tick"
size="14" size="14"
/> />
</span> </span>
<fluent-icon
v-if="messageRead"
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark-double"
class="action--icon read-tick"
size="12"
/>
<fluent-icon <fluent-icon
v-if="isEmail" v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')" v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
@ -74,7 +84,7 @@
</template> </template>
<script> <script>
import { MESSAGE_TYPE } from 'shared/constants/messages'; import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
@ -117,6 +127,10 @@ export default {
type: Number, type: Number,
default: 1, default: 1,
}, },
messageStatus: {
type: String,
default: '',
},
sourceId: { sourceId: {
type: String, type: String,
default: '', default: '',
@ -144,6 +158,15 @@ export default {
isOutgoing() { isOutgoing() {
return MESSAGE_TYPE.OUTGOING === this.messageType; return MESSAGE_TYPE.OUTGOING === this.messageType;
}, },
isDelivered() {
return MESSAGE_STATUS.DELIVERED === this.messageStatus;
},
isRead() {
return MESSAGE_STATUS.READ === this.messageStatus;
},
isSent() {
return MESSAGE_STATUS.SENT === this.messageStatus;
},
screenName() { screenName() {
const { additional_attributes: additionalAttributes = {} } = const { additional_attributes: additionalAttributes = {} } =
this.sender || {}; this.sender || {};
@ -168,7 +191,23 @@ export default {
return ( return (
this.isOutgoing && this.isOutgoing &&
this.sourceId && this.sourceId &&
(this.isAnEmailChannel || this.isAWhatsAppChannel) (this.isAnEmailChannel || (this.isAWhatsAppChannel && this.isSent))
);
},
showDeliveredIndicator() {
return (
this.isOutgoing &&
this.sourceId &&
this.isAWhatsAppChannel &&
this.isDelivered
);
},
showReadIndicator() {
return (
this.isOutgoing &&
this.sourceId &&
this.isAWhatsAppChannel &&
this.isRead
); );
}, },
}, },
@ -185,16 +224,20 @@ export default {
.right { .right {
.message-text--metadata { .message-text--metadata {
align-items: center;
.time { .time {
color: var(--w-100); color: var(--w-100);
} }
.action--icon { .action--icon {
color: var(--white);
&.read-tick { &.read-tick {
color: var(--v-100); color: var(--v-100);
margin-top: calc(var(--space-micro) + var(--space-micro) / 2);
} }
color: var(--white);
&.read-indicator {
color: var(--g-300);
}
} }
.lock--icon--private { .lock--icon--private {
@ -296,4 +339,10 @@ export default {
.delivered-icon { .delivered-icon {
margin-left: -var(--space-normal); margin-left: -var(--space-normal);
} }
.read-indicator-wrap {
line-height: 1;
display: flex;
align-items: center;
}
</style> </style>

View file

@ -56,6 +56,8 @@
"REPLY_TO_TWEET": "Reply to this tweet", "REPLY_TO_TWEET": "Reply to this tweet",
"LINK_TO_STORY": "Go to instagram story", "LINK_TO_STORY": "Go to instagram story",
"SENT": "Sent successfully", "SENT": "Sent successfully",
"READ": "Read successfully",
"DELIVERED": "Delivered successfully",
"NO_MESSAGES": "No Messages", "NO_MESSAGES": "No Messages",
"NO_CONTENT": "No content available", "NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text", "HIDE_QUOTED_TEXT": "Hide Quoted Text",

View file

@ -1,6 +1,8 @@
export const MESSAGE_STATUS = { export const MESSAGE_STATUS = {
FAILED: 'failed', FAILED: 'failed',
SENT: 'sent', SENT: 'sent',
DELIVERED: 'delivered',
READ: 'read',
PROGRESS: 'progress', PROGRESS: 'progress',
}; };

View file

@ -65,8 +65,9 @@ class Message < ApplicationRecord
# [:in_reply_to] : Used to reply to a particular tweet in threads # [:in_reply_to] : Used to reply to a particular tweet in threads
# [:deleted] : Used to denote whether the message was deleted by the agent # [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally # [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted, store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
:external_created_at, :story_sender, :story_id], coder: JSON :external_created_at, :story_sender, :story_id, :external_error], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id

View file

@ -7,11 +7,38 @@ class Whatsapp::IncomingMessageBaseService
def perform def perform
processed_params processed_params
perform_statuses
set_contact set_contact
return unless @contact return unless @contact
set_conversation set_conversation
perform_messages
end
private
def perform_statuses
return if @processed_params[:statuses].blank?
status = @processed_params[:statuses].first
@message = Message.find_by(source_id: status[:id])
return unless @message
update_message_with_status(@message, status)
end
def update_message_with_status(message, status)
message.status = status[:status]
if status[:status] == 'failed' && status[:errors].present?
error = status[:errors]&.first
message.external_error = "#{error[:code]}: #{error[:title]}"
end
message.save!
end
def perform_messages
return if @processed_params[:messages].blank? || unprocessable_message_type? return if @processed_params[:messages].blank? || unprocessable_message_type?
@message = @conversation.messages.build( @message = @conversation.messages.build(
@ -27,8 +54,6 @@ class Whatsapp::IncomingMessageBaseService
@message.save! @message.save!
end end
private
def processed_params def processed_params
@processed_params ||= params @processed_params ||= params
end end

View file

@ -5,6 +5,7 @@ json.echo_id message.echo_id if message.echo_id
json.conversation_id message.conversation.display_id json.conversation_id message.conversation.display_id
json.message_type message.message_type_before_type_cast json.message_type message.message_type_before_type_cast
json.content_type message.content_type json.content_type message.content_type
json.status message.status
json.content_attributes message.content_attributes json.content_attributes message.content_attributes
json.created_at message.created_at.to_i json.created_at message.created_at.to_i
json.private message.private json.private message.private

View file

@ -70,6 +70,46 @@ describe Whatsapp::IncomingMessageService do
end end
end end
context 'when valid status params' do
let(:from) { '2423423243' }
let(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: from) }
let(:params) do
{
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => from }],
'messages' => [{ 'from' => from, 'id' => from, 'text' => { 'body' => 'Test' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
end
before do
create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end
it 'update status message to read' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'read' }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('read')
end
it 'update status message to failed' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'failed',
'errors' => [{ 'code': 123, 'title': 'abc' }] }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq('123: abc')
end
end
context 'when valid interactive message params' do context 'when valid interactive message params' do
it 'creates appropriate conversations, message and contacts' do it 'creates appropriate conversations, message and contacts' do
params = { params = {