diff --git a/.env.example b/.env.example index 39a533355..3a5600495 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,9 @@ FB_VERIFY_TOKEN= FB_APP_SECRET= FB_APP_ID= +# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard +IG_VERIFY_TOKEN + # Twitter # documentation: https://www.chatwoot.com/docs/twitter-app-setup TWITTER_APP_ID= diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 29579fd54..5410aa3c4 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -4,10 +4,11 @@ # 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::Facebook::MessageBuilder +class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder attr_reader :response def initialize(response, inbox, outgoing_echo: false) + super() @response = response @inbox = inbox @outgoing_echo = outgoing_echo @@ -47,30 +48,12 @@ class Messages::Facebook::MessageBuilder def build_message @message = conversation.messages.create!(message_params) + @attachments.each do |attachment| process_attachment(attachment) end end - def process_attachment(attachment) - return if attachment['type'].to_sym == :template - - attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) - attachment_obj.save! - attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] - end - - def attach_file(attachment, file_url) - attachment_file = Down.download( - file_url - ) - attachment.file.attach( - io: attachment_file, - filename: attachment_file.original_filename, - content_type: attachment_file.content_type - ) - end - def ensure_contact_avatar return if contact_params[:remote_avatar_url].blank? return if @contact.avatar.attached? @@ -89,28 +72,6 @@ class Messages::Facebook::MessageBuilder )) end - def attachment_params(attachment) - file_type = attachment['type'].to_sym - params = { file_type: file_type, account_id: @message.account_id } - - if [:image, :file, :audio, :video].include? file_type - params.merge!(file_type_params(attachment)) - elsif file_type == :location - params.merge!(location_params(attachment)) - elsif file_type == :fallback - params.merge!(fallback_params(attachment)) - end - - params - end - - def file_type_params(attachment) - { - external_url: attachment['payload']['url'], - remote_file_url: attachment['payload']['url'] - } - end - def location_params(attachment) lat = attachment['payload']['coordinates']['lat'] long = attachment['payload']['coordinates']['long'] diff --git a/app/builders/messages/instagram/message_builder.rb b/app/builders/messages/instagram/message_builder.rb new file mode 100644 index 000000000..18c82d813 --- /dev/null +++ b/app/builders/messages/instagram/message_builder.rb @@ -0,0 +1,150 @@ +# 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 + + 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 + @inbox.channel.authorization_error! + raise + rescue StandardError => e + Sentry.capture_exception(e) + true + end + + private + + def attachments + @messaging[:message][:attachments] || {} + end + + def message_type + @outgoing_echo ? :outgoing : :incoming + end + + def message_source_id + @outgoing_echo ? recipient_id : sender_id + 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 ||= Conversation.find_by(conversation_params) || build_conversation + end + + def message_content + @messaging[:message][:text] + end + + def content_attributes + { message_id: @messaging[:message][:mid] } + end + + def build_message + return if @outgoing_echo && already_sent_from_chatwoot? + + @message = conversation.messages.create!(message_params) + + attachments.each do |attachment| + process_attachment(attachment) + end + 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 + )) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id, + additional_attributes: { + type: 'instagram_direct_message' + } + } + end + + def message_params + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: message_type, + source_id: message_source_id, + content: message_content, + content_attributes: content_attributes, + sender: @outgoing_echo ? nil : contact + } + end + + def already_sent_from_chatwoot? + cw_message = conversation.messages.where( + source_id: nil, + message_type: 'outgoing', + content: message_content, + private: false, + status: :sent + ).first + cw_message.update(content_attributes: content_attributes) if cw_message.present? + cw_message.present? + 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/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb new file mode 100644 index 000000000..08aa58be0 --- /dev/null +++ b/app/builders/messages/messenger/message_builder.rb @@ -0,0 +1,42 @@ +class Messages::Messenger::MessageBuilder + def process_attachment(attachment) + return if attachment['type'].to_sym == :template + + attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) + attachment_obj.save! + attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url] + end + + def attach_file(attachment, file_url) + attachment_file = Down.download( + file_url + ) + attachment.file.attach( + io: attachment_file, + filename: attachment_file.original_filename, + content_type: attachment_file.content_type + ) + end + + def attachment_params(attachment) + file_type = attachment['type'].to_sym + params = { file_type: file_type, account_id: @message.account_id } + + if [:image, :file, :audio, :video].include? file_type + params.merge!(file_type_params(attachment)) + elsif file_type == :location + params.merge!(location_params(attachment)) + elsif file_type == :fallback + params.merge!(fallback_params(attachment)) + end + + params + end + + def file_type_params(attachment) + { + external_url: attachment['payload']['url'], + remote_file_url: attachment['payload']['url'] + } + end +end diff --git a/app/controllers/api/v1/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 07a6b4d71..aa0524568 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -12,6 +12,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController page_access_token: page_access_token ) @facebook_inbox = Current.account.inboxes.create!(name: inbox_name, channel: facebook_channel) + set_instagram_id(page_access_token, facebook_channel) set_avatar(@facebook_inbox, page_id) rescue StandardError => e Rails.logger.info e @@ -22,6 +23,15 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController @page_details = mark_already_existing_facebook_pages(fb_object.get_connections('me', 'accounts')) end + def set_instagram_id(page_access_token, facebook_channel) + fb_object = Koala::Facebook::API.new(page_access_token) + response = fb_object.get_connections('me', '', { fields: 'instagram_business_account' }) + return if response['instagram_business_account'].blank? + + instagram_id = response['instagram_business_account']['id'] + facebook_channel.update(instagram_id: instagram_id) + end + # get params[:inbox_id], current_account. params[:omniauth_token] def reauthorize_page if @inbox&.facebook? diff --git a/app/controllers/api/v1/instagram_callbacks_controller.rb b/app/controllers/api/v1/instagram_callbacks_controller.rb new file mode 100644 index 000000000..0c7e7a94c --- /dev/null +++ b/app/controllers/api/v1/instagram_callbacks_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::InstagramCallbacksController < ApplicationController + skip_before_action :authenticate_user!, raise: false + skip_before_action :set_current_user + + def verify + if valid_instagram_token?(params['hub.verify_token']) + Rails.logger.info('Instagram webhook verified') + render json: params['hub.challenge'] + else + render json: { error: 'Error; wrong verify token', status: 403 } + end + end + + def events + Rails.logger.info('Instagram webhook received events') + if params['object'].casecmp('instagram').zero? + ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) + render json: :ok + else + Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") + head :unprocessable_entity + end + end + + private + + def valid_instagram_token?(token) + token == ENV['IG_VERIFY_TOKEN'] + end +end diff --git a/app/javascript/dashboard/assets/images/channels/messenger.png b/app/javascript/dashboard/assets/images/channels/messenger.png new file mode 100644 index 000000000..f1bbb68d4 Binary files /dev/null and b/app/javascript/dashboard/assets/images/channels/messenger.png differ diff --git a/app/javascript/dashboard/assets/images/instagram_direct.png b/app/javascript/dashboard/assets/images/instagram_direct.png new file mode 100755 index 000000000..ca40657b0 Binary files /dev/null and b/app/javascript/dashboard/assets/images/instagram_direct.png differ diff --git a/app/javascript/dashboard/assets/images/messenger_direct.png b/app/javascript/dashboard/assets/images/messenger_direct.png new file mode 100644 index 000000000..00b544a83 Binary files /dev/null and b/app/javascript/dashboard/assets/images/messenger_direct.png differ diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 183f42c10..07a984f04 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -6,7 +6,7 @@ > + @@ -73,9 +73,23 @@ export default { uiFlags: 'inboxAssignableAgents/getUIFlags', currentChat: 'getSelectedChat', }), + + chatExtraAttributes() { + return this.chat.additional_attributes; + }, + chatMetadata() { return this.chat.meta; }, + + chatBadge() { + if(this.chatExtraAttributes['type']){ + return this.chatExtraAttributes['type'] + } else { + return this.chatMetadata.channel + } + }, + currentContact() { return this.$store.getters['contacts/getContact']( this.chat.meta.sender.id diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index 086b2f27a..27b3cbc73 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -40,7 +40,7 @@ export default { const { apiChannelName, apiChannelThumbnail } = this.globalConfig; return [ { key: 'website', name: 'Website' }, - { key: 'facebook', name: 'Facebook' }, + { key: 'facebook', name: 'Messenger' }, { key: 'twitter', name: 'Twitter' }, { key: 'whatsapp', name: 'WhatsApp via Twilio' }, { key: 'sms', name: 'SMS via Twilio' }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue index 96cb95c1c..338d7bbeb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue @@ -206,7 +206,7 @@ export default { } }, { - scope: 'pages_manage_metadata,pages_messaging', + scope: 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,instagram_manage_messages', } ); }, diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index b834c41ea..8e73433f6 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -3,10 +3,16 @@ class SendReplyJob < ApplicationJob def perform(message_id) message = Message.find(message_id) - channel_name = message.conversation.inbox.channel.class.to_s + conversation = message.conversation + channel_name = conversation.inbox.channel.class.to_s + case channel_name when 'Channel::FacebookPage' - ::Facebook::SendOnFacebookService.new(message: message).perform + if conversation.additional_attributes['type'] == 'instagram_direct_message' + ::Instagram::SendOnInstagramService.new(message: message).perform + else + ::Facebook::SendOnFacebookService.new(message: message).perform + end when 'Channel::TwitterProfile' ::Twitter::SendOnTwitterService.new(message: message).perform when 'Channel::TwilioSms' diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb new file mode 100644 index 000000000..ef077c7ee --- /dev/null +++ b/app/jobs/webhooks/instagram_events_job.rb @@ -0,0 +1,84 @@ +class Webhooks::InstagramEventsJob < ApplicationJob + queue_as :default + + include HTTParty + + base_uri 'https://graph.facebook.com/v11.0/me' + + # @return [Array] We will support further events like reaction or seen in future + SUPPORTED_EVENTS = [:message].freeze + + # @see https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook + def perform(entries) + @entries = entries + + if @entries[0].key?(:changes) + Rails.logger.info('Probably Test data.') + # grab the test entry for the review app + create_test_text + return + end + + @entries.each do |entry| + entry[:messaging].each do |messaging| + send(@event_name, messaging) if event_name(messaging) + end + end + end + + private + + def event_name(messaging) + @event_name ||= SUPPORTED_EVENTS.find { |key| messaging.key?(key) } + end + + def message(messaging) + ::Instagram::MessageText.new(messaging).perform + end + + def create_test_text + messenger_channel = Channel::FacebookPage.last + @inbox = ::Inbox.find_by!(channel: messenger_channel) + @contact_inbox = @inbox.contact_inboxes.where(source_id: 'sender_username').first + unless @contact_inbox + @contact_inbox ||= @inbox.channel.create_contact_inbox( + 'sender_username', 'sender_username' + ) + end + @contact = @contact_inbox.contact + + @conversation ||= Conversation.find_by(conversation_params) || build_conversation(conversation_params) + + @message = @conversation.messages.create!(message_params) + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + additional_attributes: { + type: 'instagram_direct_message' + } + } + end + + def message_params + { + account_id: @conversation.account_id, + inbox_id: @conversation.inbox_id, + message_type: 'incoming', + source_id: 'facebook_test_webhooks', + content: 'This is a test message from facebook.', + sender: @contact + } + end + + def build_conversation(conversation_params) + Conversation.create!( + conversation_params.merge( + contact_inbox_id: @contact_inbox.id + ) + ) + end +end diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index d564d0048..6735d5e35 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -8,6 +8,7 @@ # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null +# instagram_id :string # page_id :string not null # # Indexes @@ -35,6 +36,19 @@ class Channel::FacebookPage < ApplicationRecord true end + def create_contact_inbox(instagram_id, name) + ActiveRecord::Base.transaction do + contact = inbox.account.contacts.create!(name: name) + ::ContactInbox.create( + contact_id: contact.id, + inbox_id: inbox.id, + source_id: instagram_id + ) + rescue StandardError => e + Rails.logger.info e + end + end + def subscribe # ref https://developers.facebook.com/docs/messenger-platform/reference/webhook-events response = Facebook::Messenger::Subscriptions.subscribe( diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb new file mode 100644 index 000000000..b4bfc1ed5 --- /dev/null +++ b/app/services/instagram/message_text.rb @@ -0,0 +1,49 @@ +class Instagram::MessageText < Instagram::WebhooksBaseService + include HTTParty + + attr_reader :messaging + + base_uri 'https://graph.facebook.com/v11.0/' + + def initialize(messaging) + super() + @messaging = messaging + end + + def perform + instagram_id, contact_id = if agent_message_via_echo? + [@messaging[:sender][:id], @messaging[:recipient][:id]] + else + [@messaging[:recipient][:id], @messaging[:sender][:id]] + end + inbox_channel(instagram_id) + ensure_contact(contact_id) + + create_message + end + + private + + def ensure_contact(ig_scope_id) + begin + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + result = k.get_object(ig_scope_id) || {} + rescue Koala::Facebook::AuthenticationError + @inbox.channel.authorization_error! + raise + rescue StandardError => e + result = {} + Sentry.capture_exception(e) + end + + find_or_create_contact(result) + end + + def agent_message_via_echo? + @messaging[:message][:is_echo].present? + end + + def create_message + Messages::Instagram::MessageBuilder.new(@messaging, @inbox, outgoing_echo: agent_message_via_echo?).perform + end +end diff --git a/app/services/instagram/send_on_instagram_service.rb b/app/services/instagram/send_on_instagram_service.rb new file mode 100644 index 000000000..17ba76cb1 --- /dev/null +++ b/app/services/instagram/send_on_instagram_service.rb @@ -0,0 +1,99 @@ +class Instagram::SendOnInstagramService < Base::SendOnChannelService + include HTTParty + + pattr_initialize [:message!] + + base_uri 'https://graph.facebook.com/v11.0/me' + + private + + delegate :additional_attributes, to: :contact + + def channel_class + Channel::FacebookPage + end + + def perform_reply + send_to_facebook_page attachament_message_params if message.attachments.present? + send_to_facebook_page message_params + rescue StandardError => e + Sentry.capture_exception(e) + channel.authorization_error! + end + + def message_params + { + recipient: { id: contact.get_source_id(inbox.id) }, + message: { + text: message.content + } + } + end + + def attachament_message_params + attachment = message.attachments.first + { + recipient: { id: contact.get_source_id(inbox.id) }, + message: { + attachment: { + type: attachment_type(attachment), + payload: { + url: attachment.file_url + } + } + } + } + end + + # Deliver a message with the given payload. + # @see https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message + def send_to_facebook_page(message_content) + access_token = channel.page_access_token + app_secret_proof = calculate_app_secret_proof(ENV['FB_APP_SECRET'], access_token) + + query = { access_token: access_token } + query[:appsecret_proof] = app_secret_proof if app_secret_proof + + # url = "https://graph.facebook.com/v11.0/me/messages?access_token=#{access_token}" + + response = HTTParty.post( + 'https://graph.facebook.com/v11.0/me/messages', + body: message_content, + query: query + ) + # response = HTTParty.post(url, options) + + Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body][:error] + + response[:body] + end + + def calculate_app_secret_proof(app_secret, access_token) + Facebook::Messenger::Configuration::AppSecretProofCalculator.call( + app_secret, access_token + ) + end + + def attachment_type(attachment) + return attachment.file_type if %w[image audio video file].include? attachment.file_type + + 'file' + end + + def conversation_type + conversation.additional_attributes['type'] + end + + def sent_first_outgoing_message_after_24_hours? + # we can send max 1 message after 24 hour window + conversation.messages.outgoing.where('id > ?', last_incoming_message.id).count == 1 + end + + def last_incoming_message + conversation.messages.incoming.last + end + + def config + Facebook::Messenger.config + end +end diff --git a/app/services/instagram/webhooks_base_service.rb b/app/services/instagram/webhooks_base_service.rb new file mode 100644 index 000000000..2a534ac82 --- /dev/null +++ b/app/services/instagram/webhooks_base_service.rb @@ -0,0 +1,21 @@ +class Instagram::WebhooksBaseService + private + + def inbox_channel(instagram_id) + messenger_channel = Channel::FacebookPage.where(instagram_id: instagram_id) + @inbox = ::Inbox.find_by!(channel: messenger_channel) + end + + def find_or_create_contact(user) + @contact_inbox = @inbox.contact_inboxes.where(source_id: user['id']).first + @contact = @contact_inbox.contact if @contact_inbox + return if @contact + + @contact_inbox = @inbox.channel.create_contact_inbox( + user['id'], user['name'] + ) + + @contact = @contact_inbox.contact + ContactAvatarJob.perform_later(@contact, user['profile_pic']) if user['profile_pic'] + end +end diff --git a/config/routes.rb b/config/routes.rb index a1c4ceff5..871490d2b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -251,6 +251,8 @@ Rails.application.routes.draw do post 'webhooks/twitter', to: 'api/v1/webhooks#twitter_events' post 'webhooks/line/:line_channel_id', to: 'webhooks/line#process_payload' post 'webhooks/telegram/:bot_token', to: 'webhooks/telegram#process_payload' + get 'instagram_callbacks/event', to: 'api/v1/instagram_callbacks#verify' + post 'instagram_callbacks/event', to: 'api/v1/instagram_callbacks#events' namespace :twitter do resource :callback, only: [:show] diff --git a/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb b/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb new file mode 100644 index 000000000..391fac835 --- /dev/null +++ b/db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb @@ -0,0 +1,9 @@ +class AddInstagramIdToFacebookPage < ActiveRecord::Migration[6.1] + def up + add_column :channel_facebook_pages, :instagram_id, :string + end + + def down + remove_column :channel_facebook_pages, :instagram_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2b3d5d274..b7040e19a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -181,6 +181,7 @@ ActiveRecord::Schema.define(version: 2021_09_22_082754) do t.integer "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "instagram_id" t.index ["page_id", "account_id"], name: "index_channel_facebook_pages_on_page_id_and_account_id", unique: true t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id" end @@ -244,6 +245,28 @@ ActiveRecord::Schema.define(version: 2021_09_22_082754) do t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true end + create_table "companies", force: :cascade do |t| + t.string "name", null: false + t.text "address" + t.string "city", null: false + t.string "state" + t.string "country", null: false + t.integer "no_of_employees", null: false + t.string "industry_type" + t.bigint "annual_revenue" + t.text "website" + t.string "office_phone_number" + t.string "facebook" + t.string "twitter" + t.string "linkedin" + t.jsonb "additional_attributes" + t.bigint "contact_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["contact_id"], name: "index_companies_on_contact_id" + t.index ["name"], name: "index_companies_on_name", unique: true + end + create_table "contact_inboxes", force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" diff --git a/spec/builders/messages/instagram/message_builder_spec.rb b/spec/builders/messages/instagram/message_builder_spec.rb new file mode 100644 index 000000000..7f9395af9 --- /dev/null +++ b/spec/builders/messages/instagram/message_builder_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +describe ::Messages::Instagram::MessageBuilder do + subject(:instagram_message_builder) { described_class } + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + let(:fb_object) { double } + let(:contact) { create(:contact, id: 'Sender-id-1', name: 'Jane Dae') } + let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: instagram_inbox.id, source_id: 'Sender-id-1') } + + describe '#perform' do + it 'creates contact and message for the facebook inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + messaging = dm_params[:entry][0]['messaging'][0] + contact_inbox + instagram_message_builder.new(messaging, instagram_inbox).perform + + instagram_inbox.reload + + expect(instagram_inbox.conversations.count).to be 1 + expect(instagram_inbox.messages.count).to be 1 + + contact = instagram_channel.inbox.contacts.first + message = instagram_channel.inbox.messages.first + + expect(contact.name).to eq('Jane Dae') + expect(message.content).to eq('This is the first message from the customer') + end + end +end diff --git a/spec/factories/channel/insatgram_channel.rb b/spec/factories/channel/insatgram_channel.rb new file mode 100644 index 000000000..e2192d683 --- /dev/null +++ b/spec/factories/channel/insatgram_channel.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :channel_instagram_fb_page, class: 'Channel::FacebookPage' do + page_access_token { SecureRandom.uuid } + user_access_token { SecureRandom.uuid } + page_id { SecureRandom.uuid } + account + end +end diff --git a/spec/factories/instagram/instagram_message_create_event.rb b/spec/factories/instagram/instagram_message_create_event.rb new file mode 100644 index 000000000..d0dbffdab --- /dev/null +++ b/spec/factories/instagram/instagram_message_create_event.rb @@ -0,0 +1,58 @@ +FactoryBot.define do + factory :instagram_message_create_event, class: Hash do + entry do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'messaging': [ + { + 'sender': { + 'id': 'Sender-id-1' + }, + 'recipient': { + 'id': 'chatwoot-app-user-id-1' + }, + 'timestamp': '2021-09-08T06:34:04+0000', + 'message': { + 'mid': 'message-id-1', + 'text': 'This is the first message from the customer' + } + } + ] + } + ] + end + initialize_with { attributes } + end + + factory :instagram_test_text_event, class: Hash do + entry do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'changes': [ + { + 'field': 'messages', + 'value': { + 'event_type': 'TEXT', + 'event_timestamp': '1527459824', + 'event_data': { + 'message_id': 'vcvacopiufqwehfawdnb', + 'sender': { + 'username': 'sender_username' + }, + 'recipient': { + 'thread_id': 'faeoqiehrkbfadsfawd' + } + } + } + } + ] + } + ] + end + initialize_with { attributes } + end +end diff --git a/spec/factories/instagram_message/incoming_messages.rb b/spec/factories/instagram_message/incoming_messages.rb new file mode 100644 index 000000000..dce5af627 --- /dev/null +++ b/spec/factories/instagram_message/incoming_messages.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :incoming_ig_text_message, class: Hash do + messaging do + [ + { + 'id': 'instagram-message-id-123', + 'time': '2021-09-08T06:34:04+0000', + 'messaging': [ + { + 'sender': { + 'id': 'Sender-id-1' + }, + 'recipient': { + 'id': 'chatwoot-app-user-id-1' + }, + 'timestamp': '2021-09-08T06:34:04+0000', + 'message': { + 'mid': 'message-id-1', + 'text': 'This is the first message from the customer' + } + } + ] + } + ] + end + + initialize_with { attributes } + end +end diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb new file mode 100644 index 000000000..0f5d0c4a3 --- /dev/null +++ b/spec/jobs/webhooks/instagram_events_job_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' +require 'webhooks/twitter' + +describe Webhooks::InstagramEventsJob do + subject(:instagram_webhook) { described_class } + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access } + let(:fb_object) { double } + + describe '#perform' do + context 'with direct_message params' do + it 'creates incoming message in the instagram inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + instagram_webhook.perform_now(dm_params[:entry]) + + instagram_inbox.reload + + expect(instagram_inbox.contacts.count).to be 1 + expect(instagram_inbox.conversations.count).to be 1 + expect(instagram_inbox.messages.count).to be 1 + end + + it 'creates test text message in the instagram inbox' do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { + name: 'Jane', + id: 'Sender-id-1', + account_id: instagram_inbox.account_id, + profile_pic: 'https://via.placeholder.com/250x250.png' + }.with_indifferent_access + ) + instagram_webhook.perform_now(test_params[:entry]) + + instagram_inbox.reload + + expect(instagram_inbox.messages.count).to be 1 + expect(instagram_inbox.messages.last.content).to eq('This is a test message from facebook.') + end + end + end +end diff --git a/spec/services/instagram/send_on_instagram_service_spec.rb b/spec/services/instagram/send_on_instagram_service_spec.rb new file mode 100644 index 000000000..0c5e754f1 --- /dev/null +++ b/spec/services/instagram/send_on_instagram_service_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe Instagram::SendOnInstagramService do + subject(:send_reply_service) { described_class.new(message: message) } + + before do + create(:message, message_type: :incoming, inbox: instagram_inbox, account: account, conversation: conversation) + end + + let!(:account) { create(:account) } + let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') } + let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) } + let!(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: instagram_inbox) } + let(:conversation) { create(:conversation, contact: contact, inbox: instagram_inbox, contact_inbox: contact_inbox) } + let(:response) { double } + + describe '#perform' do + context 'with reply' do + before do + allow(Facebook::Messenger::Configuration::AppSecretProofCalculator).to receive(:call).and_return('app_secret_key', 'access_token') + allow(HTTParty).to receive(:post).and_return( + { + body: { recipient: { id: contact_inbox.source_id } } + } + ) + end + + it 'if message is sent from chatwoot and is outgoing' do + message = create(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) + response = ::Instagram::SendOnInstagramService.new(message: message).perform + expect(response).to eq({ recipient: { id: contact_inbox.source_id } }) + end + + it 'if message with attachment is sent from chatwoot and is outgoing' do + message = build(:message, message_type: 'outgoing', inbox: instagram_inbox, account: account, conversation: conversation) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/avatar.png')), filename: 'avatar.png', content_type: 'image/png') + message.save! + response = ::Instagram::SendOnInstagramService.new(message: message).perform + expect(response).to eq({ recipient: { id: contact_inbox.source_id } }) + end + end + end +end