diff --git a/app/builders/contact_inbox_with_contact_builder.rb b/app/builders/contact_inbox_with_contact_builder.rb index 2f30093db..2c0e6087e 100644 --- a/app/builders/contact_inbox_with_contact_builder.rb +++ b/app/builders/contact_inbox_with_contact_builder.rb @@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder contact = find_contact_by_identifier(contact_attributes[:identifier]) contact ||= find_contact_by_email(contact_attributes[:email]) contact ||= find_contact_by_phone_number(contact_attributes[:phone_number]) + contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel? + contact end + def instagram_channel? + inbox.channel_type == 'Channel::Instagram' + end + + # There might be existing contact_inboxes created through Channel::FacebookPage + # with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes + # while still reusing contacts if found in Facebook channels so that we can create + # new conversations with the same contact. + def find_contact_by_instagram_source_id(instagram_id) + return if instagram_id.blank? + + existing_contact_inbox = ContactInbox.joins(:inbox) + .where(source_id: instagram_id) + .where( + 'inboxes.channel_type = ? AND inboxes.account_id = ?', + 'Channel::FacebookPage', + account.id + ).first + + existing_contact_inbox&.contact + end + def find_contact_by_identifier(identifier) return if identifier.blank? diff --git a/app/builders/messages/instagram/base_message_builder.rb b/app/builders/messages/instagram/base_message_builder.rb new file mode 100644 index 000000000..b8f892d97 --- /dev/null +++ b/app/builders/messages/instagram/base_message_builder.rb @@ -0,0 +1,179 @@ +class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder + attr_reader :messaging + + def initialize(messaging, inbox, outgoing_echo: false) + super() + @messaging = messaging + @inbox = inbox + @outgoing_echo = outgoing_echo + end + + def perform + return if @inbox.channel.reauthorization_required? + + ActiveRecord::Base.transaction do + build_message + end + rescue StandardError => e + handle_error(e) + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_identifier + message[:mid] + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + end + + def message_is_unsupported? + message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true + end + + def sender_id + @messaging[:sender][:id] + end + + def recipient_id + @messaging[:recipient][:id] + end + + def message + @messaging[:message] + end + + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact + end + + def conversation + @conversation ||= set_conversation_based_on_inbox_config + end + + def set_conversation_based_on_inbox_config + if @inbox.lock_to_single_conversation + find_conversation_scope.order(created_at: :desc).first || build_conversation + else + find_or_build_for_multiple_conversations + end + end + + def find_conversation_scope + Conversation.where(conversation_params) + end + + def find_or_build_for_multiple_conversations + last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first + return build_conversation if last_conversation.nil? + + last_conversation + end + + def message_content + @messaging[:message][:text] + end + + def story_reply_attributes + message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? + end + + def message_reply_attributes + message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? + end + + def build_message + # Duplicate webhook events may be sent for the same message + # when a user is connected to the Instagram account through both Messenger and Instagram login. + # Therefore, we need to check if the message already exists before creating it. + return if message_already_exists? + + return if @outgoing_echo + + return if message_content.blank? && all_unsupported_files? + + @message = conversation.messages.create!(message_params) + save_story_id + + attachments.each do |attachment| + process_attachment(attachment) + end + end + + def save_story_id + return if story_reply_attributes.blank? + + @message.save_story_info(story_reply_attributes) + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id, + additional_attributes: additional_conversation_attributes + )) + end + + def additional_conversation_attributes + {} + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + params = { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_identifier, + content: message_content, + sender: @outgoing_echo ? nil : contact, + content_attributes: { + in_reply_to_external_id: message_reply_attributes + } + } + + params[:content_attributes][:is_unsupported] = true if message_is_unsupported? + params + end + + def message_already_exists? + cw_message = conversation.messages.where( + source_id: @messaging[:message][:mid] + ).first + + cw_message.present? + end + + def all_unsupported_files? + return if attachments.empty? + + attachments_type = attachments.pluck(:type).uniq.first + unsupported_file_type?(attachments_type) + end + + def handle_error(error) + ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception + true + end + + # Abstract methods to be implemented by subclasses + def get_story_object_from_source_id(source_id) + raise NotImplementedError + end +end diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb index 7f1b9cab2..4e7150894 100644 --- a/app/builders/messages/instagram/message_builder.rb +++ b/app/builders/messages/instagram/message_builder.rb @@ -1,200 +1,42 @@ -# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo` -# Assumptions -# 1. Incase of an outgoing message which is echo, source_id will NOT be nil, -# based on this we are showing "not sent from chatwoot" message in frontend -# Hence there is no need to set user_id in message for outgoing echo messages. - -class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder - attr_reader :messaging - +class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder def initialize(messaging, inbox, outgoing_echo: false) - super() - @messaging = messaging - @inbox = inbox - @outgoing_echo = outgoing_echo - end - - def perform - return if @inbox.channel.reauthorization_required? - - ActiveRecord::Base.transaction do - build_message - end - rescue Koala::Facebook::AuthenticationError => e - Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}") - Rails.logger.error e - @inbox.channel.authorization_error! - raise - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception - true + super(messaging, inbox, outgoing_echo: outgoing_echo) end private - def attachments - @messaging[:message][:attachments] || {} + def get_story_object_from_source_id(source_id) + url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}" + + response = HTTParty.get(url) + + return JSON.parse(response.body).with_indifferent_access if response.success? + + # Create message first if it doesn't exist + @message ||= conversation.messages.create!(message_params) + handle_error_response(response) + nil end - def message_type - @outgoing_echo ? :outgoing : :incoming - end + def handle_error_response(response) + parsed_response = JSON.parse(response.body) + error_code = parsed_response.dig('error', 'code') - def message_identifier - message[:mid] - end + # https://developers.facebook.com/docs/messenger-platform/error-codes + # Access token has expired or become invalid. + channel.authorization_error! if error_code == 190 - def message_source_id - @outgoing_echo ? recipient_id : sender_id - end - - def message_is_unsupported? - message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true - end - - def sender_id - @messaging[:sender][:id] - end - - def recipient_id - @messaging[:recipient][:id] - end - - def message - @messaging[:message] - end - - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact - end - - def conversation - @conversation ||= set_conversation_based_on_inbox_config - end - - def instagram_direct_message_conversation - Conversation.where(conversation_params) - .where("additional_attributes ->> 'type' = 'instagram_direct_message'") - end - - def set_conversation_based_on_inbox_config - if @inbox.lock_to_single_conversation - instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation - else - find_or_build_for_multiple_conversations + # There was a problem scraping data from the provided link. + # https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005 + if error_code == 1_609_005 + @message.attachments.destroy_all + @message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content')) end + + Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}") end - def find_or_build_for_multiple_conversations - last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first - - return build_conversation if last_conversation.nil? - - last_conversation + def base_uri + "https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}" end - - def message_content - @messaging[:message][:text] - end - - def story_reply_attributes - message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present? - end - - def message_reply_attributes - message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present? - end - - def build_message - return if @outgoing_echo && already_sent_from_chatwoot? - return if message_content.blank? && all_unsupported_files? - - @message = conversation.messages.create!(message_params) - save_story_id - - attachments.each do |attachment| - process_attachment(attachment) - end - end - - def save_story_id - return if story_reply_attributes.blank? - - @message.save_story_info(story_reply_attributes) - end - - def build_conversation - @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) - - Conversation.create!(conversation_params.merge( - contact_inbox_id: @contact_inbox.id, - additional_attributes: { type: 'instagram_direct_message' } - )) - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - params = { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: message_type, - source_id: message_identifier, - content: message_content, - sender: @outgoing_echo ? nil : contact, - content_attributes: { - in_reply_to_external_id: message_reply_attributes - } - } - - params[:content_attributes][:is_unsupported] = true if message_is_unsupported? - params - end - - def already_sent_from_chatwoot? - cw_message = conversation.messages.where( - source_id: @messaging[:message][:mid] - ).first - - cw_message.present? - end - - def all_unsupported_files? - return if attachments.empty? - - attachments_type = attachments.pluck(:type).uniq.first - unsupported_file_type?(attachments_type) - end - - ### Sample response - # { - # "object": "instagram", - # "entry": [ - # { - # "id": "",// ig id of the business - # "time": 1569262486134, - # "messaging": [ - # { - # "sender": { - # "id": "" - # }, - # "recipient": { - # "id": "" - # }, - # "timestamp": 1569262485349, - # "message": { - # "mid": "", - # "text": "" - # } - # } - # ] - # } - # ], - # } end diff --git a/app/builders/messages/instagram/messenger/message_builder.rb b/app/builders/messages/instagram/messenger/message_builder.rb new file mode 100644 index 000000000..1263dee90 --- /dev/null +++ b/app/builders/messages/instagram/messenger/message_builder.rb @@ -0,0 +1,33 @@ +class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder + def initialize(messaging, inbox, outgoing_echo: false) + super(messaging, inbox, outgoing_echo: outgoing_echo) + end + + private + + def get_story_object_from_source_id(source_id) + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + k.get_object(source_id, fields: %w[story from]) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + 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 + {} + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + {} + end + + def find_conversation_scope + Conversation.where(conversation_params) + .where("additional_attributes ->> 'type' = 'instagram_direct_message'") + end + + def additional_conversation_attributes + { type: 'instagram_direct_message' } + end +end diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb index f01a236c9..4e7f2849d 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder message.save! end - def get_story_object_from_source_id(source_id) - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - k.get_object(source_id, fields: %w[story from]) || {} - rescue Koala::Facebook::AuthenticationError - @inbox.channel.authorization_error! - 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 - {} - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception + # This is a placeholder method to be overridden by child classes + def get_story_object_from_source_id(_source_id) {} end diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb index f4dcfa010..04de9e63c 100644 --- a/app/controllers/concerns/instagram_concern.rb +++ b/app/controllers/concerns/instagram_concern.rb @@ -1,6 +1,5 @@ module InstagramConcern extend ActiveSupport::Concern - include HTTParty def instagram_client ::OAuth2::Client.new( diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index 5b00376d1..9c0a24925 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -12,6 +12,7 @@ export function useChannelIcon(inbox) { 'Channel::TwitterProfile': 'i-ri-twitter-x-fill', 'Channel::WebWidget': 'i-ri-global-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill', + 'Channel::Instagram': 'i-ri-instagram-fill', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index 41a0a3d15..d2ddd2c77 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -19,6 +19,7 @@ const { isAWebWidgetInbox, isAWhatsAppChannel, isAnEmailChannel, + isAInstagramChannel, } = useInbox(); const { status, isPrivate, createdAt, sourceId, messageType } = @@ -47,7 +48,8 @@ const isSent = computed(() => { isATwilioChannel.value || isAFacebookInbox.value || isASmsInbox.value || - isATelegramChannel.value + isATelegramChannel.value || + isAInstagramChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } @@ -86,7 +88,8 @@ const isRead = computed(() => { if ( isAWhatsAppChannel.value || isATwilioChannel.value || - isAFacebookInbox.value + isAFacebookInbox.value || + isAInstagramChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.READ; } @@ -102,7 +105,6 @@ const statusToShow = computed(() => { if (isRead.value) return MESSAGE_STATUS.READ; if (isDelivered.value) return MESSAGE_STATUS.DELIVERED; if (isSent.value) return MESSAGE_STATUS.SENT; - if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED; return MESSAGE_STATUS.PROGRESS; }); diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index eb665cc53..80f328980 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -32,6 +32,10 @@ export default { return this.enabledFeatures.channel_email; } + if (key === 'instagram') { + return this.enabledFeatures.channel_instagram; + } + return [ 'website', 'twilio', @@ -40,6 +44,7 @@ export default { 'sms', 'telegram', 'line', + 'instagram', ].includes(key); }, }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 992f61929..e8a9ba827 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -261,7 +261,8 @@ export default { this.isAnEmailChannel || this.isASmsInbox || this.isATelegramChannel || - this.isALineChannel + this.isALineChannel || + this.isAInstagramChannel ); }, replyButtonLabel() { @@ -1076,7 +1077,7 @@ export default { v-if="showSelfAssignBanner" action-button-variant="ghost" color-scheme="secondary" - class="banner--self-assign mx-2 mb-2 rounded-lg" + class="mx-2 mb-2 rounded-lg banner--self-assign" :banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')" has-action-button :action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')" diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index b8e399665..2c59bc6e5 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -121,6 +121,10 @@ export const useInbox = () => { ); }); + const isAInstagramChannel = computed(() => { + return channelType.value === INBOX_TYPES.INSTAGRAM; + }); + return { inbox, isAFacebookInbox, @@ -137,5 +141,6 @@ export const useInbox = () => { isAWhatsAppCloudChannel, is360DialogWhatsAppChannel, isAnEmailChannel, + isAInstagramChannel, }; }; diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index f7430bdba..1a6ada9fa 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -34,6 +34,7 @@ export const FEATURE_FLAGS = { CUSTOM_ROLES: 'custom_roles', CHATWOOT_V4: 'chatwoot_v4', REPORT_V4: 'report_v4', + CHANNEL_INSTAGRAM: 'channel_instagram', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', }; diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 5e26a34c0..c70a2d348 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -48,6 +48,7 @@ }, "INSTAGRAM": { "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", + "CONNECT_YOUR_INSTAGRAM_PROFILE": "Connect your Instagram Profile", "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", "ERROR_AUTH": "There was an error connecting to Instagram, please try again" diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 9e91f0b72..a404cbea6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -35,6 +35,8 @@ export default { }, { key: 'telegram', name: 'Telegram' }, { key: 'line', name: 'Line' }, + // TODO: Add Instagram to the channel list after the feature is ready to use. + // { key: 'instagram', name: 'Instagram' }, ]; }, ...mapGetters({ @@ -62,7 +64,7 @@ export default {