From 30244f79a6d72c678e459d235187903ff9dcdab1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 4 Oct 2021 10:19:44 +0530 Subject: [PATCH 001/232] fixes: Reply box goes hidden and emoji input header section is broken. (#3121) --- .../dashboard/assets/scss/widgets/_conversation-view.scss | 2 +- app/javascript/shared/components/emoji/EmojiInput.vue | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index f2c156542..f6965c493 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -93,7 +93,7 @@ .conversation-panel { @include flex; - @include flex-weight(1); + @include flex-weight(1 1 1px); @include margin($zero); flex-direction: column; height: 100%; diff --git a/app/javascript/shared/components/emoji/EmojiInput.vue b/app/javascript/shared/components/emoji/EmojiInput.vue index 91a68b9a9..eb71ac73f 100644 --- a/app/javascript/shared/components/emoji/EmojiInput.vue +++ b/app/javascript/shared/components/emoji/EmojiInput.vue @@ -133,6 +133,7 @@ $font-size-medium: 18px; ul { display: flex; list-style: none; + overflow: auto; margin: 0; padding: $space-smaller 0 0; From 40d0b2faf32dd7f66a65469f6de24144af4a2717 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Tue, 5 Oct 2021 14:35:32 +0530 Subject: [PATCH 002/232] feat: Add Instagram Channel (#2955) --- .env.example | 3 + .../messages/facebook/message_builder.rb | 45 +----- .../messages/instagram/message_builder.rb | 150 ++++++++++++++++++ .../messages/messenger/message_builder.rb | 42 +++++ .../api/v1/accounts/callbacks_controller.rb | 10 ++ .../api/v1/instagram_callbacks_controller.rb | 30 ++++ .../assets/images/channels/messenger.png | Bin 0 -> 12771 bytes .../assets/images/instagram_direct.png | Bin 0 -> 80057 bytes .../assets/images/messenger_direct.png | Bin 0 -> 4509 bytes .../components/widgets/ChannelItem.vue | 2 +- .../components/widgets/Thumbnail.vue | 9 +- .../widgets/conversation/ConversationCard.vue | 14 +- .../conversation/ConversationHeader.vue | 16 +- .../dashboard/settings/inbox/ChannelList.vue | 2 +- .../settings/inbox/channels/Facebook.vue | 2 +- app/jobs/send_reply_job.rb | 10 +- app/jobs/webhooks/instagram_events_job.rb | 84 ++++++++++ app/models/channel/facebook_page.rb | 14 ++ app/services/instagram/message_text.rb | 49 ++++++ .../instagram/send_on_instagram_service.rb | 99 ++++++++++++ .../instagram/webhooks_base_service.rb | 21 +++ config/routes.rb | 2 + ...81438_add_instagram_id_to_facebook_page.rb | 9 ++ db/schema.rb | 23 +++ .../instagram/message_builder_spec.rb | 41 +++++ spec/factories/channel/insatgram_channel.rb | 10 ++ .../instagram_message_create_event.rb | 58 +++++++ .../instagram_message/incoming_messages.rb | 31 ++++ .../webhooks/instagram_events_job_spec.rb | 54 +++++++ .../send_on_instagram_service_spec.rb | 45 ++++++ 30 files changed, 825 insertions(+), 50 deletions(-) create mode 100644 app/builders/messages/instagram/message_builder.rb create mode 100644 app/builders/messages/messenger/message_builder.rb create mode 100644 app/controllers/api/v1/instagram_callbacks_controller.rb create mode 100644 app/javascript/dashboard/assets/images/channels/messenger.png create mode 100755 app/javascript/dashboard/assets/images/instagram_direct.png create mode 100644 app/javascript/dashboard/assets/images/messenger_direct.png create mode 100644 app/jobs/webhooks/instagram_events_job.rb create mode 100644 app/services/instagram/message_text.rb create mode 100644 app/services/instagram/send_on_instagram_service.rb create mode 100644 app/services/instagram/webhooks_base_service.rb create mode 100644 db/migrate/20210902181438_add_instagram_id_to_facebook_page.rb create mode 100644 spec/builders/messages/instagram/message_builder_spec.rb create mode 100644 spec/factories/channel/insatgram_channel.rb create mode 100644 spec/factories/instagram/instagram_message_create_event.rb create mode 100644 spec/factories/instagram_message/incoming_messages.rb create mode 100644 spec/jobs/webhooks/instagram_events_job_spec.rb create mode 100644 spec/services/instagram/send_on_instagram_service_spec.rb 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 0000000000000000000000000000000000000000..f1bbb68d41b472ac1d12aa7928ec9d7ef3c98bf9 GIT binary patch literal 12771 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 literal 0 HcmV?d00001 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 From bd7aeba484895e35739665b7c191cc93189ca589 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 5 Oct 2021 23:35:06 +0530 Subject: [PATCH 003/232] chore: Provider API prototype (#3112) Enabling Support for Whatsapp via 360Dialog as a prototype for the provider APIs. Co-authored-by: Pranav Raj S --- .env.example | 2 +- .../api/v1/accounts/inboxes_controller.rb | 2 + .../webhooks/whatsapp_controller.rb | 6 + .../widgets/conversation/MessagesView.vue | 4 +- .../widgets/conversation/ReplyBox.vue | 8 +- app/javascript/dashboard/helper/inbox.js | 3 + .../dashboard/i18n/locale/en/inboxMgmt.json | 43 ++++-- .../dashboard/settings/inbox/ChannelList.vue | 2 +- .../routes/dashboard/settings/inbox/Index.vue | 3 + .../dashboard/settings/inbox/Settings.vue | 5 +- .../inbox/channels/360DialogWhatsapp.vue | 128 ++++++++++++++++++ .../settings/inbox/channels/Facebook.vue | 3 +- .../settings/inbox/channels/Twilio.vue | 10 +- .../settings/inbox/channels/Whatsapp.vue | 29 +++- .../settings/inbox/facebook/Reauthorize.vue | 3 +- app/javascript/shared/mixins/inboxMixin.js | 7 + app/jobs/send_reply_job.rb | 18 ++- app/jobs/webhooks/whatsapp_events_job.rb | 13 ++ app/models/account.rb | 1 + app/models/channel/whatsapp.rb | 67 +++++++++ .../contacts/contactable_inboxes_service.rb | 25 +++- .../whatsapp/incoming_message_service.rb | 61 +++++++++ .../whatsapp/send_on_whatsapp_service.rb | 11 ++ config/routes.rb | 1 + .../20210916112533_add_whatsapp_channel.rb | 11 ++ db/schema.rb | 24 +--- .../webhooks/whatsapp_controller_spec.rb | 12 ++ spec/factories/channel/channel_whatsapp.rb | 11 ++ spec/jobs/send_reply_job_spec.rb | 9 ++ .../whatsapp/incoming_message_service_spec.rb | 21 +++ .../whatsapp/send_on_whatsapp_service_spec.rb | 23 ++++ 31 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 app/controllers/webhooks/whatsapp_controller.rb create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/360DialogWhatsapp.vue create mode 100644 app/jobs/webhooks/whatsapp_events_job.rb create mode 100644 app/models/channel/whatsapp.rb create mode 100644 app/services/whatsapp/incoming_message_service.rb create mode 100644 app/services/whatsapp/send_on_whatsapp_service.rb create mode 100644 db/migrate/20210916112533_add_whatsapp_channel.rb create mode 100644 spec/controllers/webhooks/whatsapp_controller_spec.rb create mode 100644 spec/factories/channel/channel_whatsapp.rb create mode 100644 spec/services/whatsapp/incoming_message_service_spec.rb create mode 100644 spec/services/whatsapp/send_on_whatsapp_service_spec.rb diff --git a/.env.example b/.env.example index 3a5600495..dd4052f69 100644 --- a/.env.example +++ b/.env.example @@ -101,7 +101,7 @@ FB_APP_SECRET= FB_APP_ID= # https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard -IG_VERIFY_TOKEN +IG_VERIFY_TOKEN= # Twitter # documentation: https://www.chatwoot.com/docs/twitter-app-setup diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index df055923c..3f8686499 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -96,6 +96,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController Current.account.line_channels.create!(permitted_params(Channel::Line::EDITABLE_ATTRS)[:channel].except(:type)) when 'telegram' Current.account.telegram_channels.create!(permitted_params(Channel::Telegram::EDITABLE_ATTRS)[:channel].except(:type)) + when 'whatsapp' + Current.account.whatsapp_channels.create!(permitted_params(Channel::Whatsapp::EDITABLE_ATTRS)[:channel].except(:type)) end end diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb new file mode 100644 index 000000000..7560da1e4 --- /dev/null +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -0,0 +1,6 @@ +class Webhooks::WhatsappController < ActionController::API + def process_payload + Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) + head :ok + end +end diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index c84b14b01..db042b5b7 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -1,7 +1,7 @@ @@ -78,15 +78,15 @@ " > - {{ - props.option.title - }} + + {{ props.option.title }} + -

+
@@ -94,7 +94,7 @@ v-model="currentSelectedFilter" track-by="id" label="name" - :placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')" + :placeholder="multiselectLabel" selected-label :select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" deselect-label="" @@ -185,12 +185,19 @@ export default { const fromDate = subDays(new Date(), diff); return this.fromCustomDate(fromDate); }, + multiselectLabel() { + const typeLabels = { + agent: this.$t('AGENT_REPORTS.FILTER_DROPDOWN_LABEL'), + label: this.$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL'), + inbox: this.$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL'), + team: this.$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL'), + }; + return typeLabels[this.type] || this.$t('FORMS.MULTISELECT.SELECT_ONE'); + }, }, watch: { filterItemsList(val) { this.currentSelectedFilter = val[0]; - }, - currentSelectedFilter() { this.changeFilterSelection(); }, }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index 64821f62e..b5d4f889d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -15,8 +15,8 @@ @date-range-change="onDateRangeChange" @filter-change="onFilterChange" /> -
-
+
+
- + {{ $t('REPORT.NO_ENOUGH_DATA') }} @@ -118,9 +121,15 @@ export default { }; }, metrics() { - const reportKeys = [ - 'CONVERSATIONS', - 'INCOMING_MESSAGES', + let reportKeys = ['CONVERSATIONS']; + // If report type is agent, we don't need to show + // incoming messages count, as there will not be any message + // sent by an agent which is incoming. + if (this.type !== 'agent') { + reportKeys.push('INCOMING_MESSAGES'); + } + reportKeys = [ + ...reportKeys, 'OUTGOING_MESSAGES', 'FIRST_RESPONSE_TIME', 'RESOLUTION_TIME', @@ -175,6 +184,9 @@ export default { case 'inbox': this.$store.dispatch('downloadInboxReports', { from, to, fileName }); break; + case 'team': + this.$store.dispatch('downloadTeamReports', { from, to, fileName }); + break; default: break; } diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js index d37331f02..4cb837af5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js @@ -2,6 +2,7 @@ import Index from './Index'; import AgentReports from './AgentReports'; import LabelReports from './LabelReports'; import InboxReports from './InboxReports'; +import TeamReports from './TeamReports'; import CsatResponses from './CsatResponses'; import SettingsContent from '../Wrapper'; import { frontendURL } from '../../../../helper/URLHelper'; @@ -97,5 +98,21 @@ export default { }, ], }, + { + path: frontendURL('accounts/:accountId/reports'), + component: SettingsContent, + props: { + headerTitle: 'TEAM_REPORTS.HEADER', + icon: 'ion-ios-people', + }, + children: [ + { + path: 'teams', + name: 'team_reports', + roles: ['administrator'], + component: TeamReports, + }, + ], + }, ], }; diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 828e8114f..87050bcef 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -7,6 +7,8 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import * as types from '../mutation-types'; import Report from '../../api/reports'; +import { downloadCsvFile } from '../../helper/downloadCsvFile'; + const state = { fetchingStatus: false, reportData: [], @@ -78,15 +80,7 @@ export const actions = { downloadAgentReports(_, reportObj) { return Report.getAgentReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); @@ -95,15 +89,7 @@ export const actions = { downloadLabelReports(_, reportObj) { return Report.getLabelReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); @@ -112,15 +98,16 @@ export const actions = { downloadInboxReports(_, reportObj) { return Report.getInboxReports(reportObj.from, reportObj.to) .then(response => { - let csvContent = 'data:text/csv;charset=utf-8,' + response.data; - var encodedUri = encodeURI(csvContent); - var downloadLink = document.createElement('a'); - downloadLink.href = encodedUri; - downloadLink.download = reportObj.fileName; - - document.body.appendChild(downloadLink); - downloadLink.click(); - // document.body.removeChild(downloadLink); + downloadCsvFile(reportObj.fileName, response.data); + }) + .catch(error => { + console.error(error); + }); + }, + downloadTeamReports(_, reportObj) { + return Report.getTeamReports(reportObj.from, reportObj.to) + .then(response => { + downloadCsvFile(reportObj.fileName, response.data); }) .catch(error => { console.error(error); diff --git a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js index 10ccbdcd3..0ba89ff81 100644 --- a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js @@ -78,4 +78,25 @@ describe('#actions', () => { expect(mockInboxDownloadElement.download).toEqual(param.fileName); }); }); + + describe('#downloadTeamReports', () => { + it('open CSV download prompt if API is success', async () => { + axios.get.mockResolvedValue({ + data: `Team name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes) + sales team,0,0,0 + Reporting period 2021-09-23 to 2021-09-29`, + }); + const param = { + from: 1631039400, + to: 1635013800, + fileName: 'inbox-report-24-10-2021.csv', + }; + const mockInboxDownloadElement = createElementSpy(); + await actions.downloadInboxReports(1, param); + expect(mockInboxDownloadElement.href).toEqual( + 'data:text/csv;charset=utf-8,Team%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20sales%20team,0,0,0%0A%20%20%20%20%20%20%20%20Reporting%20period%202021-09-23%20to%202021-09-29' + ); + expect(mockInboxDownloadElement.download).toEqual(param.fileName); + }); + }); }); diff --git a/spec/builders/v2/report_builder_spec.rb b/spec/builders/v2/report_builder_spec.rb index 8506c88f6..bcf02f1d8 100644 --- a/spec/builders/v2/report_builder_spec.rb +++ b/spec/builders/v2/report_builder_spec.rb @@ -171,14 +171,14 @@ describe ::V2::ReportBuilder do type: :label, id: label_1.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } builder = V2::ReportBuilder.new(account, params) metrics = builder.timeseries expect(metrics[Time.zone.today]).to be 20 - expect(metrics[Time.zone.today - 2.days]).to be 5 + expect(metrics[Time.zone.today - 2.days]).to be 0 end it 'return outgoing messages count' do @@ -187,14 +187,14 @@ describe ::V2::ReportBuilder do type: :label, id: label_1.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } builder = V2::ReportBuilder.new(account, params) metrics = builder.timeseries expect(metrics[Time.zone.today]).to be 50 - expect(metrics[Time.zone.today - 2.days]).to be 15 + expect(metrics[Time.zone.today - 2.days]).to be 0 end it 'return resolutions count' do @@ -203,7 +203,7 @@ describe ::V2::ReportBuilder do type: :label, id: label_2.id, since: (Time.zone.today - 3.days).to_time.to_i.to_s, - until: Time.zone.today.to_time.to_i.to_s + until: (Time.zone.today + 1.day).to_time.to_i.to_s } conversations = account.conversations.where('created_at < ?', 1.day.ago) @@ -242,8 +242,8 @@ describe ::V2::ReportBuilder do metrics = builder.summary expect(metrics[:conversations_count]).to be 5 - expect(metrics[:incoming_messages_count]).to be 25 - expect(metrics[:outgoing_messages_count]).to be 65 + expect(metrics[:incoming_messages_count]).to be 5 + expect(metrics[:outgoing_messages_count]).to be 15 expect(metrics[:avg_resolution_time]).to be 0 expect(metrics[:resolutions_count]).to be 0 end From c54aae21ff8f21db3a8be45c44bc1dfda2798f0c Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 7 Oct 2021 13:21:46 +0530 Subject: [PATCH 010/232] chore: Move agent availability to Account level (#3074) - Move agent availability to the account level --- .../api/v1/accounts/agents_controller.rb | 30 +-- app/controllers/api/v1/profiles_controller.rb | 13 +- app/javascript/dashboard/api/auth.js | 6 +- app/javascript/dashboard/api/endPoints.js | 3 + .../components/layout/AvailabilityStatus.vue | 18 +- .../layout/specs/AvailabilityStatus.spec.js | 8 +- .../dashboard/store/modules/auth.js | 19 +- .../store/modules/specs/auth/actions.spec.js | 9 +- .../store/modules/specs/auth/getters.spec.js | 6 +- .../helpers/BaseActionCableConnector.js | 2 +- app/models/account_user.rb | 27 +- .../concerns/availability_statusable.rb | 32 +-- app/models/user.rb | 21 +- .../v1/accounts/agents/create.json.jbuilder | 1 + .../v1/accounts/agents/update.json.jbuilder | 1 + app/views/api/v1/models/_agent.json.jbuilder | 3 +- app/views/api/v1/models/_user.json.jbuilder | 3 +- .../api/v1/profiles/availability.jbuilder | 1 + app/views/api/v1/profiles/show.json.jbuilder | 1 + .../widget/inbox_members/index.json.jbuilder | 2 +- .../api/v1/models/_user.json.jbuilder | 1 - config/routes.rb | 8 +- ...0418_add_online_status_to_account_users.rb | 21 ++ db/schema.rb | 2 + lib/online_status_tracker.rb | 2 +- .../api/v1/accounts/agents_controller_spec.rb | 16 +- .../api/v1/profiles_controller_spec.rb | 34 ++- swagger/definitions/resource/agent.yml | 11 +- swagger/index.yml | 1 + .../{ => application}/agent_bots/create.yml | 0 .../{ => application}/agent_bots/delete.yml | 0 .../{ => application}/agent_bots/index.yml | 0 .../{ => application}/agent_bots/show.yml | 0 .../{ => application}/agent_bots/update.yml | 0 swagger/paths/application/agents/create.yml | 42 ++++ swagger/paths/application/agents/delete.yml | 21 ++ swagger/paths/application/agents/index.yml | 17 ++ swagger/paths/application/agents/update.yml | 42 ++++ .../contact_inboxes/create.yml | 0 .../contactable_inboxes/get.yml | 0 .../contacts}/conversations.yml | 0 .../contacts}/crud.yml | 0 .../contacts}/list_create.yml | 0 .../contacts}/search.yml | 0 .../conversation/assignments.yml | 0 .../{ => application}/conversation/create.yml | 0 .../{ => application}/conversation/index.yml | 0 .../conversation/labels/create.yml | 0 .../conversation/labels/index.yml | 0 .../conversation/messages/create.yml | 0 .../messages/create_attachment.yml | 0 .../conversation/messages/delete.yml | 0 .../conversation/messages/index.yml | 0 .../{ => application}/conversation/show.yml | 0 .../conversation/toggle_status.yml | 0 .../conversation/update_last_seen.yml | 0 .../custom_filters/create.yml | 0 .../custom_filters/delete.yml | 0 .../custom_filters/index.yml | 0 .../{ => application}/custom_filters/show.yml | 0 .../custom_filters/update.yml | 0 .../{ => application}/inboxes/create.yml | 0 .../inboxes/get_agent_bot.yml | 0 .../inboxes/inbox_members/create.yml | 0 .../inboxes/inbox_members/delete.yml | 0 .../inboxes/inbox_members/show.yml | 0 .../inboxes/inbox_members/update.yml | 0 .../paths/{ => application}/inboxes/index.yml | 0 .../inboxes/set_agent_bot.yml | 0 .../paths/{ => application}/inboxes/show.yml | 0 .../{ => application}/inboxes/update.yml | 0 .../integrations/apps/show.yml | 0 .../integrations/hooks/create.yml | 0 .../integrations/hooks/delete.yml | 0 .../integrations/hooks/update.yml | 0 .../paths/{ => application}/reports/index.yml | 0 .../{ => application}/reports/summary.yml | 0 .../paths/{ => application}/teams/create.yml | 0 .../paths/{ => application}/teams/delete.yml | 0 .../paths/{ => application}/teams/index.yml | 0 .../paths/{ => application}/teams/show.yml | 0 .../paths/{ => application}/teams/update.yml | 0 swagger/paths/index.yml | 111 +++++---- swagger/swagger.json | 231 +++++++++++++++++- 84 files changed, 618 insertions(+), 148 deletions(-) create mode 100644 app/views/api/v1/accounts/agents/create.json.jbuilder create mode 100644 app/views/api/v1/accounts/agents/update.json.jbuilder create mode 100644 app/views/api/v1/profiles/availability.jbuilder create mode 100644 app/views/api/v1/profiles/show.json.jbuilder create mode 100644 db/migrate/20210923190418_add_online_status_to_account_users.rb rename swagger/paths/{ => application}/agent_bots/create.yml (100%) rename swagger/paths/{ => application}/agent_bots/delete.yml (100%) rename swagger/paths/{ => application}/agent_bots/index.yml (100%) rename swagger/paths/{ => application}/agent_bots/show.yml (100%) rename swagger/paths/{ => application}/agent_bots/update.yml (100%) create mode 100644 swagger/paths/application/agents/create.yml create mode 100644 swagger/paths/application/agents/delete.yml create mode 100644 swagger/paths/application/agents/index.yml create mode 100644 swagger/paths/application/agents/update.yml rename swagger/paths/{ => application}/contact_inboxes/create.yml (100%) rename swagger/paths/{ => application}/contactable_inboxes/get.yml (100%) rename swagger/paths/{contact => application/contacts}/conversations.yml (100%) rename swagger/paths/{contact => application/contacts}/crud.yml (100%) rename swagger/paths/{contact => application/contacts}/list_create.yml (100%) rename swagger/paths/{contact => application/contacts}/search.yml (100%) rename swagger/paths/{ => application}/conversation/assignments.yml (100%) rename swagger/paths/{ => application}/conversation/create.yml (100%) rename swagger/paths/{ => application}/conversation/index.yml (100%) rename swagger/paths/{ => application}/conversation/labels/create.yml (100%) rename swagger/paths/{ => application}/conversation/labels/index.yml (100%) rename swagger/paths/{ => application}/conversation/messages/create.yml (100%) rename swagger/paths/{ => application}/conversation/messages/create_attachment.yml (100%) rename swagger/paths/{ => application}/conversation/messages/delete.yml (100%) rename swagger/paths/{ => application}/conversation/messages/index.yml (100%) rename swagger/paths/{ => application}/conversation/show.yml (100%) rename swagger/paths/{ => application}/conversation/toggle_status.yml (100%) rename swagger/paths/{ => application}/conversation/update_last_seen.yml (100%) rename swagger/paths/{ => application}/custom_filters/create.yml (100%) rename swagger/paths/{ => application}/custom_filters/delete.yml (100%) rename swagger/paths/{ => application}/custom_filters/index.yml (100%) rename swagger/paths/{ => application}/custom_filters/show.yml (100%) rename swagger/paths/{ => application}/custom_filters/update.yml (100%) rename swagger/paths/{ => application}/inboxes/create.yml (100%) rename swagger/paths/{ => application}/inboxes/get_agent_bot.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/create.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/delete.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/show.yml (100%) rename swagger/paths/{ => application}/inboxes/inbox_members/update.yml (100%) rename swagger/paths/{ => application}/inboxes/index.yml (100%) rename swagger/paths/{ => application}/inboxes/set_agent_bot.yml (100%) rename swagger/paths/{ => application}/inboxes/show.yml (100%) rename swagger/paths/{ => application}/inboxes/update.yml (100%) rename swagger/paths/{ => application}/integrations/apps/show.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/create.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/delete.yml (100%) rename swagger/paths/{ => application}/integrations/hooks/update.yml (100%) rename swagger/paths/{ => application}/reports/index.yml (100%) rename swagger/paths/{ => application}/reports/summary.yml (100%) rename swagger/paths/{ => application}/teams/create.yml (100%) rename swagger/paths/{ => application}/teams/delete.yml (100%) rename swagger/paths/{ => application}/teams/index.yml (100%) rename swagger/paths/{ => application}/teams/show.yml (100%) rename swagger/paths/{ => application}/teams/update.yml (100%) diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 334844de9..a406e0cf3 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -9,21 +9,18 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController @agents = agents end + def create; end + + def update + @agent.update!(agent_params.slice(:name).compact) + @agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact) + end + def destroy @agent.current_account_user.destroy head :ok end - def update - @agent.update!(agent_params.except(:role)) - @agent.current_account_user.update!(role: agent_params[:role]) if agent_params[:role] - render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @agent } - end - - def create - render partial: 'api/v1/models/agent.json.jbuilder', locals: { resource: @user } - end - private def check_authorization @@ -47,22 +44,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController end def save_account_user - AccountUser.create!( + AccountUser.create!({ account_id: Current.account.id, user_id: @user.id, - role: new_agent_params[:role], inviter_id: current_user.id - ) + }.merge({ + role: new_agent_params[:role], + availability: new_agent_params[:availability], + auto_offline: new_agent_params[:auto_offline] + }.compact)) end def agent_params - params.require(:agent).permit(:email, :name, :role) + params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline) end def new_agent_params # intial string ensures the password requirements are met temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - params.require(:agent).permit(:email, :name, :role) + params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline) .merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user) end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index 5805f49ed..a92c479bc 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -1,9 +1,7 @@ class Api::V1::ProfilesController < Api::BaseController before_action :set_user - def show - render partial: 'api/v1/models/user.json.jbuilder', locals: { resource: @user } - end + def show; end def update if password_params[:password].present? @@ -15,19 +13,26 @@ class Api::V1::ProfilesController < Api::BaseController @user.update!(profile_params) end + def availability + @user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability]) + end + private def set_user @user = current_user end + def availability_params + params.require(:profile).permit(:account_id, :availability) + end + def profile_params params.require(:profile).permit( :email, :name, :display_name, :avatar, - :availability, ui_settings: {} ) end diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 558eb4933..c41a43624 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -161,9 +161,9 @@ export default { }); }, - updateAvailability({ availability }) { - return axios.put(endPoints('profileUpdate').url, { - profile: { availability }, + updateAvailability(availabilityData) { + return axios.post(endPoints('availabilityUpdate').url, { + profile: { ...availabilityData }, }); }, }; diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 8df609f70..0b801fdb3 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -13,6 +13,9 @@ const endPoints = { profileUpdate: { url: '/api/v1/profile', }, + availabilityUpdate: { + url: '/api/v1/profile/availability', + }, logout: { url: 'auth/sign_out', }, diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 8587bacc8..ec26b8bce 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -26,7 +26,9 @@ color-scheme="secondary" class-names="status-change--dropdown-button" :is-disabled="status.disabled" - @click="changeAvailabilityStatus(status.value)" + @click=" + changeAvailabilityStatus(status.value, currentAccountId) + " > {{ status.label }} @@ -75,7 +77,8 @@ export default { computed: { ...mapGetters({ - currentUser: 'getCurrentUser', + getCurrentUserAvailabilityStatus: 'getCurrentUserAvailabilityStatus', + getCurrentAccountId: 'getCurrentAccountId', }), availabilityDisplayLabel() { const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( @@ -85,8 +88,11 @@ export default { availabilityIndex ]; }, + currentAccountId() { + return this.getCurrentAccountId; + }, currentUserAvailabilityStatus() { - return this.currentUser.availability_status; + return this.getCurrentUserAvailabilityStatus; }, availabilityStatuses() { return this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST').map( @@ -108,16 +114,16 @@ export default { closeStatusMenu() { this.isStatusMenuOpened = false; }, - changeAvailabilityStatus(availability) { + changeAvailabilityStatus(availability, accountId) { if (this.isUpdating) { return; } this.isUpdating = true; - this.$store .dispatch('updateAvailability', { - availability, + availability: availability, + account_id: accountId, }) .finally(() => { this.isUpdating = false; diff --git a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js index d96f9761d..2954b837d 100644 --- a/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js +++ b/app/javascript/dashboard/components/layout/specs/AvailabilityStatus.spec.js @@ -17,7 +17,8 @@ const i18nConfig = new VueI18n({ }); describe('AvailabilityStatus', () => { - const currentUser = { availability_status: 'online' }; + const currentAvailabilityStatus = 'online' ; + const currentAccountId = '1'; let store = null; let actions = null; let modules = null; @@ -33,7 +34,8 @@ describe('AvailabilityStatus', () => { modules = { auth: { getters: { - getCurrentUser: () => currentUser, + getCurrentUserAvailabilityStatus: () => currentAvailabilityStatus, + getCurrentAccountId: () => currentAccountId, }, }, }; @@ -77,7 +79,7 @@ describe('AvailabilityStatus', () => { expect(actions.updateAvailability).toBeCalledWith( expect.any(Object), - { availability: 'offline' }, + { availability: 'offline', account_id: currentAccountId }, undefined ); }); diff --git a/app/javascript/dashboard/store/modules/auth.js b/app/javascript/dashboard/store/modules/auth.js index e0691049c..0805b35db 100644 --- a/app/javascript/dashboard/store/modules/auth.js +++ b/app/javascript/dashboard/store/modules/auth.js @@ -40,7 +40,11 @@ export const getters = { }, getCurrentUserAvailabilityStatus(_state) { - return _state.currentUser.availability_status; + const { accounts = [] } = _state.currentUser; + const [currentAccount = {}] = accounts.filter( + account => account.id === _state.currentAccountId + ); + return currentAccount.availability_status; }, getCurrentAccountId(_state) { @@ -125,14 +129,17 @@ export const actions = { } }, - updateAvailability: ({ commit, dispatch }, { availability }) => { - authAPI.updateAvailability({ availability }).then(response => { + updateAvailability: async ({ commit, dispatch }, params) => { + try { + const response = await authAPI.updateAvailability(params); const userData = response.data; - const { id, availability_status: availabilityStatus } = userData; + const { id } = userData; setUser(userData, getHeaderExpiry(response)); commit(types.default.SET_CURRENT_USER); - dispatch('agents/updatePresence', { [id]: availabilityStatus }); - }); + dispatch('agents/updatePresence', { [id]: params.availability }); + } catch (error) { + // Ignore error + } }, setCurrentAccountId({ commit }, accountId) { diff --git a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js index adbca0d94..fe503e3ea 100644 --- a/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js @@ -54,13 +54,16 @@ describe('#actions', () => { describe('#updateAvailability', () => { it('sends correct actions if API is success', async () => { - axios.put.mockResolvedValue({ - data: { id: 1, name: 'John', availability_status: 'offline' }, + axios.post.mockResolvedValue({ + data: { + id: 1, + account_users: [{ account_id: 1, availability_status: 'offline' }], + }, headers: { expiry: 581842904 }, }); await actions.updateAvailability( { commit, dispatch }, - { availability: 'offline' } + { availability: 'offline', account_id: 1 }, ); expect(setUser).toHaveBeenCalledTimes(1); expect(commit.mock.calls).toEqual([[types.default.SET_CURRENT_USER]]); diff --git a/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js b/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js index c934d3824..dd87cd79d 100644 --- a/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js @@ -21,7 +21,11 @@ describe('#getters', () => { it('get', () => { expect( getters.getCurrentUserAvailabilityStatus({ - currentUser: { id: 1, name: 'Pranav', availability_status: 'busy' }, + currentAccountId: 1, + currentUser: { + id: 1, + accounts: [{ id: 1, availability_status: 'busy' }], + }, }) ).toEqual('busy'); }); diff --git a/app/javascript/shared/helpers/BaseActionCableConnector.js b/app/javascript/shared/helpers/BaseActionCableConnector.js index 89b3ed6b0..f92ed9c6b 100644 --- a/app/javascript/shared/helpers/BaseActionCableConnector.js +++ b/app/javascript/shared/helpers/BaseActionCableConnector.js @@ -1,6 +1,6 @@ import { createConsumer } from '@rails/actioncable'; -const PRESENCE_INTERVAL = 60000; +const PRESENCE_INTERVAL = 20000; class BaseActionCableConnector { constructor(app, pubsubToken, websocketHost = '') { diff --git a/app/models/account_user.rb b/app/models/account_user.rb index 6c5e89e3c..8f06253fc 100644 --- a/app/models/account_user.rb +++ b/app/models/account_user.rb @@ -2,14 +2,16 @@ # # Table name: account_users # -# id :bigint not null, primary key -# active_at :datetime -# role :integer default("agent") -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint -# inviter_id :bigint -# user_id :bigint +# id :bigint not null, primary key +# active_at :datetime +# auto_offline :boolean default(TRUE), not null +# availability :integer default("online"), not null +# role :integer default("agent") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint +# inviter_id :bigint +# user_id :bigint # # Indexes # @@ -24,15 +26,20 @@ # class AccountUser < ApplicationRecord + include AvailabilityStatusable + belongs_to :account belongs_to :user belongs_to :inviter, class_name: 'User', optional: true enum role: { agent: 0, administrator: 1 } + enum availability: { online: 0, offline: 1, busy: 2 } + accepts_nested_attributes_for :account after_create_commit :notify_creation, :create_notification_setting after_destroy :notify_deletion, :remove_user_from_account + after_save :update_presence_in_redis, if: :saved_change_to_availability? validates :user_id, uniqueness: { scope: :account_id } @@ -56,4 +63,8 @@ class AccountUser < ApplicationRecord def notify_deletion Rails.configuration.dispatcher.dispatch(AGENT_REMOVED, Time.zone.now, account: account) end + + def update_presence_in_redis + OnlineStatusTracker.set_status(account.id, user.id, availability) + end end diff --git a/app/models/concerns/availability_statusable.rb b/app/models/concerns/availability_statusable.rb index 11d1e438e..9cfe6bfec 100644 --- a/app/models/concerns/availability_statusable.rb +++ b/app/models/concerns/availability_statusable.rb @@ -2,29 +2,29 @@ module AvailabilityStatusable extend ActiveSupport::Concern def online_presence? - return if user_profile_page_context? - - ::OnlineStatusTracker.get_presence(availability_account_id, self.class.name, id) + obj_id = is_a?(Contact) ? id : user_id + ::OnlineStatusTracker.get_presence(account_id, self.class.name, obj_id) end def availability_status - return availability if user_profile_page_context? - return 'offline' unless online_presence? - return 'online' if is_a? Contact - - ::OnlineStatusTracker.get_status(availability_account_id, id) || 'online' + if is_a? Contact + contact_availability_status + else + user_availability_status + end end - def user_profile_page_context? - # at the moment profile pages aren't account scoped - # hence we will return availability attribute instead of true presence - # we will revisit this later - is_a?(User) && Current.account.blank? + private + + def contact_availability_status + online_presence? ? 'online' : 'offline' end - def availability_account_id - return account_id if is_a? Contact + def user_availability_status + # we are not considering presence in this case. Just returns the availability + return availability unless auto_offline - Current.account.id + # availability as a fallback in case the status is not present in redis + online_presence? ? (::OnlineStatusTracker.get_status(account_id, user_id) || availability) : 'offline' end end diff --git a/app/models/user.rb b/app/models/user.rb index 89ed7be84..214658869 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,7 +39,6 @@ class User < ApplicationRecord include AccessTokenable - include AvailabilityStatusable include Avatarable # Include default devise modules. include DeviseTokenAuth::Concerns::User @@ -57,6 +56,8 @@ class User < ApplicationRecord :confirmable, :password_has_required_content + # TODO: remove in a future version once online status is moved to account users + # remove the column availability from users enum availability: { online: 0, offline: 1, busy: 2 } # The validation below has been commented out as it does not @@ -89,8 +90,6 @@ class User < ApplicationRecord before_validation :set_password_and_uid, on: :create - after_save :update_presence_in_redis, if: :saved_change_to_availability? - scope :order_by_full_name, -> { order('lower(name) ASC') } def send_devise_notification(notification, *args) @@ -141,6 +140,14 @@ class User < ApplicationRecord current_account_user&.role end + def availability_status + current_account_user&.availability_status + end + + def auto_offline + current_account_user&.auto_offline + end + def inviter current_account_user&.inviter end @@ -169,12 +176,4 @@ class User < ApplicationRecord type: 'user' } end - - private - - def update_presence_in_redis - accounts.each do |account| - OnlineStatusTracker.set_status(account.id, id, availability) - end - end end diff --git a/app/views/api/v1/accounts/agents/create.json.jbuilder b/app/views/api/v1/accounts/agents/create.json.jbuilder new file mode 100644 index 000000000..7f22d270d --- /dev/null +++ b/app/views/api/v1/accounts/agents/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/agent.json.jbuilder', resource: @user diff --git a/app/views/api/v1/accounts/agents/update.json.jbuilder b/app/views/api/v1/accounts/agents/update.json.jbuilder new file mode 100644 index 000000000..38328ca08 --- /dev/null +++ b/app/views/api/v1/accounts/agents/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/agent.json.jbuilder', resource: @agent diff --git a/app/views/api/v1/models/_agent.json.jbuilder b/app/views/api/v1/models/_agent.json.jbuilder index 071fbbcd8..fd7119ca5 100644 --- a/app/views/api/v1/models/_agent.json.jbuilder +++ b/app/views/api/v1/models/_agent.json.jbuilder @@ -1,10 +1,11 @@ +json.id resource.id # could be nil for a deleted agent hence the safe operator before account id json.account_id resource.account&.id json.availability_status resource.availability_status +json.auto_offline resource.auto_offline json.confirmed resource.confirmed? json.email resource.email json.available_name resource.available_name -json.id resource.id json.custom_attributes resource.custom_attributes if resource.custom_attributes.present? json.name resource.name json.role resource.role diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index 5c0cdeec9..745dd0618 100644 --- a/app/views/api/v1/models/_user.json.jbuilder +++ b/app/views/api/v1/models/_user.json.jbuilder @@ -1,6 +1,5 @@ json.access_token resource.access_token.token json.account_id resource.active_account_user&.account_id -json.availability_status resource.availability_status json.available_name resource.available_name json.avatar_url resource.avatar_url json.confirmed resource.confirmed? @@ -22,5 +21,7 @@ json.accounts do json.name account_user.account.name json.active_at account_user.active_at json.role account_user.role + json.availability_status account_user.availability_status + json.auto_offline account_user.auto_offline end end diff --git a/app/views/api/v1/profiles/availability.jbuilder b/app/views/api/v1/profiles/availability.jbuilder new file mode 100644 index 000000000..5a6dc2dad --- /dev/null +++ b/app/views/api/v1/profiles/availability.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/user.json.jbuilder', resource: @user diff --git a/app/views/api/v1/profiles/show.json.jbuilder b/app/views/api/v1/profiles/show.json.jbuilder new file mode 100644 index 000000000..5a6dc2dad --- /dev/null +++ b/app/views/api/v1/profiles/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/user.json.jbuilder', resource: @user diff --git a/app/views/api/v1/widget/inbox_members/index.json.jbuilder b/app/views/api/v1/widget/inbox_members/index.json.jbuilder index af38fc1ee..417c23756 100644 --- a/app/views/api/v1/widget/inbox_members/index.json.jbuilder +++ b/app/views/api/v1/widget/inbox_members/index.json.jbuilder @@ -3,6 +3,6 @@ json.payload do json.id inbox_member.user.id json.name inbox_member.user.available_name json.avatar_url inbox_member.user.avatar_url - json.availability_status inbox_member.user.availability_status + json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status end end diff --git a/app/views/platform/api/v1/models/_user.json.jbuilder b/app/views/platform/api/v1/models/_user.json.jbuilder index 1ec708b8d..4c50efaaa 100644 --- a/app/views/platform/api/v1/models/_user.json.jbuilder +++ b/app/views/platform/api/v1/models/_user.json.jbuilder @@ -1,6 +1,5 @@ json.access_token resource.access_token.token json.account_id resource.active_account_user&.account_id -json.availability_status resource.availability_status json.available_name resource.available_name json.avatar_url resource.avatar_url json.confirmed resource.confirmed? diff --git a/config/routes.rb b/config/routes.rb index 9a210473e..c825911d7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,7 +40,7 @@ Rails.application.routes.draw do resource :contact_merge, only: [:create] end - resources :agents, except: [:show, :edit, :new] + resources :agents, only: [:index, :create, :update, :destroy] resources :agent_bots, only: [:index, :create, :show, :update, :destroy] resources :callbacks, only: [] do @@ -159,7 +159,11 @@ Rails.application.routes.draw do resources :webhooks, only: [:create] end - resource :profile, only: [:show, :update] + resource :profile, only: [:show, :update] do + member do + post :availability + end + end resource :notification_subscriptions, only: [:create] namespace :widget do diff --git a/db/migrate/20210923190418_add_online_status_to_account_users.rb b/db/migrate/20210923190418_add_online_status_to_account_users.rb new file mode 100644 index 000000000..eccc9be1c --- /dev/null +++ b/db/migrate/20210923190418_add_online_status_to_account_users.rb @@ -0,0 +1,21 @@ +class AddOnlineStatusToAccountUsers < ActiveRecord::Migration[6.1] + def change + change_table :account_users, bulk: true do |t| + t.integer :availability, default: 0, null: false + t.boolean :auto_offline, default: true, null: false + end + + update_existing_user_availability + end + + private + + def update_existing_user_availability + User.find_in_batches do |user_batch| + user_batch.each do |user| + availability = user.availability + user.account_users.update(availability: availability) + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7a954f94d..5f23ac780 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -35,6 +35,8 @@ ActiveRecord::Schema.define(version: 2021_09_29_150415) do t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.datetime "active_at" + t.integer "availability", default: 0, null: false + t.boolean "auto_offline", default: true, null: false t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true t.index ["account_id"], name: "index_account_users_on_account_id" t.index ["user_id"], name: "index_account_users_on_user_id" diff --git a/lib/online_status_tracker.rb b/lib/online_status_tracker.rb index 42bc0dca0..8688c4251 100644 --- a/lib/online_status_tracker.rb +++ b/lib/online_status_tracker.rb @@ -1,5 +1,5 @@ module OnlineStatusTracker - PRESENCE_DURATION = 60.seconds + PRESENCE_DURATION = 20.seconds # presence : sorted set with timestamp as the score & object id as value diff --git a/spec/controllers/api/v1/accounts/agents_controller_spec.rb b/spec/controllers/api/v1/accounts/agents_controller_spec.rb index 872b14669..4d8aa4d65 100644 --- a/spec/controllers/api/v1/accounts/agents_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agents_controller_spec.rb @@ -94,7 +94,7 @@ RSpec.describe 'Agents API', type: :request do expect(response).to have_http_status(:unauthorized) end - it 'modifies an agent' do + it 'modifies an agent name' do put "/api/v1/accounts/#{account.id}/agents/#{other_agent.id}", params: params, headers: admin.create_new_auth_token, @@ -103,6 +103,20 @@ RSpec.describe 'Agents API', type: :request do expect(response).to have_http_status(:success) expect(other_agent.reload.name).to eq(params[:name]) end + + it 'modifies an agents account user attributes' do + put "/api/v1/accounts/#{account.id}/agents/#{other_agent.id}", + params: { role: 'administrator', availability: 'busy', auto_offline: false }, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body) + expect(response_data['role']).to eq('administrator') + expect(response_data['availability_status']).to eq('busy') + expect(response_data['auto_offline']).to eq(false) + expect(other_agent.account_users.first.role).to eq('administrator') + end end end diff --git a/spec/controllers/api/v1/profiles_controller_spec.rb b/spec/controllers/api/v1/profiles_controller_spec.rb index 3247b5f83..53de5bcfe 100644 --- a/spec/controllers/api/v1/profiles_controller_spec.rb +++ b/spec/controllers/api/v1/profiles_controller_spec.rb @@ -89,16 +89,6 @@ RSpec.describe 'Profile API', type: :request do expect(agent.avatar.attached?).to eq(true) end - it 'updates the availability status' do - put '/api/v1/profile', - params: { profile: { availability: 'offline' } }, - headers: agent.create_new_auth_token, - as: :json - - expect(response).to have_http_status(:success) - expect(::OnlineStatusTracker.get_status(account.id, agent.id)).to eq('offline') - end - it 'updates the ui settings' do put '/api/v1/profile', params: { profile: { ui_settings: { is_contact_sidebar_open: false } } }, @@ -111,4 +101,28 @@ RSpec.describe 'Profile API', type: :request do end end end + + describe 'POST /api/v1/profile/availability' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post '/api/v1/profile/availability' + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, password: 'Test123!', account: account, role: :agent) } + + it 'updates the availability status' do + post '/api/v1/profile/availability', + params: { profile: { availability: 'busy', account_id: account.id } }, + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(::OnlineStatusTracker.get_status(account.id, agent.id)).to eq('busy') + end + end + end end diff --git a/swagger/definitions/resource/agent.yml b/swagger/definitions/resource/agent.yml index 0473d3d87..e3d506a3b 100644 --- a/swagger/definitions/resource/agent.yml +++ b/swagger/definitions/resource/agent.yml @@ -1,7 +1,7 @@ type: object properties: id: - type: number + type: integer uid: type: string name: @@ -13,12 +13,19 @@ properties: email: type: string account_id: - type: number + type: integer role: type: string enum: ['agent', 'administrator'] confirmed: type: boolean + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent computed by Chatwoot. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. custom_attributes: type: object description: Available for users who are created through platform APIs and has custom attributes associated. diff --git a/swagger/index.yml b/swagger/index.yml index 2d1ef704a..908c27509 100644 --- a/swagger/index.yml +++ b/swagger/index.yml @@ -53,6 +53,7 @@ x-tagGroups: - name: Application tags: - Account AgentBots + - Agent - Contact - Conversation - Conversation Assignment diff --git a/swagger/paths/agent_bots/create.yml b/swagger/paths/application/agent_bots/create.yml similarity index 100% rename from swagger/paths/agent_bots/create.yml rename to swagger/paths/application/agent_bots/create.yml diff --git a/swagger/paths/agent_bots/delete.yml b/swagger/paths/application/agent_bots/delete.yml similarity index 100% rename from swagger/paths/agent_bots/delete.yml rename to swagger/paths/application/agent_bots/delete.yml diff --git a/swagger/paths/agent_bots/index.yml b/swagger/paths/application/agent_bots/index.yml similarity index 100% rename from swagger/paths/agent_bots/index.yml rename to swagger/paths/application/agent_bots/index.yml diff --git a/swagger/paths/agent_bots/show.yml b/swagger/paths/application/agent_bots/show.yml similarity index 100% rename from swagger/paths/agent_bots/show.yml rename to swagger/paths/application/agent_bots/show.yml diff --git a/swagger/paths/agent_bots/update.yml b/swagger/paths/application/agent_bots/update.yml similarity index 100% rename from swagger/paths/agent_bots/update.yml rename to swagger/paths/application/agent_bots/update.yml diff --git a/swagger/paths/application/agents/create.yml b/swagger/paths/application/agents/create.yml new file mode 100644 index 000000000..de7381830 --- /dev/null +++ b/swagger/paths/application/agents/create.yml @@ -0,0 +1,42 @@ +tags: + - Agent +operationId: add-new-agent-to-account +summary: Add a New Agent +description: Add a new Agent to Account +security: + - userApiKey: [] +parameters: + - name: data + in: body + required: true + schema: + type: object + properties: + name: + type: string + description: Full Name of the agent + required: true + email: + type: string + description: Email of the Agent + required: true + role: + type: string + enum: ['agent', 'administrator'] + description: Whether its administrator or agent + required: true + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. +responses: + 200: + description: Success + schema: + description: 'Newly Created Agent' + $ref: '#/definitions/agent' + 403: + description: Access denied diff --git a/swagger/paths/application/agents/delete.yml b/swagger/paths/application/agents/delete.yml new file mode 100644 index 000000000..533d3f1ca --- /dev/null +++ b/swagger/paths/application/agents/delete.yml @@ -0,0 +1,21 @@ +tags: + - Agent +operationId: delete-agent-from-account +summary: Remove an Agent from Account +description: Remove an Agent from Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + schema: + type: integer + required: true + description: The ID of the agent to be deleted +responses: + 200: + description: Success + 404: + description: Agent not found + 403: + description: Access denied diff --git a/swagger/paths/application/agents/index.yml b/swagger/paths/application/agents/index.yml new file mode 100644 index 000000000..026893a4b --- /dev/null +++ b/swagger/paths/application/agents/index.yml @@ -0,0 +1,17 @@ +tags: + - Agent +operationId: get-account-agents +summary: List Agents in Account +description: Get Details of Agents in an Account +security: + - userApiKey: [] +responses: + 200: + description: Success + schema: + type: array + description: 'Array of all active agents' + items: + $ref: '#/definitions/agent' + 403: + description: Access denied diff --git a/swagger/paths/application/agents/update.yml b/swagger/paths/application/agents/update.yml new file mode 100644 index 000000000..8e4745348 --- /dev/null +++ b/swagger/paths/application/agents/update.yml @@ -0,0 +1,42 @@ +tags: + - Agent +operationId: update-agent-in-account +summary: Update Agent in Account +description: Update an Agent in Account +security: + - userApiKey: [] +parameters: + - in: path + name: id + schema: + type: integer + required: true + description: The ID of the agent to be updated. + - name: data + in: body + required: true + schema: + type: object + properties: + role: + type: string + enum: ['agent', 'administrator'] + description: Whether its administrator or agent + required: true + availability_status: + type: string + enum: ['available', 'busy', 'offline'] + description: The availability status of the agent. + auto_offline: + type: boolean + description: Whether the availability status of agent is configured to go offline automatically when away. +responses: + 200: + description: Success + schema: + description: 'The updated agent' + $ref: '#/definitions/agent' + 404: + description: Agent not found + 403: + description: Access denied \ No newline at end of file diff --git a/swagger/paths/contact_inboxes/create.yml b/swagger/paths/application/contact_inboxes/create.yml similarity index 100% rename from swagger/paths/contact_inboxes/create.yml rename to swagger/paths/application/contact_inboxes/create.yml diff --git a/swagger/paths/contactable_inboxes/get.yml b/swagger/paths/application/contactable_inboxes/get.yml similarity index 100% rename from swagger/paths/contactable_inboxes/get.yml rename to swagger/paths/application/contactable_inboxes/get.yml diff --git a/swagger/paths/contact/conversations.yml b/swagger/paths/application/contacts/conversations.yml similarity index 100% rename from swagger/paths/contact/conversations.yml rename to swagger/paths/application/contacts/conversations.yml diff --git a/swagger/paths/contact/crud.yml b/swagger/paths/application/contacts/crud.yml similarity index 100% rename from swagger/paths/contact/crud.yml rename to swagger/paths/application/contacts/crud.yml diff --git a/swagger/paths/contact/list_create.yml b/swagger/paths/application/contacts/list_create.yml similarity index 100% rename from swagger/paths/contact/list_create.yml rename to swagger/paths/application/contacts/list_create.yml diff --git a/swagger/paths/contact/search.yml b/swagger/paths/application/contacts/search.yml similarity index 100% rename from swagger/paths/contact/search.yml rename to swagger/paths/application/contacts/search.yml diff --git a/swagger/paths/conversation/assignments.yml b/swagger/paths/application/conversation/assignments.yml similarity index 100% rename from swagger/paths/conversation/assignments.yml rename to swagger/paths/application/conversation/assignments.yml diff --git a/swagger/paths/conversation/create.yml b/swagger/paths/application/conversation/create.yml similarity index 100% rename from swagger/paths/conversation/create.yml rename to swagger/paths/application/conversation/create.yml diff --git a/swagger/paths/conversation/index.yml b/swagger/paths/application/conversation/index.yml similarity index 100% rename from swagger/paths/conversation/index.yml rename to swagger/paths/application/conversation/index.yml diff --git a/swagger/paths/conversation/labels/create.yml b/swagger/paths/application/conversation/labels/create.yml similarity index 100% rename from swagger/paths/conversation/labels/create.yml rename to swagger/paths/application/conversation/labels/create.yml diff --git a/swagger/paths/conversation/labels/index.yml b/swagger/paths/application/conversation/labels/index.yml similarity index 100% rename from swagger/paths/conversation/labels/index.yml rename to swagger/paths/application/conversation/labels/index.yml diff --git a/swagger/paths/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml similarity index 100% rename from swagger/paths/conversation/messages/create.yml rename to swagger/paths/application/conversation/messages/create.yml diff --git a/swagger/paths/conversation/messages/create_attachment.yml b/swagger/paths/application/conversation/messages/create_attachment.yml similarity index 100% rename from swagger/paths/conversation/messages/create_attachment.yml rename to swagger/paths/application/conversation/messages/create_attachment.yml diff --git a/swagger/paths/conversation/messages/delete.yml b/swagger/paths/application/conversation/messages/delete.yml similarity index 100% rename from swagger/paths/conversation/messages/delete.yml rename to swagger/paths/application/conversation/messages/delete.yml diff --git a/swagger/paths/conversation/messages/index.yml b/swagger/paths/application/conversation/messages/index.yml similarity index 100% rename from swagger/paths/conversation/messages/index.yml rename to swagger/paths/application/conversation/messages/index.yml diff --git a/swagger/paths/conversation/show.yml b/swagger/paths/application/conversation/show.yml similarity index 100% rename from swagger/paths/conversation/show.yml rename to swagger/paths/application/conversation/show.yml diff --git a/swagger/paths/conversation/toggle_status.yml b/swagger/paths/application/conversation/toggle_status.yml similarity index 100% rename from swagger/paths/conversation/toggle_status.yml rename to swagger/paths/application/conversation/toggle_status.yml diff --git a/swagger/paths/conversation/update_last_seen.yml b/swagger/paths/application/conversation/update_last_seen.yml similarity index 100% rename from swagger/paths/conversation/update_last_seen.yml rename to swagger/paths/application/conversation/update_last_seen.yml diff --git a/swagger/paths/custom_filters/create.yml b/swagger/paths/application/custom_filters/create.yml similarity index 100% rename from swagger/paths/custom_filters/create.yml rename to swagger/paths/application/custom_filters/create.yml diff --git a/swagger/paths/custom_filters/delete.yml b/swagger/paths/application/custom_filters/delete.yml similarity index 100% rename from swagger/paths/custom_filters/delete.yml rename to swagger/paths/application/custom_filters/delete.yml diff --git a/swagger/paths/custom_filters/index.yml b/swagger/paths/application/custom_filters/index.yml similarity index 100% rename from swagger/paths/custom_filters/index.yml rename to swagger/paths/application/custom_filters/index.yml diff --git a/swagger/paths/custom_filters/show.yml b/swagger/paths/application/custom_filters/show.yml similarity index 100% rename from swagger/paths/custom_filters/show.yml rename to swagger/paths/application/custom_filters/show.yml diff --git a/swagger/paths/custom_filters/update.yml b/swagger/paths/application/custom_filters/update.yml similarity index 100% rename from swagger/paths/custom_filters/update.yml rename to swagger/paths/application/custom_filters/update.yml diff --git a/swagger/paths/inboxes/create.yml b/swagger/paths/application/inboxes/create.yml similarity index 100% rename from swagger/paths/inboxes/create.yml rename to swagger/paths/application/inboxes/create.yml diff --git a/swagger/paths/inboxes/get_agent_bot.yml b/swagger/paths/application/inboxes/get_agent_bot.yml similarity index 100% rename from swagger/paths/inboxes/get_agent_bot.yml rename to swagger/paths/application/inboxes/get_agent_bot.yml diff --git a/swagger/paths/inboxes/inbox_members/create.yml b/swagger/paths/application/inboxes/inbox_members/create.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/create.yml rename to swagger/paths/application/inboxes/inbox_members/create.yml diff --git a/swagger/paths/inboxes/inbox_members/delete.yml b/swagger/paths/application/inboxes/inbox_members/delete.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/delete.yml rename to swagger/paths/application/inboxes/inbox_members/delete.yml diff --git a/swagger/paths/inboxes/inbox_members/show.yml b/swagger/paths/application/inboxes/inbox_members/show.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/show.yml rename to swagger/paths/application/inboxes/inbox_members/show.yml diff --git a/swagger/paths/inboxes/inbox_members/update.yml b/swagger/paths/application/inboxes/inbox_members/update.yml similarity index 100% rename from swagger/paths/inboxes/inbox_members/update.yml rename to swagger/paths/application/inboxes/inbox_members/update.yml diff --git a/swagger/paths/inboxes/index.yml b/swagger/paths/application/inboxes/index.yml similarity index 100% rename from swagger/paths/inboxes/index.yml rename to swagger/paths/application/inboxes/index.yml diff --git a/swagger/paths/inboxes/set_agent_bot.yml b/swagger/paths/application/inboxes/set_agent_bot.yml similarity index 100% rename from swagger/paths/inboxes/set_agent_bot.yml rename to swagger/paths/application/inboxes/set_agent_bot.yml diff --git a/swagger/paths/inboxes/show.yml b/swagger/paths/application/inboxes/show.yml similarity index 100% rename from swagger/paths/inboxes/show.yml rename to swagger/paths/application/inboxes/show.yml diff --git a/swagger/paths/inboxes/update.yml b/swagger/paths/application/inboxes/update.yml similarity index 100% rename from swagger/paths/inboxes/update.yml rename to swagger/paths/application/inboxes/update.yml diff --git a/swagger/paths/integrations/apps/show.yml b/swagger/paths/application/integrations/apps/show.yml similarity index 100% rename from swagger/paths/integrations/apps/show.yml rename to swagger/paths/application/integrations/apps/show.yml diff --git a/swagger/paths/integrations/hooks/create.yml b/swagger/paths/application/integrations/hooks/create.yml similarity index 100% rename from swagger/paths/integrations/hooks/create.yml rename to swagger/paths/application/integrations/hooks/create.yml diff --git a/swagger/paths/integrations/hooks/delete.yml b/swagger/paths/application/integrations/hooks/delete.yml similarity index 100% rename from swagger/paths/integrations/hooks/delete.yml rename to swagger/paths/application/integrations/hooks/delete.yml diff --git a/swagger/paths/integrations/hooks/update.yml b/swagger/paths/application/integrations/hooks/update.yml similarity index 100% rename from swagger/paths/integrations/hooks/update.yml rename to swagger/paths/application/integrations/hooks/update.yml diff --git a/swagger/paths/reports/index.yml b/swagger/paths/application/reports/index.yml similarity index 100% rename from swagger/paths/reports/index.yml rename to swagger/paths/application/reports/index.yml diff --git a/swagger/paths/reports/summary.yml b/swagger/paths/application/reports/summary.yml similarity index 100% rename from swagger/paths/reports/summary.yml rename to swagger/paths/application/reports/summary.yml diff --git a/swagger/paths/teams/create.yml b/swagger/paths/application/teams/create.yml similarity index 100% rename from swagger/paths/teams/create.yml rename to swagger/paths/application/teams/create.yml diff --git a/swagger/paths/teams/delete.yml b/swagger/paths/application/teams/delete.yml similarity index 100% rename from swagger/paths/teams/delete.yml rename to swagger/paths/application/teams/delete.yml diff --git a/swagger/paths/teams/index.yml b/swagger/paths/application/teams/index.yml similarity index 100% rename from swagger/paths/teams/index.yml rename to swagger/paths/application/teams/index.yml diff --git a/swagger/paths/teams/show.yml b/swagger/paths/application/teams/show.yml similarity index 100% rename from swagger/paths/teams/show.yml rename to swagger/paths/application/teams/show.yml diff --git a/swagger/paths/teams/update.yml b/swagger/paths/application/teams/update.yml similarity index 100% rename from swagger/paths/teams/update.yml rename to swagger/paths/application/teams/update.yml diff --git a/swagger/paths/index.yml b/swagger/paths/index.yml index 4324b2504..4936bc1b7 100644 --- a/swagger/paths/index.yml +++ b/swagger/paths/index.yml @@ -116,63 +116,76 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat # ---------------- end of public api routes-----------# # ------------ Application API routes ------------# -# AgentBots + +# AgentBots /api/v1/accounts/{account_id}/agent_bots: parameters: - $ref: '#/parameters/account_id' get: - $ref: ./agent_bots/index.yml + $ref: ./application/agent_bots/index.yml post: - $ref: ./agent_bots/create.yml + $ref: ./application/agent_bots/create.yml /api/v1/accounts/{account_id}/agent_bots/{id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/agent_bot_id' get: - $ref: './agent_bots/show.yml' + $ref: './application/agent_bots/show.yml' patch: - $ref: ./agent_bots/update.yml + $ref: ./application/agent_bots/update.yml delete: - $ref: ./agent_bots/delete.yml + $ref: ./application/agent_bots/delete.yml + +# Agents +/api/v1/accounts/{account_id}/agents: + get: + $ref: ./application/agents/index.yml + post: + $ref: ./application/agents/create.yml +/api/v1/accounts/{account_id}/agents/{id}: + patch: + $ref: ./application/agents/update.yml + delete: + $ref: ./application/agents/delete.yml # Contacts /api/v1/accounts/{account_id}/contacts: - $ref: ./contact/list_create.yml + $ref: ./application/contacts/list_create.yml /api/v1/accounts/{account_id}/contacts/{id}: - $ref: ./contact/crud.yml + $ref: ./application/contacts/crud.yml /api/v1/accounts/{account_id}/contacts/{id}/conversations: - $ref: ./contact/conversations.yml + $ref: ./application/contacts/conversations.yml /api/v1/accounts/{account_id}/contacts/search: - $ref: ./contact/search.yml + $ref: ./application/contacts/search.yml /api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes: - $ref: ./contact_inboxes/create.yml + $ref: ./application/contact_inboxes/create.yml /api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes: - $ref: ./contactable_inboxes/get.yml + $ref: ./application/contactable_inboxes/get.yml # Conversations /api/v1/accounts/{account_id}/conversations: parameters: - $ref: '#/parameters/account_id' - $ref: ./conversation/index.yml + $ref: ./application/conversation/index.yml /api/v1/accounts/{account_id}/conversations/: parameters: - $ref: '#/parameters/account_id' - $ref: ./conversation/create.yml + $ref: ./application/conversation/create.yml /api/v1/accounts/{account_id}/conversations/{converstion_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/show.yml + $ref: ./application/conversation/show.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_status: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' post: - $ref: ./conversation/toggle_status.yml + $ref: ./application/conversation/toggle_status.yml # Conversations Assignments @@ -181,7 +194,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' post: - $ref: ./conversation/assignments.yml + $ref: ./application/conversation/assignments.yml # Conversation Labels @@ -190,56 +203,56 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/labels/index.yml + $ref: ./application/conversation/labels/index.yml post: - $ref: ./conversation/labels/create.yml + $ref: ./application/conversation/labels/create.yml # Inboxes /api/v1/accounts/{account_id}/inboxes: - $ref: ./inboxes/index.yml + $ref: ./application/inboxes/index.yml /api/v1/accounts/{account_id}/inboxes/{id}/: - $ref: ./inboxes/show.yml + $ref: ./application/inboxes/show.yml /api/v1/accounts/{account_id}/inboxes/: - $ref: ./inboxes/create.yml + $ref: ./application/inboxes/create.yml /api/v1/accounts/{account_id}/inboxes/{id}: - $ref: ./inboxes/update.yml + $ref: ./application/inboxes/update.yml /api/v1/accounts/{account_id}/inboxes/{id}/agent_bot: - $ref: ./inboxes/get_agent_bot.yml + $ref: ./application/inboxes/get_agent_bot.yml /api/v1/accounts/{account_id}/inboxes/{id}/set_agent_bot: - $ref: ./inboxes/set_agent_bot.yml + $ref: ./application/inboxes/set_agent_bot.yml # Inbox Members /api/v1/accounts/{account_id}/inbox_members: get: - $ref: ./inboxes/inbox_members/show.yml + $ref: ./application/inboxes/inbox_members/show.yml post: - $ref: ./inboxes/inbox_members/create.yml + $ref: ./application/inboxes/inbox_members/create.yml patch: - $ref: ./inboxes/inbox_members/update.yml + $ref: ./application/inboxes/inbox_members/update.yml delete: - $ref: ./inboxes/inbox_members/delete.yml + $ref: ./application/inboxes/inbox_members/delete.yml # Messages /api/v1/accounts/{account_id}/conversations/{id}/messages: - $ref: ./conversation/messages/create_attachment.yml + $ref: ./application/conversation/messages/create_attachment.yml /api/v1/accounts/{account_id}/conversations/{converstion_id}/messages: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' get: - $ref: ./conversation/messages/index.yml + $ref: ./application/conversation/messages/index.yml post: - $ref: ./conversation/messages/create.yml + $ref: ./application/conversation/messages/create.yml /api/v1/accounts/{account_id}/conversations/{conversation_id}/messages/{message_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/conversation_id' - $ref: '#/parameters/message_id' delete: - $ref: ./conversation/messages/delete.yml + $ref: ./application/conversation/messages/delete.yml @@ -248,14 +261,14 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat parameters: - $ref: '#/parameters/account_id' get: - $ref: './integrations/apps/show.yml' + $ref: './application/integrations/apps/show.yml' /api/v1/accounts/{account_id}/integrations/hooks: post: - $ref: './integrations/hooks/create.yml' + $ref: './application/integrations/hooks/create.yml' patch: - $ref: ./integrations/hooks/update.yml + $ref: ./application/integrations/hooks/update.yml delete: - $ref: ./integrations/hooks/delete.yml + $ref: ./application/integrations/hooks/delete.yml @@ -269,19 +282,19 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat parameters: - $ref: '#/parameters/account_id' get: - $ref: ./teams/index.yml + $ref: ./application/teams/index.yml post: - $ref: ./teams/create.yml + $ref: ./application/teams/create.yml /api/v1/accounts/{account_id}/teams/{id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/team_id' get: - $ref: './teams/show.yml' + $ref: './application/teams/show.yml' patch: - $ref: ./teams/update.yml + $ref: ./application/teams/update.yml delete: - $ref: ./teams/delete.yml + $ref: ./application/teams/delete.yml ### Custom Filters goes here @@ -297,19 +310,19 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat required: false description: The type of custom filter get: - $ref: ./custom_filters/index.yml + $ref: ./application/custom_filters/index.yml post: - $ref: ./custom_filters/create.yml + $ref: ./application/custom_filters/create.yml /api/v1/accounts/{account_id}/custom_filters/{custom_filter_id}: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/custom_filter_id' get: - $ref: './custom_filters/show.yml' + $ref: './application/custom_filters/show.yml' patch: - $ref: ./custom_filters/update.yml + $ref: ./application/custom_filters/update.yml delete: - $ref: ./custom_filters/delete.yml + $ref: ./application/custom_filters/delete.yml ### Reports @@ -335,7 +348,7 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat type: string description: The timestamp from where report should stop. get: - $ref: './reports/index.yml' + $ref: './application/reports/index.yml' # Summary /api/v2/accounts/{id}/reports/summary: @@ -358,4 +371,4 @@ public/api/v1/inboxes/{inbox_identifier}/contacts/{contact_identifier}/conversat type: string description: The timestamp from where report should stop. get: - $ref: './reports/summary.yml' + $ref: './application/reports/summary.yml' diff --git a/swagger/swagger.json b/swagger/swagger.json index 450c18f4d..0d8570d99 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -1103,6 +1103,219 @@ } } }, + "/api/v1/accounts/{account_id}/agents": { + "get": { + "tags": [ + "Agent" + ], + "operationId": "get-account-agents", + "summary": "List Agents in Account", + "description": "Get Details of Agents in an Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "type": "array", + "description": "Array of all active agents", + "items": { + "$ref": "#/definitions/agent" + } + } + }, + "403": { + "description": "Access denied" + } + } + }, + "post": { + "tags": [ + "Agent" + ], + "operationId": "add-new-agent-to-account", + "summary": "Add a New Agent", + "description": "Add a new Agent to Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Full Name of the agent", + "required": true + }, + "email": { + "type": "string", + "description": "Email of the Agent", + "required": true + }, + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "Whether its administrator or agent", + "required": true + }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + } + } + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/agent" + } + }, + "403": { + "description": "Access denied" + } + } + } + }, + "/api/v1/accounts/{account_id}/agents/{id}": { + "patch": { + "tags": [ + "Agent" + ], + "operationId": "update-agent-in-account", + "summary": "Update Agent in Account", + "description": "Update an Agent in Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true, + "description": "The ID of the agent to be updated." + }, + { + "name": "data", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "agent", + "administrator" + ], + "description": "Whether its administrator or agent", + "required": true + }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + } + } + } + } + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/agent" + } + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Access denied" + } + } + }, + "delete": { + "tags": [ + "Agent" + ], + "operationId": "delete-agent-from-account", + "summary": "Remove an Agent from Account", + "description": "Remove an Agent from Account", + "security": [ + { + "userApiKey": [ + + ] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true, + "description": "The ID of the agent to be deleted" + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Agent not found" + }, + "403": { + "description": "Access denied" + } + } + } + }, "/api/v1/accounts/{account_id}/contacts": { "get": { "tags": [ @@ -3432,7 +3645,7 @@ "type": "object", "properties": { "id": { - "type": "number" + "type": "integer" }, "uid": { "type": "string" @@ -3450,7 +3663,7 @@ "type": "string" }, "account_id": { - "type": "number" + "type": "integer" }, "role": { "type": "string", @@ -3462,6 +3675,19 @@ "confirmed": { "type": "boolean" }, + "availability_status": { + "type": "string", + "enum": [ + "available", + "busy", + "offline" + ], + "description": "The availability status of the agent computed by Chatwoot." + }, + "auto_offline": { + "type": "boolean", + "description": "Whether the availability status of agent is configured to go offline automatically when away." + }, "custom_attributes": { "type": "object", "description": "Available for users who are created through platform APIs and has custom attributes associated." @@ -4556,6 +4782,7 @@ "name": "Application", "tags": [ "Account AgentBots", + "Agent", "Contact", "Conversation", "Conversation Assignment", From 700721ea6d1b4d8c44d2fa8c8f1fcbd898b9b3f3 Mon Sep 17 00:00:00 2001 From: Tejaswini Chile Date: Thu, 7 Oct 2021 14:36:54 +0530 Subject: [PATCH 011/232] fix: Issue when Instagram response body is empty Fixes #3138 --- app/services/instagram/send_on_instagram_service.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/instagram/send_on_instagram_service.rb b/app/services/instagram/send_on_instagram_service.rb index c47b18188..dde8cf3d0 100644 --- a/app/services/instagram/send_on_instagram_service.rb +++ b/app/services/instagram/send_on_instagram_service.rb @@ -62,9 +62,8 @@ class Instagram::SendOnInstagramService < Base::SendOnChannelService body: message_content, query: query ) - # response = HTTParty.post(url, options) - Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body][:error] + Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body] response[:body] end From 8c192559fee9c899cfd16933b10a33943fa54ccc Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 7 Oct 2021 18:06:43 +0530 Subject: [PATCH 012/232] chore: Rate limits on widget conversation endpoints (#3162) - Limit widget conversation creation to 6 per 12 hours - Enable rack attack by default --- .env.example | 2 +- config/initializers/rack_attack.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index dd4052f69..ade61ba74 100644 --- a/.env.example +++ b/.env.example @@ -169,7 +169,7 @@ USE_INBOX_AVATAR_FOR_BOT=true ## Rack Attack configuration ## To prevent and throttle abusive requests -# ENABLE_RACK_ATTACK=false +# ENABLE_RACK_ATTACK=true ## Running chatwoot as an API only server diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 1e1aa9cf8..acbf7e138 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -52,6 +52,16 @@ class Rack::Attack req.ip if req.path == '/api/v1/accounts' && req.post? end + ## Prevent Conversation Bombing on Widget APIs ### + throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req| + req.ip if req.path == '/api/v1/widget/conversations' && req.post? + end + + ## Prevent Contact update Bombing in Widget API ### + throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req| + req.ip if req.path == '/api/v1/widget/contacts' && (req.patch? || req.put?) + end + # ref: https://github.com/rack/rack-attack/issues/399 throttle('login/email', limit: 20, period: 5.minutes) do |req| if req.path == '/auth/sign_in' && req.post? @@ -75,4 +85,4 @@ ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" end -Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', false)) +Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) From 5d3cce12d5b07adc44275dae72b337b594d476bc Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Fri, 8 Oct 2021 12:14:23 +0530 Subject: [PATCH 013/232] fix: Disable rack attack gem in circleCI (#3167) --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index c5a6430a3..fbdc6cc14 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,7 @@ defaults: &defaults environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false + - ENABLE_RACK_ATTACK: false jobs: build: <<: *defaults From b9e85a628b0561a2cc12ebcb307d634cf6efeba7 Mon Sep 17 00:00:00 2001 From: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com> Date: Fri, 8 Oct 2021 12:53:24 +0530 Subject: [PATCH 014/232] Feat : Toggle to enforce user validation in Chatwoots web SDK (#3137) * If enabled, enforces user validation with identifier_hash * Fixes the hmac flag payload * Adds missing i18n label for checkbox * If enabled, Adds EOF on json file * If applied, Handles HMAC Disable option Co-authored-by: Tejaswini Chile Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../dashboard/i18n/locale/en/inboxMgmt.json | 5 ++++ .../dashboard/settings/inbox/Settings.vue | 24 +++++++++++++++++++ app/models/channel/web_widget.rb | 2 +- app/views/api/v1/models/_inbox.json.jbuilder | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 1d935dd64..b05a730e6 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -302,6 +302,9 @@ "ENABLE_CSAT": { "ENABLED": "Enabled", "DISABLED": "Disabled" + }, + "ENABLE_HMAC": { + "LABEL": "Enable" } }, "DELETE": { @@ -351,6 +354,8 @@ "AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.", "HMAC_VERIFICATION": "User Identity Validation", "HMAC_DESCRIPTION": "Inorder to validate the user's identity, the SDK allows you to pass an `identifier_hash` for each user. You can generate HMAC using 'sha256' with the key shown here.", + "HMAC_MANDATORY_VERIFICATION": "Enforce User Identity Validation", + "HMAC_MANDATORY_DESCRIPTION": "If enabled, Chatwoot SDKs setUser method will not work unless the `identifier_hash` is provided for each user.", "INBOX_IDENTIFIER": "Inbox Identifier", "INBOX_IDENTIFIER_SUB_TEXT": "Use the `inbox_identifier` token shown here to authentication your API clients.", "FORWARD_EMAIL_TITLE": "Forward to Email", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 63e74e2fc..b33ce4ed3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -316,6 +316,24 @@ > + +
+ + +
+
@@ -377,6 +395,7 @@ export default { avatarUrl: '', selectedAgents: [], greetingEnabled: true, + hmacMandatory: null, greetingMessage: '', autoAssignment: false, emailCollectEnabled: false, @@ -511,6 +530,9 @@ export default { e.target.value ); }, + handleHmacFlag() { + this.updateInbox(); + }, toggleInput(selected, current) { if (selected.includes(current)) { const newSelectedFlags = selected.filter(flag => flag !== current); @@ -533,6 +555,7 @@ export default { this.selectedInboxName = this.inbox.name; this.webhookUrl = this.inbox.webhook_url; this.greetingEnabled = this.inbox.greeting_enabled || false; + this.hmacMandatory = this.inbox.hmac_mandatory || false; this.greetingMessage = this.inbox.greeting_message || ''; this.autoAssignment = this.inbox.enable_auto_assignment; this.emailCollectEnabled = this.inbox.enable_email_collect; @@ -589,6 +612,7 @@ export default { welcome_tagline: this.channelWelcomeTagline || '', selectedFeatureFlags: this.selectedFeatureFlags, reply_time: this.replyTime || 'in_a_few_minutes', + hmac_mandatory: this.hmacMandatory, }, }; if (this.avatarFile) { diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb index 38f6e2eb5..debee3bd5 100644 --- a/app/models/channel/web_widget.rb +++ b/app/models/channel/web_widget.rb @@ -29,7 +29,7 @@ class Channel::WebWidget < ApplicationRecord include FlagShihTzu self.table_name = 'channel_web_widgets' - EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, + EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled, :hmac_mandatory, { pre_chat_form_options: [:pre_chat_message, :require_email] }, { selected_feature_flags: [] }].freeze diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 5c25cedd0..e27ebcac4 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -20,6 +20,7 @@ json.callback_webhook_url resource.callback_webhook_url ## WebWidget Attributes json.widget_color resource.channel.try(:widget_color) json.website_url resource.channel.try(:website_url) +json.hmac_mandatory resource.channel.try(:hmac_mandatory) json.welcome_title resource.channel.try(:welcome_title) json.welcome_tagline resource.channel.try(:welcome_tagline) json.web_widget_script resource.channel.try(:web_widget_script) From ec2dc1b61b628ba3e77c0f0a328a2f42c7dad4f5 Mon Sep 17 00:00:00 2001 From: Sanju Date: Fri, 8 Oct 2021 13:55:21 +0530 Subject: [PATCH 015/232] fix: Update the styles for ol & li - lists in dashboard (#3110) --- .../components/widgets/conversation/bubble/Text.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue index be1cc555e..281ca630c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Text.vue @@ -60,11 +60,9 @@ export default { .text-content { overflow: auto; - &::v-deep { - ul, - ol { - margin-left: var(--space-normal); - } + ul, + ol { + padding-left: var(--space-two); } table { all: revert; From 1c4afb10dfaceed44b4e8f06184182f4ecca2048 Mon Sep 17 00:00:00 2001 From: Sanju Date: Fri, 8 Oct 2021 14:08:13 +0530 Subject: [PATCH 016/232] fix: Open live-chat widget clicking on any unread message (#3153) Co-authored-by: Pranav Raj S --- app/javascript/widget/components/UnreadMessage.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/widget/components/UnreadMessage.vue b/app/javascript/widget/components/UnreadMessage.vue index e8ee1cca4..48a6b4686 100644 --- a/app/javascript/widget/components/UnreadMessage.vue +++ b/app/javascript/widget/components/UnreadMessage.vue @@ -83,6 +83,8 @@ export default { onClickMessage() { if (this.campaignId) { bus.$emit('on-campaign-view-clicked', this.campaignId); + } else { + bus.$emit('on-unread-view-clicked'); } }, }, From 0e0632be228e0d91c23dd8d9addea71fc6470805 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Fri, 8 Oct 2021 15:45:45 +0530 Subject: [PATCH 017/232] chore: Minor Housekeeping tasks (#3169) - Limit Rack attack to production environments - Make the long-running data migration optional --- .circleci/config.yml | 1 - config/initializers/rack_attack.rb | 2 +- .../20210923190418_add_online_status_to_account_users.rb | 5 +---- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index fbdc6cc14..c5a6430a3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,7 +17,6 @@ defaults: &defaults environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - RAILS_LOG_TO_STDOUT: false - - ENABLE_RACK_ATTACK: false jobs: build: <<: *defaults diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index acbf7e138..39556b6f2 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -85,4 +85,4 @@ ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start Rails.logger.info "[Rack::Attack][Blocked] remote_ip: \"#{payload[:request].remote_ip}\", path: \"#{payload[:request].path}\"" end -Rack::Attack.enabled = ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) +Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false diff --git a/db/migrate/20210923190418_add_online_status_to_account_users.rb b/db/migrate/20210923190418_add_online_status_to_account_users.rb index eccc9be1c..00b2d4999 100644 --- a/db/migrate/20210923190418_add_online_status_to_account_users.rb +++ b/db/migrate/20210923190418_add_online_status_to_account_users.rb @@ -4,12 +4,9 @@ class AddOnlineStatusToAccountUsers < ActiveRecord::Migration[6.1] t.integer :availability, default: 0, null: false t.boolean :auto_offline, default: true, null: false end - - update_existing_user_availability end - private - + # run as a seperate data migration if you want to migrate the user statuses def update_existing_user_availability User.find_in_batches do |user_batch| user_batch.each do |user| From 68e697c379006e41ed088a73c08cd05ee622fc06 Mon Sep 17 00:00:00 2001 From: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com> Date: Mon, 11 Oct 2021 13:00:48 +0530 Subject: [PATCH 018/232] feat: Support cc and bcc in email replies (#3098) Co-authored-by: Tejaswini Co-authored-by: Pranav Raj S --- app/builders/messages/message_builder.rb | 11 +++++ app/javascript/dashboard/api/inbox/message.js | 10 ++++ .../widgets/conversation/Message.vue | 10 +++- .../widgets/conversation/ReplyBox.vue | 25 ++++++++++ .../widgets/conversation/ReplyEmailHead.vue | 49 ++++++++++++------- .../widgets/conversation/bubble/MailHead.vue | 12 ++++- app/mailers/conversation_reply_mailer.rb | 17 ++++++- .../mailers/conversation_reply_mailer_spec.rb | 26 +++++++++- 8 files changed, 135 insertions(+), 25 deletions(-) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index b6ae5409d..a30394edc 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -16,6 +16,7 @@ class Messages::MessageBuilder def perform @message = @conversation.messages.build(message_params) process_attachments + process_emails @message.save! @message end @@ -34,6 +35,16 @@ class Messages::MessageBuilder end end + def process_emails + return unless @conversation.inbox&.inbox_type == 'Email' + + cc_emails = @params[:cc_emails].split(',') if @params[:cc_emails] + bcc_emails = @params[:bcc_emails].split(',') if @params[:bcc_emails] + + @message.content_attributes[:cc_emails] = cc_emails + @message.content_attributes[:bcc_emails] = bcc_emails + end + def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' diff --git a/app/javascript/dashboard/api/inbox/message.js b/app/javascript/dashboard/api/inbox/message.js index 98c250e60..39bb5eb04 100644 --- a/app/javascript/dashboard/api/inbox/message.js +++ b/app/javascript/dashboard/api/inbox/message.js @@ -8,6 +8,8 @@ export const buildCreatePayload = ({ contentAttributes, echoId, file, + ccEmails, + bccEmails, }) => { let payload; if (file) { @@ -18,12 +20,16 @@ export const buildCreatePayload = ({ } payload.append('private', isPrivate); payload.append('echo_id', echoId); + payload.append('cc_emails', ccEmails); + payload.append('bcc_emails', bccEmails); } else { payload = { content: message, private: isPrivate, echo_id: echoId, content_attributes: contentAttributes, + cc_emails: ccEmails, + bcc_emails: bccEmails, }; } return payload; @@ -41,6 +47,8 @@ class MessageApi extends ApiClient { contentAttributes, echo_id: echoId, file, + ccEmails, + bccEmails, }) { return axios({ method: 'post', @@ -51,6 +59,8 @@ class MessageApi extends ApiClient { contentAttributes, echoId, file, + ccEmails, + bccEmails, }), }); } diff --git a/app/javascript/dashboard/components/widgets/conversation/Message.vue b/app/javascript/dashboard/components/widgets/conversation/Message.vue index e5de1b040..b3674681a 100644 --- a/app/javascript/dashboard/components/widgets/conversation/Message.vue +++ b/app/javascript/dashboard/components/widgets/conversation/Message.vue @@ -3,8 +3,9 @@
0); }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index fedd96927..2de33e6af 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -20,6 +20,11 @@ v-on-clickaway="hideEmojiPicker" :on-click="emojiOnClick" /> + diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue index 4f30f03bd..b8e193783 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyEmailHead.vue @@ -1,17 +1,17 @@ + @@ -190,12 +217,6 @@ export default { left: var(--space-normal); } -::v-deep .multiselect__tags .option__title { - display: inline-flex; - align-items: center; - margin-left: var(--space-small); -} - .footer { margin-top: var(--space-medium); display: flex; @@ -206,4 +227,8 @@ export default { .error .message { margin-top: 0; } + +.label--merge-warning { + margin-left: var(--space-small); +} diff --git a/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue b/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue index 5d028bd6a..1f238e509 100644 --- a/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue +++ b/app/javascript/dashboard/modules/contact/components/MergeContactSummary.vue @@ -5,7 +5,7 @@
  • - + -
    - - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - -
    -
    - - {{ $t('DELETE_CONTACT.BUTTON_LABEL') }} - -
    -
-
-
- - - -
+
+ + + +
+
diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 96a7c811a..801157bb4 100644 --- a/app/javascript/dashboard/store/modules/contacts/actions.js +++ b/app/javascript/dashboard/store/modules/contacts/actions.js @@ -4,6 +4,7 @@ import { } from 'shared/helpers/CustomErrors'; import types from '../../mutation-types'; import ContactAPI from '../../../api/contacts'; +import AccountActionsAPI from '../../../api/accountActions'; export const actions = { search: async ({ commit }, { search, page, sortAttr, label }) => { @@ -137,6 +138,18 @@ export const actions = { commit(types.SET_CONTACT_ITEM, data); }, + merge: async ({ commit }, { childId, parentId }) => { + commit(types.SET_CONTACT_UI_FLAG, { isMerging: true }); + try { + const response = await AccountActionsAPI.merge(parentId, childId); + commit(types.SET_CONTACT_ITEM, response.data); + } catch (error) { + throw new Error(error); + } finally { + commit(types.SET_CONTACT_UI_FLAG, { isMerging: false }); + } + }, + deleteContactThroughConversations: ({ commit }, id) => { commit(types.DELETE_CONTACT, id); commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true }); diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index f1982e4be..b2657ca78 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -13,6 +13,7 @@ const state = { isFetchingItem: false, isFetchingInboxes: false, isUpdating: false, + isMerging: false, isDeleting: false, }, sortOrder: [], diff --git a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js index 03d49d8d0..dc49f4731 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js @@ -168,6 +168,30 @@ describe('#actions', () => { }); }); + describe('#merge', () => { + it('sends correct mutations if API is success', async () => { + axios.post.mockResolvedValue({ + data: contactList[0], + }); + await actions.merge({ commit }, { childId: 0, parentId: 1 }); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isMerging: true }], + [types.SET_CONTACT_ITEM, contactList[0]], + [types.SET_CONTACT_UI_FLAG, { isMerging: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.merge({ commit }, { childId: 0, parentId: 1 }) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isMerging: true }], + [types.SET_CONTACT_UI_FLAG, { isMerging: false }], + ]); + }); + }); + describe('#deleteContactThroughConversations', () => { it('returns correct mutations', () => { actions.deleteContactThroughConversations({ commit }, contactList[0].id); From 5799b9fa265526ffdbc693adc4b0a9fcbe6b6e18 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 13 Oct 2021 18:58:30 +0530 Subject: [PATCH 032/232] chore: Fix the prop warning issue in contact merge modal (#3211) --- .../modules/contact/components/ContactDropdownItem.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue b/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue index c4467c75f..ecee39509 100644 --- a/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue +++ b/app/javascript/dashboard/modules/contact/components/ContactDropdownItem.vue @@ -47,8 +47,8 @@ export default { default: '', }, identifier: { - type: String, - default: '', + type: [String, Number], + required: true, }, }, }; From 5c30bc3e2be5ebe7500b3e6af4fa5cd980c8b804 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Thu, 14 Oct 2021 11:51:00 +0530 Subject: [PATCH 033/232] fix: Read message appears on page refresh in the widget (#3175) --- app/javascript/widget/App.vue | 6 ++++-- app/javascript/widget/helpers/actionCable.js | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index 2f2113b57..ef9d63aac 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -36,6 +36,7 @@ export default { widgetPosition: 'right', showPopoutButton: false, isWebWidgetTriggered: false, + isWidgetOpen: false, }; }, computed: { @@ -134,8 +135,8 @@ export default { this.hideMessageBubble = !!hideBubble; }, registerUnreadEvents() { - bus.$on('on-agent-message-recieved', () => { - if (!this.isIFrame) { + bus.$on('on-agent-message-received', () => { + if (!this.isIFrame || this.isWidgetOpen) { this.setUserLastSeen(); } this.setUnreadView(); @@ -257,6 +258,7 @@ export default { this.showUnreadView = false; this.showCampaignView = false; } else if (message.event === 'toggle-open') { + this.isWidgetOpen = message.isOpen; this.toggleOpen(); } }); diff --git a/app/javascript/widget/helpers/actionCable.js b/app/javascript/widget/helpers/actionCable.js index 32bdd644f..c4cf483c6 100644 --- a/app/javascript/widget/helpers/actionCable.js +++ b/app/javascript/widget/helpers/actionCable.js @@ -34,7 +34,7 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store .dispatch('conversation/addOrUpdateMessage', data) .then(() => { - window.bus.$emit('on-agent-message-recieved'); + window.bus.$emit('on-agent-message-received'); }); }; From 99abbb8158153f02f39550a334bb01821c2fdf62 Mon Sep 17 00:00:00 2001 From: Pranav Raj S Date: Thu, 14 Oct 2021 12:55:46 +0530 Subject: [PATCH 034/232] feat: Display `sent` status of emails in email channel (#3125) --- .codeclimate.yml | 4 ++ .../widgets/conversation/ReplyBox.vue | 14 +++--- .../widgets/conversation/bubble/Actions.vue | 18 +++++++- .../dashboard/i18n/locale/en/chatlist.json | 1 + .../dashboard/settings/inbox/Settings.vue | 3 ++ app/javascript/shared/mixins/inboxMixin.js | 17 +++---- app/mailboxes/application_mailbox.rb | 2 +- app/mailboxes/reply_mailbox.rb | 4 +- app/mailers/conversation_reply_mailer.rb | 42 +++++++++++++----- app/models/contact_inbox.rb | 2 +- app/models/message.rb | 10 ++--- .../email_reply.html.erb | 8 ++++ .../conversation_reply_email_worker.rb | 8 ++-- app/workers/email_reply_worker.rb | 14 ++++++ .../mailers/conversation_reply_mailer_spec.rb | 35 +++++++++++---- spec/models/message_spec.rb | 8 ++++ .../conversation_reply_email_worker_spec.rb | 8 ++-- spec/workers/email_reply_worker_spec.rb | 44 +++++++++++++++++++ 18 files changed, 187 insertions(+), 55 deletions(-) create mode 100644 app/views/mailers/conversation_reply_mailer/email_reply.html.erb create mode 100644 app/workers/email_reply_worker.rb create mode 100644 spec/workers/email_reply_worker_spec.rb diff --git a/.codeclimate.yml b/.codeclimate.yml index ac38c27bb..50d5360fd 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -14,6 +14,10 @@ plugins: checks: similar-code: enabled: false + method-count: + enabled: true + config: + threshold: 25 exclude_patterns: - "spec/" - "**/specs/" diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 2de33e6af..661515adf 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -22,8 +22,8 @@ /> diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index 59a0a9896..597a5504d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -1,6 +1,12 @@