diff --git a/.github/workflows/frontend-fe.yml b/.github/workflows/frontend-fe.yml index 50ed8d005..a456d7092 100644 --- a/.github/workflows/frontend-fe.yml +++ b/.github/workflows/frontend-fe.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/nightly_installer.yml b/.github/workflows/nightly_installer.yml index a01ba1093..beef5727c 100644 --- a/.github/workflows/nightly_installer.yml +++ b/.github/workflows/nightly_installer.yml @@ -2,7 +2,7 @@ # # # # Linux nightly installer action # # This action will try to install and setup -# # chatwoot on an Ubuntu 20.04 machine using +# # chatwoot on an Ubuntu 22.04 machine using # # the linux installer script. # # # # This is set to run daily at midnight. diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index dd8a0bdca..10732d018 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -8,7 +8,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: postgres: image: pgvector/pgvector:pg15 diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 724f69ec3..9be79da05 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -7,7 +7,7 @@ on: jobs: test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 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..7767ee1b4 --- /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 false 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 a36d6f018..e306c7b70 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 false 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..41ba59f2c --- /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 4866769fd..9449ef084 100644 --- a/app/builders/messages/messenger/message_builder.rb +++ b/app/builders/messages/messenger/message_builder.rb @@ -68,20 +68,7 @@ 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 + def get_story_object_from_source_id(_source_id) {} end diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 01aa42620..438944f04 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def allowed_agent_params - [:name, :email, :name, :role, :availability, :auto_offline] + [:name, :email, :role, :availability, :auto_offline] end def agent_params 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/assets/scss/_next-colors.scss b/app/javascript/dashboard/assets/scss/_next-colors.scss index 48cfce921..f23c01d42 100644 --- a/app/javascript/dashboard/assets/scss/_next-colors.scss +++ b/app/javascript/dashboard/assets/scss/_next-colors.scss @@ -29,6 +29,19 @@ --iris-11: 87 83 198; --iris-12: 39 41 98; + --blue-1: 251 253 255; + --blue-2: 245 249 255; + --blue-3: 233 243 255; + --blue-4: 218 236 255; + --blue-5: 201 226 255; + --blue-6: 181 213 255; + --blue-7: 155 195 252; + --blue-8: 117 171 247; + --blue-9: 39 129 246; + --blue-10: 16 115 233; + --blue-11: 8 109 224; + --blue-12: 11 50 101; + --ruby-1: 255 252 253; --ruby-2: 255 247 248; --ruby-3: 254 234 237; @@ -131,6 +144,19 @@ --iris-11: 158 177 255; --iris-12: 224 223 254; + --blue-1: 10 17 28; + --blue-2: 15 24 38; + --blue-3: 15 39 72; + --blue-4: 10 49 99; + --blue-5: 18 61 117; + --blue-6: 29 84 134; + --blue-7: 40 89 156; + --blue-8: 48 106 186; + --blue-9: 39 129 246; + --blue-10: 21 116 231; + --blue-11: 126 182 255; + --blue-12: 205 227 255; + --ruby-1: 25 17 19; --ruby-2: 30 21 23; --ruby-3: 58 20 30; diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue index 14d944b3e..75400692e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/CreateNewContactDialog.vue @@ -51,6 +51,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });