diff --git a/.bundler-audit.yml b/.bundler-audit.yml new file mode 100644 index 000000000..afe8702ac --- /dev/null +++ b/.bundler-audit.yml @@ -0,0 +1,3 @@ +--- +ignore: + - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) 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/.env.example b/.env.example index 39a533355..447c2f4ef 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= @@ -113,7 +116,7 @@ SLACK_CLIENT_SECRET= ### Change this env variable only if you are using a custom build mobile app ## Mobile app env variables -IOS_APP_ID=6C953F3RX2.com.chatwoot.app +IOS_APP_ID=L7YLMN4634.com.chatwoot.app ANDROID_BUNDLE_ID=com.chatwoot.app # https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section) @@ -166,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/Gemfile b/Gemfile index 1dddb482c..bbd55d4cd 100644 --- a/Gemfile +++ b/Gemfile @@ -56,7 +56,6 @@ gem 'activerecord-import' gem 'dotenv-rails' gem 'foreman' gem 'puma' -gem 'rack-timeout' gem 'webpacker', '~> 5.x' # metrics on heroku gem 'barnes' @@ -122,6 +121,11 @@ gem 'hairtrigger' gem 'procore-sift' +group :production, :staging do + # we dont want request timing out in development while using byebug + gem 'rack-timeout' +end + group :development do gem 'annotate' gem 'bullet' @@ -143,6 +147,11 @@ group :test do end group :development, :test do + # TODO: is this needed ? + # errors thrown by devise password gem + gem 'flay' + gem 'rspec' + # for error thrown by devise password gem gem 'active_record_query_trace' gem 'bundle-audit', require: false gem 'byebug', platform: :mri diff --git a/Gemfile.lock b/Gemfile.lock index 45a7b9133..11d12a459 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,21 +90,21 @@ GEM rake (>= 10.4, < 14.0) ast (2.4.2) attr_extras (6.2.4) - aws-eventstream (1.1.1) - aws-partitions (1.482.0) - aws-sdk-core (3.119.0) + aws-eventstream (1.2.0) + aws-partitions (1.513.0) + aws-sdk-core (3.121.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.46.0) - aws-sdk-core (~> 3, >= 3.119.0) + aws-sdk-kms (1.49.0) + aws-sdk-core (~> 3, >= 3.120.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.98.0) - aws-sdk-core (~> 3, >= 3.119.0) + aws-sdk-s3 (1.103.0) + aws-sdk-core (~> 3, >= 3.120.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.1) - aws-sigv4 (1.2.4) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.4.0) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.1) azure-storage-common (~> 2.0) @@ -119,28 +119,28 @@ GEM statsd-ruby (~> 1.1) bcrypt (3.1.16) bindex (0.8.1) - bootsnap (1.7.7) + bootsnap (1.9.1) msgpack (~> 1.0) brakeman (5.1.1) browser (5.3.1) builder (3.2.4) - bullet (6.1.4) + bullet (6.1.5) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) bundler-audit - bundler-audit (0.8.0) + bundler-audit (0.9.0.1) bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) coderay (1.1.3) - commonmarker (0.22.0) + commonmarker (0.23.2) concurrent-ruby (1.1.9) connection_pool (2.2.5) crack (0.4.5) rexml crass (1.0.6) - cypress-on-rails (1.10.1) + cypress-on-rails (1.11.0) rack database_cleaner (2.0.1) database_cleaner-active_record (~> 2.0.0) @@ -150,7 +150,7 @@ GEM database_cleaner-core (2.0.1) datetime_picker_rails (0.0.7) momentjs-rails (>= 2.8.1) - ddtrace (0.51.1) + ddtrace (0.53.0) ffi (~> 1.0) msgpack declarative (0.0.20) @@ -174,12 +174,13 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) - down (5.2.3) + down (5.2.4) addressable (~> 2.8) ecma-re-validator (0.3.0) regexp_parser (~> 2.0) erubi (1.10.0) - et-orbi (1.2.4) + erubis (2.7.0) + et-orbi (1.2.5) tzinfo execjs (2.8.1) facebook-messenger (2.0.1) @@ -190,7 +191,7 @@ GEM factory_bot_rails (6.2.0) factory_bot (~> 6.2.0) railties (>= 5.0.0) - faker (2.18.0) + faker (2.19.0) i18n (>= 1.6, < 2) faraday (1.0.1) multipart-post (>= 1.2, < 3) @@ -198,10 +199,15 @@ GEM faraday (~> 1.0) fcm (1.0.3) faraday (~> 1) - ffi (1.15.3) + ffi (1.15.4) flag_shih_tzu (0.3.23) + flay (2.12.1) + erubis (~> 2.7.0) + path_expander (~> 1.0) + ruby_parser (~> 3.0) + sexp_processor (~> 4.0) foreman (0.87.2) - fugit (1.5.0) + fugit (1.5.2) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.4) gapic-common (0.3.4) @@ -210,7 +216,7 @@ GEM googleapis-common-protos-types (>= 1.0.4, < 2.0) googleauth (~> 0.9) grpc (~> 1.25) - geocoder (1.6.7) + geocoder (1.7.0) gli (2.20.1) globalid (0.5.2) activesupport (>= 5.0) @@ -223,9 +229,9 @@ GEM retriable (>= 2.0, < 4.a) rexml webrick - google-apis-iamcredentials_v1 (0.6.0) + google-apis-iamcredentials_v1 (0.7.0) google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.6.0) + google-apis-storage_v1 (0.8.0) google-apis-core (>= 0.4, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) @@ -238,7 +244,7 @@ GEM google-cloud-errors (~> 1.0) google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) - google-cloud-errors (1.1.0) + google-cloud-errors (1.2.0) google-cloud-storage (1.34.1) addressable (~> 2.5) digest-crc (~> 0.4) @@ -247,28 +253,32 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - google-protobuf (3.17.3-universal-darwin) - google-protobuf (3.17.3-x86_64-linux) - googleapis-common-protos (1.3.11) + google-protobuf (3.18.1) + google-protobuf (3.18.1-universal-darwin) + google-protobuf (3.18.1-x86_64-linux) + googleapis-common-protos (1.3.12) google-protobuf (~> 3.14) - googleapis-common-protos-types (>= 1.0.6, < 2.0) + googleapis-common-protos-types (~> 1.2) grpc (~> 1.27) - googleapis-common-protos-types (1.1.0) + googleapis-common-protos-types (1.2.0) google-protobuf (~> 3.14) - googleauth (0.17.0) + googleauth (0.17.1) faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.14) + signet (~> 0.15) groupdate (5.2.2) activesupport (>= 5) - grpc (1.38.0-universal-darwin) - google-protobuf (~> 3.15) + grpc (1.41.0) + google-protobuf (~> 3.17) googleapis-common-protos-types (~> 1.0) - grpc (1.38.0-x86_64-linux) - google-protobuf (~> 3.15) + grpc (1.41.0-universal-darwin) + google-protobuf (~> 3.17) + googleapis-common-protos-types (~> 1.0) + grpc (1.41.0-x86_64-linux) + google-protobuf (~> 3.17) googleapis-common-protos-types (~> 1.0) haikunator (1.1.1) hairtrigger (0.2.24) @@ -282,7 +292,7 @@ GEM http-accept (1.7.0) http-cookie (1.0.4) domain_name (~> 0.5) - httparty (0.18.1) + httparty (0.20.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) httpclient (2.8.3) @@ -306,7 +316,7 @@ GEM hana (~> 1.3) regexp_parser (~> 2.0) uri_template (~> 0.7) - jwt (2.2.3) + jwt (2.3.0) kaminari (1.2.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.1) @@ -327,9 +337,9 @@ GEM addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) - line-bot-api (1.21.0) - liquid (5.0.1) - listen (3.6.0) + line-bot-api (1.22.0) + liquid (5.1.0) + listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) loofah (2.12.0) @@ -337,17 +347,18 @@ GEM nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - marcel (1.0.1) + marcel (1.0.2) maxminddb (0.1.22) memoist (0.16.2) method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.0704) + mime-types-data (3.2021.0901) mini_magick (4.11.0) - mini_mime (1.1.1) + mini_mime (1.1.2) + mini_portile2 (2.5.3) minitest (5.14.4) - mock_redis (0.28.0) + mock_redis (0.29.0) ruby2_keywords momentjs-rails (2.20.1) railties (>= 3.1) @@ -358,8 +369,11 @@ GEM net-http-persistent (4.0.1) connection_pool (~> 2.2) netrc (0.11.0) - newrelic_rpm (7.2.0) + newrelic_rpm (8.0.0) nio4r (2.5.8) + nokogiri (1.11.7) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) nokogiri (1.11.7-arm64-darwin) racc (~> 1.4) nokogiri (1.11.7-x86_64-darwin) @@ -369,9 +383,10 @@ GEM oauth (0.5.6) orm_adapter (0.5.0) os (1.1.1) - parallel (1.20.1) + parallel (1.21.0) parser (3.0.2.0) ast (~> 2.4.1) + path_expander (1.1.0) pg (1.2.3) procore-sift (0.16.0) rails (> 4.2.0) @@ -381,9 +396,9 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (5.4.0) + puma (5.5.1) nio4r (~> 2.0) - pundit (2.1.0) + pundit (2.1.1) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.5.2) @@ -415,7 +430,7 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.4.1) + rails-html-sanitizer (1.4.2) loofah (~> 2.3) railties (6.1.4.1) actionpack (= 6.1.4.1) @@ -446,6 +461,10 @@ GEM netrc (~> 0.8) retriable (3.1.2) rexml (3.2.5) + rspec (3.10.0) + rspec-core (~> 3.10.0) + rspec-expectations (~> 3.10.0) + rspec-mocks (~> 3.10.0) rspec-core (3.10.1) rspec-support (~> 3.10.0) rspec-expectations (3.10.1) @@ -454,7 +473,7 @@ GEM rspec-mocks (3.10.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.10.0) - rspec-rails (5.0.1) + rspec-rails (5.0.2) actionpack (>= 5.2) activesupport (>= 5.2) railties (>= 5.2) @@ -463,35 +482,34 @@ GEM rspec-mocks (~> 3.10) rspec-support (~> 3.10) rspec-support (3.10.2) - rubocop (1.18.4) + rubocop (1.22.1) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.8.0, < 2.0) + rubocop-ast (>= 1.12.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.8.0) + rubocop-ast (1.12.0) parser (>= 3.0.1.1) - rubocop-performance (1.11.4) + rubocop-performance (1.11.5) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.11.3) + rubocop-rails (2.12.3) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.7.0, < 2.0) - rubocop-rspec (2.4.0) - rubocop (~> 1.0) - rubocop-ast (>= 1.1.0) + rubocop-rspec (2.5.0) + rubocop (~> 1.19) ruby-progressbar (1.11.0) - ruby-vips (2.1.2) + ruby-vips (2.1.3) ffi (~> 1.12) ruby2_keywords (0.0.5) ruby2ruby (2.4.4) ruby_parser (~> 3.1) sexp_processor (~> 4.6) - ruby_parser (3.16.0) + ruby_parser (3.17.0) sexp_processor (~> 4.15, >= 4.15.1) sassc (2.4.0) ffi (~> 1.9) @@ -501,38 +519,38 @@ GEM sprockets (> 3.0) sprockets-rails tilt - scout_apm (4.1.1) + scout_apm (4.1.2) parser seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) selectize-rails (0.12.6) semantic_range (3.0.0) - sentry-rails (4.6.4) + sentry-rails (4.7.3) railties (>= 5.0) - sentry-ruby-core (~> 4.6.0) - sentry-ruby (4.6.4) + sentry-ruby-core (~> 4.7.0) + sentry-ruby (4.7.3) concurrent-ruby (~> 1.0, >= 1.0.2) faraday (>= 1.0) - sentry-ruby-core (= 4.6.4) - sentry-ruby-core (4.6.4) + sentry-ruby-core (= 4.7.3) + sentry-ruby-core (4.7.3) concurrent-ruby faraday - sentry-sidekiq (4.6.4) - sentry-ruby-core (~> 4.6.0) + sentry-sidekiq (4.7.3) + sentry-ruby-core (~> 4.7.0) sidekiq (>= 3.0) sexp_processor (4.15.3) shoulda-matchers (5.0.0) activesupport (>= 5.2.0) - sidekiq (6.2.1) + sidekiq (6.2.2) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) sidekiq-cron (1.2.0) fugit (~> 1.1) sidekiq (>= 4.2.1) - signet (0.15.0) - addressable (~> 2.3) + signet (0.16.0) + addressable (~> 2.8) faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) @@ -575,15 +593,15 @@ GEM oauth tzinfo (2.0.4) concurrent-ruby (~> 1.0) - tzinfo-data (1.2021.1) + tzinfo-data (1.2021.3) tzinfo (>= 1.0.0) uber (0.1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.7.7) - unicode-display_width (2.0.0) + unf_ext (0.0.8) + unicode-display_width (2.1.0) uniform_notifier (1.14.2) uri_template (0.7.0) valid_email2 (4.0.0) @@ -596,11 +614,11 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webmock (3.13.0) - addressable (>= 2.3.6) + webmock (3.14.0) + addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webpacker (5.4.0) + webpacker (5.4.3) activesupport (>= 5.2) rack-proxy (>= 0.6.1) railties (>= 5.2) @@ -617,6 +635,7 @@ GEM PLATFORMS arm64-darwin-20 + ruby x86_64-darwin-18 x86_64-darwin-20 x86_64-darwin-21 @@ -652,6 +671,7 @@ DEPENDENCIES faker fcm flag_shih_tzu + flay foreman geocoder google-cloud-dialogflow @@ -687,6 +707,7 @@ DEPENDENCIES redis-namespace responders rest-client + rspec rspec-rails (~> 5.0.0) rubocop rubocop-performance diff --git a/app/actions/contact_merge_action.rb b/app/actions/contact_merge_action.rb index f20e2ffb4..d31b6a367 100644 --- a/app/actions/contact_merge_action.rb +++ b/app/actions/contact_merge_action.rb @@ -42,7 +42,7 @@ class ContactMergeAction end def merge_and_remove_mergee_contact - mergable_attribute_keys = %w[identifier name email phone_number custom_attributes] + mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes] base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 4ffb4b101..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'] @@ -167,7 +128,7 @@ class Messages::Facebook::MessageBuilder result = {} # OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user # We don't need to capture this error as we don't care about contact params in case of echo messages - Sentry.capture_exception(e) unless outgoing_echo? + Sentry.capture_exception(e) unless @outgoing_echo rescue StandardError => e result = {} Sentry.capture_exception(e) 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/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/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/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 30804b8e8..82e6ce94c 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -41,19 +41,25 @@ class V2::ReportBuilder user when :label label + when :team + team end end def inbox - @inbox ||= account.inboxes.where(id: params[:id]).first + @inbox ||= account.inboxes.find(params[:id]) end def user - @user ||= account.users.where(id: params[:id]).first + @user ||= account.users.find(params[:id]) end def label - @label ||= account.labels.where(id: params[:id]).first + @label ||= account.labels.find(params[:id]) + end + + def team + @team ||= account.teams.find(params[:id]) end def conversations_count @@ -62,15 +68,14 @@ class V2::ReportBuilder .count end - # unscoped removes all scopes added to a model previously def incoming_messages_count - scope.messages.unscoped.where(account_id: account.id).incoming + scope.messages.incoming.unscope(:order) .group_by_day(:created_at, range: range, default_value: 0) .count end def outgoing_messages_count - scope.messages.unscoped.where(account_id: account.id).outgoing + scope.messages.outgoing.unscope(:order) .group_by_day(:created_at, range: range, default_value: 0) .count end 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/accounts/callbacks_controller.rb b/app/controllers/api/v1/accounts/callbacks_controller.rb index 07a6b4d71..9fc05d531 100644 --- a/app/controllers/api/v1/accounts/callbacks_controller.rb +++ b/app/controllers/api/v1/accounts/callbacks_controller.rb @@ -12,9 +12,10 @@ 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 + Sentry.capture_exception(e) end end @@ -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? @@ -45,8 +55,13 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController def update_fb_page(fb_page_id, access_token) fb_page = get_fb_page(fb_page_id) - fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token) - fb_page&.reauthorized! + ActiveRecord::Base.transaction do + fb_page&.update!(user_access_token: @user_access_token, page_access_token: access_token) + set_instagram_id(access_token, fb_page) + fb_page&.reauthorized! + rescue StandardError => e + Sentry.capture_exception(e) + end end def get_fb_page(fb_page_id) diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index 229127df6..11b563d81 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -28,7 +28,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController end def campaign_params - params.require(:campaign).permit(:title, :description, :message, :enabled, :inbox_id, :sender_id, + params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id, :scheduled_at, audience: [:type, :id], trigger_rules: {}) end end diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index 444f0c918..561b224c3 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search] - before_action :fetch_contact, only: [:show, :update, :contactable_inboxes] + before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes] before_action :set_include_contact_inboxes, only: [:index, :search] def index @@ -30,10 +30,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController end def import + render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank? + ActiveRecord::Base.transaction do import = Current.account.data_imports.create!(data_type: 'contacts') import.import_file.attach(params[:import_file]) end + head :ok end @@ -70,6 +73,18 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController }, status: :unprocessable_entity end + def destroy + if ::OnlineStatusTracker.get_presence( + @contact.account.id, 'Contact', @contact.id + ) + return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) }, + :unprocessable_entity) + end + + @contact.destroy! + head :ok + end + private # TODO: Move this to a finder class @@ -134,4 +149,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController def fetch_contact @contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id]) end + + def render_error(error, error_status) + render json: error, status: error_status + end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index d360723a6..89d48e544 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -69,6 +69,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def update_last_seen @conversation.agent_last_seen_at = DateTime.now.utc + @conversation.assignee_last_seen_at = DateTime.now.utc if assignee? + @conversation.save! + end + + def custom_attributes + @conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes] @conversation.save! end @@ -112,6 +118,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation_params additional_attributes = params[:additional_attributes]&.permit! || {} + custom_attributes = params[:custom_attributes]&.permit! || {} status = params[:status].present? ? { status: params[:status] } : {} # TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases @@ -122,11 +129,18 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro contact_id: @contact_inbox.contact_id, contact_inbox_id: @contact_inbox.id, additional_attributes: additional_attributes, - snoozed_until: params[:snoozed_until] + custom_attributes: custom_attributes, + snoozed_until: params[:snoozed_until], + assignee_id: params[:assignee_id], + team_id: params[:team_id] }.merge(status) end def conversation_finder @conversation_finder ||= ConversationFinder.new(current_user, params) end + + def assignee? + @conversation.assignee_id? && current_user == @conversation.assignee + end end 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/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/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index af28fe544..4fabd587e 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -29,6 +29,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv' end + def teams + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv' + render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' + end + private def check_authorization diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb new file mode 100644 index 000000000..bd6fee1f5 --- /dev/null +++ b/app/controllers/webhooks/instagram_controller.rb @@ -0,0 +1,30 @@ +class Webhooks::InstagramController < 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/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/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 63846acaa..a0dda66ea 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -94,6 +94,8 @@ class ConversationFinder end def filter_by_status + return if params[:status] == 'all' + @conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS) end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 147e1d525..502d8bda1 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -8,6 +8,7 @@ :has-accounts="hasAccounts" /> + @@ -15,6 +16,7 @@ import { mapGetters } from 'vuex'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal'; import WootSnackbarBox from './components/SnackbarContainer'; +import NetworkNotification from './components/NetworkNotification'; import { accountIdFromPathname } from './helper/URLHelper'; export default { @@ -23,6 +25,7 @@ export default { components: { WootSnackbarBox, AddAccountModal, + NetworkNotification, }, data() { diff --git a/app/javascript/dashboard/api/accountActions.js b/app/javascript/dashboard/api/accountActions.js new file mode 100644 index 000000000..d8c46fe0a --- /dev/null +++ b/app/javascript/dashboard/api/accountActions.js @@ -0,0 +1,18 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AccountActions extends ApiClient { + constructor() { + super('actions', { accountScoped: true }); + } + + merge(parentId, childId) { + return axios.post(`${this.url}/contact_merge`, { + base_contact_id: parentId, + mergee_contact_id: childId, + }); + } +} + +export default new AccountActions(); 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/contacts.js b/app/javascript/dashboard/api/contacts.js index 0ed9bb101..a6415cb37 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -52,6 +52,14 @@ class ContactAPI extends ApiClient { )}`; return axios.get(requestURL); } + + importContacts(file) { + const formData = new FormData(); + formData.append('import_file', file); + return axios.post(`${this.url}/import`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } } export default new ContactAPI(); 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/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/api/reports.js b/app/javascript/dashboard/api/reports.js index 9f7875c79..faeee779f 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -6,15 +6,15 @@ class ReportsAPI extends ApiClient { super('reports', { accountScoped: true, apiVersion: 'v2' }); } - getAccountReports(metric, since, until) { + getReports(metric, since, until, type = 'account', id) { return axios.get(`${this.url}`, { - params: { metric, since, until, type: 'account' }, + params: { metric, since, until, type, id }, }); } - getAccountSummary(since, until) { + getSummary(since, until, type = 'account', id) { return axios.get(`${this.url}/summary`, { - params: { since, until, type: 'account' }, + params: { since, until, type, id }, }); } @@ -23,6 +23,24 @@ class ReportsAPI extends ApiClient { params: { since, until }, }); } + + getLabelReports(since, until) { + return axios.get(`${this.url}/labels`, { + params: { since, until }, + }); + } + + getInboxReports(since, until) { + return axios.get(`${this.url}/inboxes`, { + params: { since, until }, + }); + } + + getTeamReports(since, until) { + return axios.get(`${this.url}/teams`, { + params: { since, until }, + }); + } } export default new ReportsAPI(); diff --git a/app/javascript/dashboard/api/specs/accountActions.spec.js b/app/javascript/dashboard/api/specs/accountActions.spec.js new file mode 100644 index 000000000..45f9663f3 --- /dev/null +++ b/app/javascript/dashboard/api/specs/accountActions.spec.js @@ -0,0 +1,23 @@ +import accountActionsAPI from '../accountActions'; +import ApiClient from '../ApiClient'; +import describeWithAPIMock from './apiSpecHelper'; + +describe('#ContactsAPI', () => { + it('creates correct instance', () => { + expect(accountActionsAPI).toBeInstanceOf(ApiClient); + expect(accountActionsAPI).toHaveProperty('merge'); + }); + + describeWithAPIMock('API calls', context => { + it('#merge', () => { + accountActionsAPI.merge(1, 2); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/actions/contact_merge', + { + base_contact_id: 1, + mergee_contact_id: 2, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 0c0a21125..03a71ca11 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -59,6 +59,18 @@ describe('#ContactsAPI', () => { '/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support' ); }); + + it('#importContacts', () => { + const file = 'file'; + contactAPI.importContacts(file); + expect(context.axiosMock.post).toHaveBeenCalledWith( + '/api/v1/contacts/import', + expect.any(FormData), + { + headers: { 'Content-Type': 'multipart/form-data' }, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 72d5b7a90..f3b66996a 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -11,39 +11,35 @@ describe('#Reports API', () => { expect(reportsAPI).toHaveProperty('create'); expect(reportsAPI).toHaveProperty('update'); expect(reportsAPI).toHaveProperty('delete'); - expect(reportsAPI).toHaveProperty('getAccountReports'); - expect(reportsAPI).toHaveProperty('getAccountSummary'); + expect(reportsAPI).toHaveProperty('getReports'); + expect(reportsAPI).toHaveProperty('getSummary'); expect(reportsAPI).toHaveProperty('getAgentReports'); + expect(reportsAPI).toHaveProperty('getLabelReports'); + expect(reportsAPI).toHaveProperty('getInboxReports'); + expect(reportsAPI).toHaveProperty('getTeamReports'); }); describeWithAPIMock('API calls', context => { it('#getAccountReports', () => { - reportsAPI.getAccountReports( - 'conversations_count', - 1621103400, - 1621621800 - ); - expect(context.axiosMock.get).toHaveBeenCalledWith( - '/api/v2/reports', - { - params: { - metric: 'conversations_count', - since: 1621103400, - until: 1621621800, - type: 'account' - }, - } - ); + reportsAPI.getReports('conversations_count', 1621103400, 1621621800); + expect(context.axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { + params: { + metric: 'conversations_count', + since: 1621103400, + until: 1621621800, + type: 'account', + }, + }); }); it('#getAccountSummary', () => { - reportsAPI.getAccountSummary(1621103400, 1621621800); + reportsAPI.getSummary(1621103400, 1621621800); expect(context.axiosMock.get).toHaveBeenCalledWith( '/api/v2/reports/summary', { params: { since: 1621103400, until: 1621621800, - type: 'account' + type: 'account', }, } ); @@ -61,5 +57,44 @@ describe('#Reports API', () => { } ); }); + + it('#getLabelReports', () => { + reportsAPI.getLabelReports(1621103400, 1621621800); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/labels', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); + + it('#getInboxReports', () => { + reportsAPI.getInboxReports(1621103400, 1621621800); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/inboxes', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); + + it('#getTeamReports', () => { + reportsAPI.getTeamReports(1621103400, 1621621800); + expect(context.axiosMock.get).toHaveBeenCalledWith( + '/api/v2/reports/teams', + { + params: { + since: 1621103400, + until: 1621621800, + }, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/assets/images/channels/messenger.png b/app/javascript/dashboard/assets/images/channels/messenger.png new file mode 100644 index 000000000..f1bbb68d4 Binary files /dev/null and b/app/javascript/dashboard/assets/images/channels/messenger.png differ diff --git a/app/javascript/dashboard/assets/images/fb-badge.png b/app/javascript/dashboard/assets/images/fb-badge.png deleted file mode 100644 index 4d00add1e..000000000 Binary files a/app/javascript/dashboard/assets/images/fb-badge.png and /dev/null differ diff --git a/app/javascript/dashboard/assets/images/twitter-badge.png b/app/javascript/dashboard/assets/images/twitter-badge.png deleted file mode 100644 index 9dd805942..000000000 Binary files a/app/javascript/dashboard/assets/images/twitter-badge.png and /dev/null differ diff --git a/app/javascript/dashboard/assets/images/woot-logo.png b/app/javascript/dashboard/assets/images/woot-logo.png deleted file mode 100644 index baa2b095e..000000000 Binary files a/app/javascript/dashboard/assets/images/woot-logo.png and /dev/null differ diff --git a/app/javascript/dashboard/assets/images/woot-logo.svg b/app/javascript/dashboard/assets/images/woot-logo.svg deleted file mode 100644 index 438f2b4b0..000000000 --- a/app/javascript/dashboard/assets/images/woot-logo.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - on_white - Created with Sketch. - - \ No newline at end of file diff --git a/app/javascript/dashboard/assets/scss/_animations.scss b/app/javascript/dashboard/assets/scss/_animations.scss index 3a26d5bf2..ebb20a35e 100644 --- a/app/javascript/dashboard/assets/scss/_animations.scss +++ b/app/javascript/dashboard/assets/scss/_animations.scss @@ -93,3 +93,17 @@ /* .slide-fade-leave-active for <2.1.8 */ { opacity: 0; } + +.network-notification-fade-enter-active { + transition: all .1s $ease-in-sine; +} + +.network-notification-fade-leave-active { + transition: all .1s $ease-out-sine; +} + +.network-notification-fade-enter, +.network-notification-fade-leave-to { + transform: translateY(-$space-small); + opacity: 0; +} diff --git a/app/javascript/dashboard/assets/scss/_utility-helpers.scss b/app/javascript/dashboard/assets/scss/_utility-helpers.scss new file mode 100644 index 000000000..60fecb994 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_utility-helpers.scss @@ -0,0 +1,3 @@ +.margin-right-small { + margin-right: var(--space-small); +} diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index 908daa315..7debac956 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -14,6 +14,7 @@ @import 'helper-classes'; @import 'formulate'; @import 'date-picker'; +@import 'utility-helpers'; @import 'foundation-sites/scss/foundation'; @import '~bourbon/core/bourbon'; diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index 7ae877045..e97792b97 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -15,6 +15,10 @@ .multiselect { margin-bottom: var(--space-normal); + &.multiselect--disabled { + opacity: .8; + } + .multiselect--active { >.multiselect__tags { border-color: $color-woot; @@ -209,3 +213,53 @@ flex-shrink: 0; } } + +.multiselect-wrap--medium { + $multiselect-height: 4.8rem; + + .multiselect__tags, + .multiselect__input { + align-items: center; + display: flex; + } + + .multiselect__tags, + .multiselect__input, + .multiselect { + background: var(--white); + font-size: var(--font-size-small); + height: $multiselect-height; + min-height: $multiselect-height; + } + + .multiselect__input { + height: $multiselect-height - $space-micro; + min-height: $multiselect-height - $space-micro; + } + + .multiselect__single { + align-items: center; + display: flex; + font-size: var(--font-size-small); + margin: 0; + padding: var(--space-smaller) var(--space-micro); + } + + .multiselect__placeholder { + margin: 0; + padding: var(--space-smaller) var(--space-micro); + } + + .multiselect__select { + min-height: $multiselect-height; + } + + .multiselect--disabled .multiselect__current, + .multiselect--disabled .multiselect__select { + background: transparent; + } + + .multiselect__tags-wrap { + flex-shrink: 0; + } +} diff --git a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss index f286c7bc0..c0d6b1bb6 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss @@ -42,14 +42,6 @@ $resolve-button-width: 13.2rem; margin-right: var(--space-normal); min-width: 0; - .user--name { - @include margin(0); - display: inline-block; - font-size: $font-size-medium; - line-height: 1.3; - text-transform: capitalize; - width: 100%; - } .user--profile__meta { align-items: flex-start; @@ -59,12 +51,6 @@ $resolve-button-width: 13.2rem; margin-left: $space-slab; min-width: 0; } - - .user--profile__button { - font-size: $font-size-mini; - margin-top: $space-micro; - padding: 0; - } } } 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/dashboard/assets/scss/widgets/_modal.scss b/app/javascript/dashboard/assets/scss/widgets/_modal.scss index 8c4e656c4..a017f776a 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_modal.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_modal.scss @@ -71,7 +71,8 @@ @include padding($space-large); } - form { + form, + .modal-content { @include padding($space-large); align-self: center; diff --git a/app/javascript/dashboard/assets/scss/widgets/_report.scss b/app/javascript/dashboard/assets/scss/widgets/_report.scss index ca013a39d..0ee598245 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_report.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_report.scss @@ -32,7 +32,6 @@ } } - .report-bar { @include margin(-1px $zero); @include background-white; diff --git a/app/javascript/dashboard/assets/scss/widgets/_reports.scss b/app/javascript/dashboard/assets/scss/widgets/_reports.scss new file mode 100644 index 000000000..7280b6d4d --- /dev/null +++ b/app/javascript/dashboard/assets/scss/widgets/_reports.scss @@ -0,0 +1,30 @@ +.date-picker { + margin-left: var(--space-smaller); +} + +.margin-left-small { + margin-left: var(--space-smaller); +} + +.reports-option__rounded--item { + border-radius: 100%; + height: var(--space-two); + width: var(--space-two); +} + +.reports-option__item { + flex-shrink: 0; + margin-right: var(--space-small); +} + +.reports-option__label--swatch { + border: 1px solid var(--color-border); +} + +.margin-right-small { + margin-right: var(--space-small); +} + +.display-flex { + display: flex; +} diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 5ca64d215..f8c628a80 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -26,6 +26,7 @@ :active-label="label" :team-id="teamId" :chat="chat" + :show-assignee="showAssigneeInConversationCard" />
@@ -119,6 +120,9 @@ export default { }; }); }, + showAssigneeInConversationCard() { + return this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ALL; + }, inbox() { return this.$store.getters['inboxes/getInbox'](this.activeInbox); }, diff --git a/app/javascript/dashboard/components/NetworkNotification.vue b/app/javascript/dashboard/components/NetworkNotification.vue new file mode 100644 index 000000000..efd6b273e --- /dev/null +++ b/app/javascript/dashboard/components/NetworkNotification.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue index 8587bacc8..b7194355b 100644 --- a/app/javascript/dashboard/components/layout/AvailabilityStatus.vue +++ b/app/javascript/dashboard/components/layout/AvailabilityStatus.vue @@ -1,7 +1,7 @@ + + diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.vue b/app/javascript/dashboard/components/widgets/Thumbnail.vue index 6fe83720b..eb1741bbc 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.vue +++ b/app/javascript/dashboard/components/widgets/Thumbnail.vue @@ -15,39 +15,60 @@ :size="avatarSize" /> + + +
{{ item['TEXT'] }} + @@ -30,6 +33,8 @@ export default { } else if (this.activeStatus === wootConstants.STATUS_TYPE.PENDING) { this.activeStatus = wootConstants.STATUS_TYPE.SNOOZED; } else if (this.activeStatus === wootConstants.STATUS_TYPE.SNOOZED) { + this.activeStatus = wootConstants.STATUS_TYPE.ALL; + } else if (this.activeStatus === wootConstants.STATUS_TYPE.ALL) { this.activeStatus = wootConstants.STATUS_TYPE.OPEN; } } diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index d3152fa3c..67f78b86e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -8,20 +8,26 @@ }" @click="cardClick(chat)" > -
- - - {{ inboxName }} - +

{{ currentContact.name }}

@@ -62,19 +68,21 @@ import { mapGetters } from 'vuex'; import { MESSAGE_TYPE } from 'widget/helpers/constants'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; -import { getInboxClassByType } from 'dashboard/helper/inbox'; import Thumbnail from '../Thumbnail'; import conversationMixin from '../../../mixins/conversations'; import timeMixin from '../../../mixins/time'; import router from '../../../routes'; import { frontendURL, conversationUrl } from '../../../helper/URLHelper'; +import InboxName from '../InboxName'; +import inboxMixin from 'shared/mixins/inboxMixin'; export default { components: { + InboxName, Thumbnail, }, - mixins: [timeMixin, conversationMixin, messageFormatterMixin], + mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin], props: { activeLabel: { type: String, @@ -96,6 +104,10 @@ export default { type: [String, Number], default: 0, }, + showAssignee: { + type: Boolean, + default: false, + }, }, computed: { @@ -108,7 +120,11 @@ export default { }), chatMetadata() { - return this.chat.meta; + return this.chat.meta || {}; + }, + + assignee() { + return this.chatMetadata.assignee || {}; }, currentContact() { @@ -167,18 +183,12 @@ export default { return this.getPlainText(subject || this.lastMessageInChat.content); }, - chatInbox() { + inbox() { const { inbox_id: inboxId } = this.chat; const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId); return stateInbox; }, - computedInboxClass() { - const { phone_number: phoneNumber, channel_type: type } = this.chatInbox; - const classByType = getInboxClassByType(type, phoneNumber); - return classByType; - }, - showInboxName() { return ( !this.hideInboxName && @@ -187,11 +197,10 @@ export default { ); }, inboxName() { - const stateInbox = this.chatInbox; + const stateInbox = this.inbox; return stateInbox.name || ''; }, }, - methods: { cardClick(chat) { const { activeInbox } = this; @@ -226,15 +235,6 @@ export default { } } -.conversation--details .label { - padding: var(--space-micro) 0 var(--space-micro) 0; - line-height: var(--space-slab); - font-weight: var(--font-weight-medium); - background: none; - color: var(--s-500); - font-size: var(--font-size-mini); -} - .conversation--details { .conversation--user { padding-top: var(--space-micro); @@ -252,4 +252,23 @@ export default { color: var(--s-600); font-size: var(--font-size-mini); } + +.conversation--metadata { + display: flex; + justify-content: space-between; + padding-right: var(--space-normal); + + .label { + padding: var(--space-micro) 0 var(--space-micro) 0; + line-height: var(--space-slab); + font-weight: var(--font-weight-medium); + background: none; + color: var(--s-500); + font-size: var(--font-size-mini); + } + + .assignee-label { + max-width: 50%; + } +} diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 1ec5a5db0..bdb917695 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -4,7 +4,7 @@ @@ -12,20 +12,23 @@

{{ currentContact.name }}

- - {{ - `${ - isContactPanelOpen - ? $t('CONVERSATION.HEADER.CLOSE') - : $t('CONVERSATION.HEADER.OPEN') - } ${$t('CONVERSATION.HEADER.DETAILS')}` - }} - +
+ + + {{ snoozedDisplayText }} + + + {{ contactPanelToggleText }} + +
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/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 @@ @@ -41,6 +45,7 @@ import ContactsTable from './ContactsTable'; import ContactInfoPanel from './ContactInfoPanel'; import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact'; import TableFooter from 'dashboard/components/widgets/TableFooter'; +import ImportContacts from './ImportContacts.vue'; const DEFAULT_PAGE = 1; @@ -51,6 +56,7 @@ export default { TableFooter, ContactInfoPanel, CreateContact, + ImportContacts, }, props: { label: { type: String, default: '' }, @@ -59,6 +65,7 @@ export default { return { searchQuery: '', showCreateModal: false, + showImportModal: false, selectedContactId: '', sortConfig: { name: 'asc' }, }; @@ -168,6 +175,9 @@ export default { onToggleCreate() { this.showCreateModal = !this.showCreateModal; }, + onToggleImport() { + this.showImportModal = !this.showImportModal; + }, onSortChange(params) { this.sortConfig = params; this.fetchContacts(this.meta.currentPage); diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index 93e471a76..f0118e17d 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -29,11 +29,20 @@ {{ $t('CREATE_CONTACT.BUTTON_LABEL') }} + + + {{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }} +
@@ -41,7 +50,6 @@ diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 075b6de39..8d30ac944 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -222,8 +222,9 @@ export default { return this.additionalAttributes.initiated_at; }, browserName() { - return `${this.browser.browser_name || ''} ${this.browser - .browser_version || ''}`; + return `${this.browser.browser_name || ''} ${ + this.browser.browser_version || '' + }`; }, contactAdditionalAttributes() { return this.contact.additional_attributes || {}; @@ -247,10 +248,8 @@ export default { return `${cityAndCountry} ${countryFlag}`; }, platformName() { - const { - platform_name: platformName, - platform_version: platformVersion, - } = this.browser; + const { platform_name: platformName, platform_version: platformVersion } = + this.browser; return `${platformName || ''} ${platformVersion || ''}`; }, channelType() { @@ -421,10 +420,11 @@ export default { .close-button { position: absolute; - right: $space-normal; - top: $space-slab; + right: $space-two; + top: $space-slab + $space-two; font-size: $font-size-default; color: $color-heading; + z-index: 9999; } .conversation--labels { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue index db4deae84..829b28e18 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue @@ -48,30 +48,49 @@ />
- - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - -
+
- {{ $t('CONTACT_PANEL.NEW_MESSAGE') }} - + /> - {{ $t('EDIT_CONTACT.BUTTON_LABEL') }} - + /> + +
+
+ diff --git a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue index c0aa1eae9..c289cea17 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/Wrapper.vue @@ -9,9 +9,10 @@ :back-url="backUrl" :show-new-button="showNewButton" /> - + + @@ -27,6 +28,10 @@ export default { headerTitle: String, headerButtonText: String, icon: String, + keepAlive: { + type: Boolean, + default: true, + }, newButtonRoutes: { type: Array, default: () => [], diff --git a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue index cf56f66e9..a71744939 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/campaigns/AddCampaign.vue @@ -146,6 +146,15 @@ /> {{ $t('CAMPAIGN.ADD.FORM.ENABLED') }} + @@ -291,6 +316,24 @@ > + +
+ + +
+
@@ -324,6 +367,8 @@ 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..25d85b486 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,8 @@ 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/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue index 28d132ab0..27cf47437 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twilio.vue @@ -79,12 +79,12 @@ const shouldStartWithPlusSign = (value = '') => value.startsWith('+'); export default { mixins: [alertMixin], - props: { - type: { - type: String, - required: true, + props: { + type: { + type: String, + required: true, }, - }, + }, data() { return { accountSID: '', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue index d8e31c97c..d6f13c426 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue @@ -1,22 +1,43 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue index 62c8712a2..c8d12df89 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/facebook/Reauthorize.vue @@ -79,7 +79,8 @@ export default { } }, { - scope: 'pages_manage_metadata,pages_messaging', + scope: + 'pages_manage_metadata,pages_messaging,instagram_basic,pages_show_list,instagram_manage_messages', auth_type: 'reauthorize', } ); diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/AgentReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/AgentReports.vue new file mode 100644 index 000000000..cd8ff4421 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/AgentReports.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReports.vue new file mode 100644 index 000000000..0ee04ff0b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReports.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReports.vue new file mode 100644 index 000000000..0a2e25dd9 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReports.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/TeamReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/TeamReports.vue new file mode 100644 index 000000000..f557039cc --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/TeamReports.vue @@ -0,0 +1,19 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue new file mode 100644 index 000000000..a2f459734 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue @@ -0,0 +1,231 @@ + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue new file mode 100644 index 000000000..b5d4f889d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -0,0 +1,211 @@ + + + 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 737c91e7b..4cb837af5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js @@ -1,4 +1,8 @@ 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'; @@ -11,6 +15,7 @@ export default { props: { headerTitle: 'REPORT.HEADER', icon: 'ion-arrow-graph-up-right', + keepAlive: false, }, children: [ { @@ -31,6 +36,7 @@ export default { props: { headerTitle: 'CSAT_REPORTS.HEADER', icon: 'ion-happy-outline', + keepAlive: false, }, children: [ { @@ -41,5 +47,72 @@ export default { }, ], }, + { + path: frontendURL('accounts/:accountId/reports'), + component: SettingsContent, + props: { + headerTitle: 'AGENT_REPORTS.HEADER', + icon: 'ion-ios-people', + keepAlive: false, + }, + children: [ + { + path: 'agent', + name: 'agent_reports', + roles: ['administrator'], + component: AgentReports, + }, + ], + }, + { + path: frontendURL('accounts/:accountId/reports'), + component: SettingsContent, + props: { + headerTitle: 'LABEL_REPORTS.HEADER', + icon: 'ion-pricetags', + keepAlive: false, + }, + children: [ + { + path: 'label', + name: 'label_reports', + roles: ['administrator'], + component: LabelReports, + }, + ], + }, + { + path: frontendURL('accounts/:accountId/reports'), + component: SettingsContent, + props: { + headerTitle: 'INBOX_REPORTS.HEADER', + icon: 'ion-archive', + keepAlive: false, + }, + children: [ + { + path: 'inboxes', + name: 'inbox_reports', + roles: ['administrator'], + component: InboxReports, + }, + ], + }, + { + 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/auth.js b/app/javascript/dashboard/store/modules/auth.js index e0691049c..a50481110 100644 --- a/app/javascript/dashboard/store/modules/auth.js +++ b/app/javascript/dashboard/store/modules/auth.js @@ -39,8 +39,12 @@ export const getters = { return _state.currentUser.ui_settings || {}; }, - getCurrentUserAvailabilityStatus(_state) { - return _state.currentUser.availability_status; + getCurrentUserAvailability(_state) { + const { accounts = [] } = _state.currentUser; + const [currentAccount = {}] = accounts.filter( + account => account.id === _state.currentAccountId + ); + return currentAccount.availability; }, getCurrentAccountId(_state) { @@ -125,21 +129,24 @@ 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) { commit(types.default.SET_CURRENT_ACCOUNT_ID, accountId); }, - setCurrentUserAvailabilityStatus({ commit, state: $state }, data) { + setCurrentUserAvailability({ commit, state: $state }, data) { if (data[$state.currentUser.id]) { commit( types.default.SET_CURRENT_USER_AVAILABILITY, @@ -151,8 +158,8 @@ export const actions = { // mutations export const mutations = { - [types.default.SET_CURRENT_USER_AVAILABILITY](_state, status) { - Vue.set(_state.currentUser, 'availability_status', status); + [types.default.SET_CURRENT_USER_AVAILABILITY](_state, availability) { + Vue.set(_state.currentUser, 'availability', availability); }, [types.default.CLEAR_USER](_state) { _state.currentUser.id = null; diff --git a/app/javascript/dashboard/store/modules/contactConversations.js b/app/javascript/dashboard/store/modules/contactConversations.js index d483637fc..eefae3165 100644 --- a/app/javascript/dashboard/store/modules/contactConversations.js +++ b/app/javascript/dashboard/store/modules/contactConversations.js @@ -82,6 +82,9 @@ export const mutations = { const conversations = $state.records[id] || []; Vue.set($state.records, id, [...conversations, data]); }, + [types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => { + Vue.delete($state.records, id); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/contacts/actions.js b/app/javascript/dashboard/store/modules/contacts/actions.js index 09761c443..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 }) => { @@ -82,6 +83,32 @@ export const actions = { } } }, + import: async ({ commit }, file) => { + commit(types.SET_CONTACT_UI_FLAG, { isCreating: true }); + try { + await ContactAPI.importContacts(file); + commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); + } catch (error) { + commit(types.SET_CONTACT_UI_FLAG, { isCreating: false }); + if (error.response?.data?.message) { + throw new ExceptionWithMessage(error.response.data.message); + } + } + }, + delete: async ({ commit }, id) => { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true }); + try { + await ContactAPI.delete(id); + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + } catch (error) { + commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false }); + if (error.response?.data?.message) { + throw new Error(error.response.data.message); + } else { + throw new Error(error); + } + } + }, fetchContactableInbox: async ({ commit }, id) => { commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true }); @@ -110,4 +137,24 @@ export const actions = { setContact({ commit }, data) { 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 }); + commit(`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, id, { + root: true, + }); + }, }; diff --git a/app/javascript/dashboard/store/modules/contacts/index.js b/app/javascript/dashboard/store/modules/contacts/index.js index d5264169e..b2657ca78 100644 --- a/app/javascript/dashboard/store/modules/contacts/index.js +++ b/app/javascript/dashboard/store/modules/contacts/index.js @@ -13,6 +13,8 @@ const state = { isFetchingItem: false, isFetchingInboxes: false, isUpdating: false, + isMerging: false, + isDeleting: false, }, sortOrder: [], }; diff --git a/app/javascript/dashboard/store/modules/contacts/mutations.js b/app/javascript/dashboard/store/modules/contacts/mutations.js index 46f4d94fa..9e8e64e6d 100644 --- a/app/javascript/dashboard/store/modules/contacts/mutations.js +++ b/app/javascript/dashboard/store/modules/contacts/mutations.js @@ -46,6 +46,12 @@ export const mutations = { Vue.set($state.records, data.id, data); }, + [types.DELETE_CONTACT]: ($state, id) => { + const index = $state.sortOrder.findIndex(item => item === id); + Vue.delete($state.sortOrder, index); + Vue.delete($state.records, id); + }, + [types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => { Object.values($state.records).forEach(element => { const availabilityStatus = data[element.id]; diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 0415d8724..efa12fb7b 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -24,8 +24,9 @@ const actions = { commit(types.default.SET_LIST_LOADING_STATUS); try { const response = await ConversationApi.get(params); - const { data } = response.data; - const { payload: chatList, meta: metaData } = data; + const { + data: { payload: chatList, meta: metaData }, + } = response.data; commit(types.default.SET_ALL_CONVERSATION, chatList); dispatch('conversationStats/set', metaData); dispatch('conversationLabels/setBulkConversationLabels', chatList); @@ -36,10 +37,7 @@ const actions = { ); dispatch( 'conversationPage/setCurrentPage', - { - filter: params.assigneeType, - page: params.page, - }, + { filter: params.assigneeType, page: params.page }, { root: true } ); if (!chatList.length) { @@ -69,10 +67,7 @@ const actions = { } = await MessageApi.getPreviousMessages(data); commit( `conversationMetadata/${types.default.SET_CONVERSATION_METADATA}`, - { - id: data.conversationId, - data: meta, - } + { id: data.conversationId, data: meta } ); commit(types.default.SET_PREVIOUS_CONVERSATIONS, { id: data.conversationId, @@ -140,14 +135,22 @@ const actions = { { conversationId, status, snoozedUntil = null } ) => { try { - const response = await ConversationApi.toggleStatus({ + const { + data: { + payload: { + current_status: updatedStatus, + snoozed_until: updatedSnoozedUntil, + } = {}, + } = {}, + } = await ConversationApi.toggleStatus({ conversationId, status, snoozedUntil, }); - commit(types.default.RESOLVE_CONVERSATION, { + commit(types.default.CHANGE_CONVERSATION_STATUS, { conversationId, - status: response.data.payload.current_status, + status: updatedStatus, + snoozedUntil: updatedSnoozedUntil, }); } catch (error) { // Handle error @@ -155,6 +158,7 @@ const actions = { }, sendMessage: async ({ commit }, data) => { + // eslint-disable-next-line no-useless-catch try { const pendingMessage = createPendingMessage(data); commit(types.default.ADD_MESSAGE, pendingMessage); @@ -164,7 +168,7 @@ const actions = { status: MESSAGE_STATUS.SENT, }); } catch (error) { - // Handle error + throw error; } }, @@ -222,11 +226,7 @@ const actions = { data: { id, agent_last_seen_at: lastSeen }, } = await ConversationApi.markMessageRead(data); setTimeout( - () => - commit(types.default.MARK_MESSAGE_READ, { - id, - lastSeen, - }), + () => commit(types.default.MARK_MESSAGE_READ, { id, lastSeen }), 4000 ); } catch (error) { diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js index 27df5c7ef..be136af54 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers.js @@ -5,6 +5,9 @@ export const findPendingMessageIndex = (chat, message) => { ); }; +const filterByStatus = (chatStatus, filterStatus) => + filterStatus === 'all' ? true : chatStatus === filterStatus; + export const applyPageFilters = (conversation, filters) => { const { inboxId, status, labels = [], teamId } = filters; const { @@ -15,9 +18,8 @@ export const applyPageFilters = (conversation, filters) => { } = conversation; const team = meta.team || {}; const { id: chatTeamId } = team; - const filterByStatus = chatStatus === status; - let shouldFilter = filterByStatus; + let shouldFilter = filterByStatus(chatStatus, status); if (inboxId) { const filterByInbox = Number(inboxId) === chatInboxId; shouldFilter = shouldFilter && filterByInbox; diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js index f0005c627..8ad2788da 100644 --- a/app/javascript/dashboard/store/modules/conversations/index.js +++ b/app/javascript/dashboard/store/modules/conversations/index.js @@ -69,9 +69,13 @@ export const mutations = { Vue.set(chat.meta, 'team', team); }, - [types.default.RESOLVE_CONVERSATION](_state, { conversationId, status }) { + [types.default.CHANGE_CONVERSATION_STATUS]( + _state, + { conversationId, status, snoozedUntil } + ) { const conversation = getters.getConversationById(_state)(conversationId) || {}; + Vue.set(conversation, 'snoozed_until', snoozedUntil); Vue.set(conversation, 'status', status); }, @@ -177,6 +181,13 @@ export const mutations = { Vue.set(chat, 'can_reply', canReply); } }, + + [types.default.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) { + const chats = _state.allConversations.filter( + c => c.meta.sender.id !== contactId + ); + Vue.set(_state, 'allConversations', chats); + }, }; export default { diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 6094608e6..b02d35f3f 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -73,6 +73,11 @@ export const getters = { item => item.channel_type === INBOX_TYPES.TWILIO ); }, + getTwilioSMSInboxes($state) { + return $state.records.filter( + item => item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms' + ); + }, }; export const actions = { diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 7b47583a3..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: [], @@ -36,10 +38,12 @@ const getters = { export const actions = { fetchAccountReport({ commit }, reportObj) { commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, true); - Report.getAccountReports( + Report.getReports( reportObj.metric, reportObj.from, - reportObj.to + reportObj.to, + reportObj.type, + reportObj.id ).then(accountReport => { let { data } = accountReport; data = data.filter( @@ -60,7 +64,12 @@ export const actions = { }); }, fetchAccountSummary({ commit }, reportObj) { - Report.getAccountSummary(reportObj.from, reportObj.to) + Report.getSummary( + reportObj.from, + reportObj.to, + reportObj.type, + reportObj.id + ) .then(accountSummary => { commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data); }) @@ -71,15 +80,34 @@ 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); + }); + }, + downloadLabelReports(_, reportObj) { + return Report.getLabelReports(reportObj.from, reportObj.to) + .then(response => { + downloadCsvFile(reportObj.fileName, response.data); + }) + .catch(error => { + console.error(error); + }); + }, + downloadInboxReports(_, reportObj) { + return Report.getInboxReports(reportObj.from, reportObj.to) + .then(response => { + 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/auth/actions.spec.js b/app/javascript/dashboard/store/modules/specs/auth/actions.spec.js index adbca0d94..1d2dfe889 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]]); @@ -112,9 +115,9 @@ describe('#actions', () => { }); }); - describe('#setCurrentUserAvailabilityStatus', () => { + describe('#setCurrentUserAvailability', () => { it('sends correct mutations if user id is available', async () => { - actions.setCurrentUserAvailabilityStatus( + actions.setCurrentUserAvailability( { commit, state: { currentUser: { id: 1 } }, @@ -127,7 +130,7 @@ describe('#actions', () => { }); it('does not send correct mutations if user id is not available', async () => { - actions.setCurrentUserAvailabilityStatus( + actions.setCurrentUserAvailability( { commit, state: { currentUser: { id: 1 } }, 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..742b259dc 100644 --- a/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/auth/getters.spec.js @@ -20,8 +20,12 @@ describe('#getters', () => { it('get', () => { expect( - getters.getCurrentUserAvailabilityStatus({ - currentUser: { id: 1, name: 'Pranav', availability_status: 'busy' }, + getters.getCurrentUserAvailability({ + currentAccountId: 1, + currentUser: { + id: 1, + accounts: [{ id: 1, availability: 'busy' }], + }, }) ).toEqual('busy'); }); 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 4bc8b6723..dc49f4731 100644 --- a/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/contacts/actions.spec.js @@ -139,6 +139,27 @@ describe('#actions', () => { }); }); + describe('#delete', () => { + it('sends correct mutations if API is success', async () => { + axios.delete.mockResolvedValue(); + await actions.delete({ commit }, contactList[0].id); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect( + actions.delete({ commit }, contactList[0].id) + ).rejects.toThrow(Error); + expect(commit.mock.calls).toEqual([ + [types.SET_CONTACT_UI_FLAG, { isDeleting: true }], + [types.SET_CONTACT_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + describe('#setContact', () => { it('returns correct mutations', () => { const data = { id: 1, name: 'john doe', availability_status: 'online' }; @@ -146,4 +167,43 @@ describe('#actions', () => { expect(commit.mock.calls).toEqual([[types.SET_CONTACT_ITEM, data]]); }); }); + + 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); + expect(commit.mock.calls).toEqual([ + [types.DELETE_CONTACT, contactList[0].id], + [types.CLEAR_CONTACT_CONVERSATIONS, contactList[0].id, { root: true }], + [ + `contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, + contactList[0].id, + { root: true }, + ], + ]); + }); + }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js index ce939ea3c..1b020b4ef 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/actions.spec.js @@ -217,15 +217,24 @@ describe('#actions', () => { describe('#toggleStatus', () => { it('sends correct mutations if toggle status is successful', async () => { axios.post.mockResolvedValue({ - data: { payload: { conversation_id: 1, current_status: 'resolved' } }, + data: { + payload: { + conversation_id: 1, + current_status: 'snoozed', + snoozed_until: null, + }, + }, }); await actions.toggleStatus( { commit }, - { conversationId: 1, status: 'resolved' } + { conversationId: 1, status: 'snoozed' } ); expect(commit).toHaveBeenCalledTimes(1); expect(commit.mock.calls).toEqual([ - ['RESOLVE_CONVERSATION', { conversationId: 1, status: 'resolved' }], + [ + 'CHANGE_CONVERSATION_STATUS', + { conversationId: 1, status: 'snoozed', snoozedUntil: null }, + ], ]); }); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js index 6f3463666..af25f5527 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js @@ -7,28 +7,28 @@ const conversationList = [ { id: 1, inbox_id: 2, - status: 1, + status: 'open', meta: {}, labels: ['sales', 'dev'], }, { id: 2, inbox_id: 2, - status: 1, + status: 'open', meta: {}, labels: ['dev'], }, { id: 11, inbox_id: 3, - status: 1, + status: 'resolved', meta: { team: { id: 5 } }, labels: [], }, { id: 22, inbox_id: 4, - status: 1, + status: 'pending', meta: { team: { id: 5 } }, labels: ['sales'], }, @@ -56,14 +56,14 @@ describe('#applyPageFilters', () => { describe('#filter-team', () => { it('returns true if conversation has team and team filter is active', () => { const filters = { - status: 1, + status: 'resolved', teamId: 5, }; - expect(applyPageFilters(conversationList[3], filters)).toEqual(true); + expect(applyPageFilters(conversationList[2], filters)).toEqual(true); }); it('returns true if conversation has no team and team filter is active', () => { const filters = { - status: 1, + status: 'open', teamId: 5, }; expect(applyPageFilters(conversationList[0], filters)).toEqual(false); @@ -73,14 +73,14 @@ describe('#applyPageFilters', () => { describe('#filter-inbox', () => { it('returns true if conversation has inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'pending', inboxId: 4, }; expect(applyPageFilters(conversationList[3], filters)).toEqual(true); }); it('returns true if conversation has no inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'open', inboxId: 5, }; expect(applyPageFilters(conversationList[0], filters)).toEqual(false); @@ -90,14 +90,14 @@ describe('#applyPageFilters', () => { describe('#filter-labels', () => { it('returns true if conversation has labels and labels filter is active', () => { const filters = { - status: 1, + status: 'open', labels: ['dev'], }; expect(applyPageFilters(conversationList[0], filters)).toEqual(true); }); it('returns true if conversation has no inbox and inbox filter is active', () => { const filters = { - status: 1, + status: 'open', labels: ['dev'], }; expect(applyPageFilters(conversationList[2], filters)).toEqual(false); @@ -107,7 +107,13 @@ describe('#applyPageFilters', () => { describe('#filter-status', () => { it('returns true if conversation has status and status filter is active', () => { const filters = { - status: 1, + status: 'open', + }; + expect(applyPageFilters(conversationList[1], filters)).toEqual(true); + }); + it('returns true if conversation has status and status filter is all', () => { + const filters = { + status: 'all', }; expect(applyPageFilters(conversationList[1], filters)).toEqual(true); }); diff --git a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js index 3ffc2b505..cf1bf8b73 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/mutations.spec.js @@ -161,7 +161,7 @@ describe('#mutations', () => { }); }); - describe('#RESOLVE_CONVERSATION', () => { + describe('#CHANGE_CONVERSATION_STATUS', () => { it('updates the conversation status correctly', () => { const state = { allConversations: [ @@ -173,7 +173,7 @@ describe('#mutations', () => { ], }; - mutations[types.RESOLVE_CONVERSATION](state, { + mutations[types.CHANGE_CONVERSATION_STATUS](state, { conversationId: '1', status: 'resolved', }); diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js index 3a245aa2f..9db92b00a 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js @@ -49,10 +49,10 @@ export default [ name: 'Test Widget 5', channel_type: 'Channel::TwilioSms', avatar_url: null, + medium: 'sms', page_id: null, widget_color: '#68BC00', website_token: 'randomid125', enable_auto_assignment: true, }, - ]; diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js index f70e372cb..f49d68e89 100644 --- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js @@ -19,6 +19,11 @@ describe('#getters', () => { expect(getters.getTwilioInboxes(state).length).toEqual(1); }); + it('getTwilioSMSInboxes', () => { + const state = { records: inboxList }; + expect(getters.getTwilioSMSInboxes(state).length).toEqual(1); + }); + it('getInbox', () => { const state = { records: inboxList, 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 57eea00f9..0ba89ff81 100644 --- a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js @@ -5,7 +5,17 @@ global.open = jest.fn(); global.axios = axios; jest.mock('axios'); +const createElementSpy = () => { + const element = document.createElement('a'); + jest.spyOn(document, 'createElement').mockImplementation(() => element); + return element; +}; + describe('#actions', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('#downloadAgentReports', () => { it('open CSV download prompt if API is success', async () => { axios.get.mockResolvedValue({ @@ -17,15 +27,76 @@ describe('#actions', () => { to: 1630504922510, fileName: 'agent-report-01-09-2021.csv', }; - const mockDownloadElement = document.createElement('a'); - jest - .spyOn(document, 'createElement') - .mockImplementation(() => mockDownloadElement); + const mockAgentDownloadElement = createElementSpy(); await actions.downloadAgentReports(1, param); - expect(mockDownloadElement.href).toEqual( + expect(mockAgentDownloadElement.href).toEqual( 'data:text/csv;charset=utf-8,Agent%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20Pranav,36,114,28411' ); - expect(mockDownloadElement.download).toEqual(param.fileName); + expect(mockAgentDownloadElement.download).toEqual(param.fileName); + }); + }); + + describe('#downloadLabelReports', () => { + it('open CSV download prompt if API is success', async () => { + axios.get.mockResolvedValue({ + data: `Label Title,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes) + website,0,0,0`, + }); + const param = { + from: 1632335400, + to: 1632853800, + type: 'label', + fileName: 'label-report-01-09-2021.csv', + }; + const mockLabelDownloadElement = createElementSpy(); + await actions.downloadLabelReports(1, param); + expect(mockLabelDownloadElement.href).toEqual( + 'data:text/csv;charset=utf-8,Label%20Title,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20website,0,0,0' + ); + expect(mockLabelDownloadElement.download).toEqual(param.fileName); + }); + }); + + describe('#downloadInboxReports', () => { + it('open CSV download prompt if API is success', async () => { + axios.get.mockResolvedValue({ + data: `Inbox name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes) + Fayaz,2,127,0 + EMa,0,0,0 + Twillio WA,0,0,0`, + }); + 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,Inbox%20name,Conversations%20count,Avg%20first%20response%20time%20(Minutes),Avg%20resolution%20time%20(Minutes)%0A%20%20%20%20%20%20%20%20Fayaz,2,127,0%0A%20%20%20%20%20%20%20%20EMa,0,0,0%0A%20%20%20%20%20%20%20%20Twillio%20WA,0,0,0' + ); + 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/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index c364f7e4d..2604d189f 100755 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -18,11 +18,12 @@ export default { CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER', UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE', UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT', + CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS', SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW', CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW', CLEAR_ALL_MESSAGES: 'CLEAR_ALL_MESSAGES', - RESOLVE_CONVERSATION: 'RESOLVE_CONVERSATION', + CHANGE_CONVERSATION_STATUS: 'CHANGE_CONVERSATION_STATUS', ADD_CONVERSATION: 'ADD_CONVERSATION', UPDATE_CONVERSATION: 'UPDATE_CONVERSATION', MUTE_CONVERSATION: 'MUTE_CONVERSATION', @@ -101,6 +102,7 @@ export default { SET_CONTACTS: 'SET_CONTACTS', CLEAR_CONTACTS: 'CLEAR_CONTACTS', EDIT_CONTACT: 'EDIT_CONTACT', + DELETE_CONTACT: 'DELETE_CONTACT', UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE', // Notifications @@ -119,6 +121,7 @@ export default { SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS', ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION', + DELETE_CONTACT_CONVERSATION: 'DELETE_CONTACT_CONVERSATION', // Contact Label SET_CONTACT_LABELS_UI_FLAG: 'SET_CONTACT_LABELS_UI_FLAG', diff --git a/app/javascript/packs/sdk.js b/app/javascript/packs/sdk.js index ab02f1f3a..d666b864b 100755 --- a/app/javascript/packs/sdk.js +++ b/app/javascript/packs/sdk.js @@ -40,8 +40,8 @@ const runSDK = ({ baseUrl, websiteToken }) => { launcherTitle: chatwootSettings.launcherTitle || '', showPopoutButton: chatwootSettings.showPopoutButton || false, - toggle() { - IFrameHelper.events.toggleBubble(); + toggle(state) { + IFrameHelper.events.toggleBubble(state); }, setUser(identifier, user) { diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js index 5fcf215c0..098171fe5 100644 --- a/app/javascript/sdk/IFrameHelper.js +++ b/app/javascript/sdk/IFrameHelper.js @@ -137,8 +137,15 @@ export const IFrameHelper = { setBubbleText(window.$chatwoot.launcherTitle || message.label); }, - toggleBubble: () => { - onBubbleClick(); + toggleBubble: state => { + let bubbleState = {}; + if (state === 'open') { + bubbleState.toggleValue = true; + } else if (state === 'close') { + bubbleState.toggleValue = false; + } + + onBubbleClick(bubbleState); }, onBubbleToggle: isOpen => { diff --git a/app/javascript/shared/components/ChatOption.vue b/app/javascript/shared/components/ChatOption.vue index 74a614579..672939356 100644 --- a/app/javascript/shared/components/ChatOption.vue +++ b/app/javascript/shared/components/ChatOption.vue @@ -41,10 +41,11 @@ export default { @import '~widget/assets/scss/variables.scss'; .option { - border: 1px solid $color-woot; border-radius: $space-jumbo; + border: 1px solid $color-woot; float: left; margin: $space-smaller; + max-width: 100%; .option-button { background: transparent; @@ -52,7 +53,11 @@ export default { border: 0; color: $color-woot; cursor: pointer; + height: auto; + line-height: 1.5; + min-height: $space-two * 2; text-align: left; + white-space: normal; span { display: inline-block; 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; diff --git a/app/javascript/shared/components/ui/MultiselectDropdown.vue b/app/javascript/shared/components/ui/MultiselectDropdown.vue index 0d0d67296..687b7bf90 100644 --- a/app/javascript/shared/components/ui/MultiselectDropdown.vue +++ b/app/javascript/shared/components/ui/MultiselectDropdown.vue @@ -16,7 +16,6 @@ :src="selectedItem.thumbnail" size="24px" :status="selectedItem.availability_status" - :badge="selectedItem.channel" :username="selectedItem.name" />
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/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js index 57e5e0e58..d6b802de8 100644 --- a/app/javascript/shared/helpers/Validators.js +++ b/app/javascript/shared/helpers/Validators.js @@ -1,2 +1,4 @@ export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/); export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === ''; +export const shouldBeUrl = (value = '') => + value ? value.startsWith('http') : true; diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js new file mode 100644 index 000000000..ba393a7a3 --- /dev/null +++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js @@ -0,0 +1,7 @@ +import { shouldBeUrl } from '../Validators'; + +describe('#shouldBeUrl', () => { + it('should return correct url', () => { + expect(shouldBeUrl('http')).toEqual(true); + }); +}); diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 3afd16508..022b9327e 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -3,6 +3,7 @@ export const INBOX_TYPES = { FB: 'Channel::FacebookPage', TWITTER: 'Channel::TwitterProfile', TWILIO: 'Channel::TwilioSms', + WHATSAPP: 'Channel::Whatsapp', API: 'Channel::Api', EMAIL: 'Channel::Email', TELEGRAM: 'Channel::Telegram', @@ -35,13 +36,51 @@ export default { isAnEmailChannel() { return this.channelType === INBOX_TYPES.EMAIL; }, + isATelegramChannel() { + return this.channelType === INBOX_TYPES.TELEGRAM; + }, isATwilioSMSChannel() { - const { phone_number: phoneNumber = '' } = this.inbox; - return this.isATwilioChannel && !phoneNumber.startsWith('whatsapp'); + const { medium: medium = '' } = this.inbox; + return this.isATwilioChannel && medium === 'sms'; }, isATwilioWhatsappChannel() { - const { phone_number: phoneNumber = '' } = this.inbox; - return this.isATwilioChannel && phoneNumber.startsWith('whatsapp'); + const { medium: medium = '' } = this.inbox; + return this.isATwilioChannel && medium === 'whatsapp'; + }, + chatAdditionalAttributes() { + const { additional_attributes: additionalAttributes } = this.chat || {}; + return additionalAttributes || {}; + }, + isTwitterInboxTweet() { + return this.chatAdditionalAttributes.type === 'tweet'; + }, + twilioBadge() { + return `${this.isATwilioSMSChannel ? 'sms' : 'whatsapp'}`; + }, + twitterBadge() { + return `${this.isTwitterInboxTweet ? 'twitter-tweet' : 'twitter-dm'}`; + }, + facebookBadge() { + return this.chatAdditionalAttributes.type || 'facebook'; + }, + inboxBadge() { + let badgeKey = ''; + if (this.isATwitterInbox) { + badgeKey = this.twitterBadge; + } else if (this.isAFacebookInbox) { + badgeKey = this.facebookBadge; + } else if (this.isATwilioChannel) { + badgeKey = this.twilioBadge; + } else if (this.isAWhatsappChannel) { + badgeKey = 'whatsapp'; + } + return badgeKey || this.channelType; + }, + isAWhatsappChannel() { + return ( + this.channelType === INBOX_TYPES.WHATSAPP || + this.isATwilioWhatsappChannel + ); }, }, }; diff --git a/app/javascript/shared/mixins/specs/inboxMixin.spec.js b/app/javascript/shared/mixins/specs/inboxMixin.spec.js index 093f36b57..f128a44d8 100644 --- a/app/javascript/shared/mixins/specs/inboxMixin.spec.js +++ b/app/javascript/shared/mixins/specs/inboxMixin.spec.js @@ -70,7 +70,23 @@ describe('inboxMixin', () => { return { inbox: { channel_type: 'Channel::TwilioSms', - phone_number: '+91944444444', + }, + }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isATwilioChannel).toBe(true); + }); + + it('isATwilioSMSChannel returns true if channel type is Twilio and medium is SMS', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { + inbox: { + channel_type: 'Channel::TwilioSms', + medium: 'sms', }, }; }, @@ -80,7 +96,7 @@ describe('inboxMixin', () => { expect(wrapper.vm.isATwilioSMSChannel).toBe(true); }); - it('isATwilioWhatsappChannel returns true if channel type is Twilio and phonenumber is a whatsapp number', () => { + it('isATwilioWhatsappChannel returns true if channel type is Twilio and medium is whatsapp', () => { const Component = { render() {}, mixins: [inboxMixin], @@ -88,7 +104,7 @@ describe('inboxMixin', () => { return { inbox: { channel_type: 'Channel::TwilioSms', - phone_number: 'whatsapp:+91944444444', + medium: 'whatsapp', }, }; }, @@ -111,4 +127,79 @@ describe('inboxMixin', () => { const wrapper = shallowMount(Component); expect(wrapper.vm.isAnEmailChannel).toBe(true); }); + + it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { + chat: { + channel_type: 'Channel::TwitterProfile', + additional_attributes: { + type: 'tweet', + }, + }, + }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isTwitterInboxTweet).toBe(true); + }); + + it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { + inbox: { + channel_type: 'Channel::TwilioSms', + medium: 'sms', + }, + }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isATwilioSMSChannel).toBe(true); + expect(wrapper.vm.twilioBadge).toBe('sms'); + }); + + it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { + chat: { + id: 1, + additional_attributes: { + type: 'tweet', + }, + }, + }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isTwitterInboxTweet).toBe(true); + expect(wrapper.vm.twitterBadge).toBe('twitter-tweet'); + }); + + it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => { + const Component = { + render() {}, + mixins: [inboxMixin], + data() { + return { + inbox: { + channel_type: 'Channel::Telegram', + }, + }; + }, + }; + const wrapper = shallowMount(Component); + expect(wrapper.vm.isATwilioChannel).toBe(false); + expect(wrapper.vm.isATwitterInbox).toBe(false); + expect(wrapper.vm.channelType).toBe('Channel::Telegram'); + }); }); diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue index e4c3ba1a2..ef9d63aac 100755 --- a/app/javascript/widget/App.vue +++ b/app/javascript/widget/App.vue @@ -19,12 +19,14 @@ import Router from './views/Router'; import { getLocale } from './helpers/urlParamsHelper'; import { BUS_EVENTS } from 'shared/constants/busEvents'; import { isEmptyObject } from 'widget/helpers/utils'; +import availabilityMixin from 'widget/mixins/availability'; export default { name: 'App', components: { Router, }, + mixins: [availabilityMixin], data() { return { showUnreadView: false, @@ -34,6 +36,7 @@ export default { widgetPosition: 'right', showPopoutButton: false, isWebWidgetTriggered: false, + isWidgetOpen: false, }; }, computed: { @@ -132,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(); @@ -219,7 +222,11 @@ export default { this.scrollConversationToBottom(); } else if (message.event === 'change-url') { const { referrerURL, referrerHost } = message; - this.initCampaigns({ currentURL: referrerURL, websiteToken }); + this.initCampaigns({ + currentURL: referrerURL, + websiteToken, + isInBusinessHours: this.isInBusinessHours, + }); window.referrerURL = referrerURL; bus.$emit(BUS_EVENTS.SET_REFERRER_HOST, referrerHost); } else if (message.event === 'toggle-close-button') { @@ -251,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/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'); } }, }, 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'); }); }; diff --git a/app/javascript/widget/helpers/campaignHelper.js b/app/javascript/widget/helpers/campaignHelper.js index 4cde814f6..4e527c853 100644 --- a/app/javascript/widget/helpers/campaignHelper.js +++ b/app/javascript/widget/helpers/campaignHelper.js @@ -7,17 +7,24 @@ export const formatCampaigns = ({ campaigns }) => { return campaigns.map(item => { return { id: item.id, + triggerOnlyDuringBusinessHours: + item.trigger_only_during_business_hours || false, timeOnPage: item?.trigger_rules?.time_on_page, url: item?.trigger_rules?.url, }; }); }; -// Find all campaigns that matches the current URL -export const filterCampaigns = ({ campaigns, currentURL }) => { - return campaigns.filter( - item => - stripTrailingSlash({ URL: item.url }) === - stripTrailingSlash({ URL: currentURL }) +// Filter all campaigns based on current URL and business availability time +export const filterCampaigns = ({ + campaigns, + currentURL, + isInBusinessHours, +}) => { + return campaigns.filter(item => + item.triggerOnlyDuringBusinessHours + ? isInBusinessHours + : stripTrailingSlash({ URL: item.url }) === + stripTrailingSlash({ URL: currentURL }) ); }; diff --git a/app/javascript/widget/helpers/campaignTimer.js b/app/javascript/widget/helpers/campaignTimer.js index 9e587345a..dd189c4de 100644 --- a/app/javascript/widget/helpers/campaignTimer.js +++ b/app/javascript/widget/helpers/campaignTimer.js @@ -4,12 +4,12 @@ class CampaignTimer { this.campaignTimers = []; } - initTimers = ({ campaigns }) => { + initTimers = ({ campaigns }, websiteToken) => { this.clearTimers(); campaigns.forEach(campaign => { const { timeOnPage, id: campaignId } = campaign; this.campaignTimers[campaignId] = setTimeout(() => { - store.dispatch('campaign/startCampaign', { campaignId }); + store.dispatch('campaign/startCampaign', { campaignId, websiteToken }); }, timeOnPage * 1000); }); }; diff --git a/app/javascript/widget/helpers/specs/camapginFixtures.js b/app/javascript/widget/helpers/specs/campaignFixtures.js similarity index 72% rename from app/javascript/widget/helpers/specs/camapginFixtures.js rename to app/javascript/widget/helpers/specs/campaignFixtures.js index 9816c6d12..70fbcef39 100644 --- a/app/javascript/widget/helpers/specs/camapginFixtures.js +++ b/app/javascript/widget/helpers/specs/campaignFixtures.js @@ -1,6 +1,7 @@ export default [ { id: 1, + trigger_only_during_business_hours: false, trigger_rules: { time_on_page: 3, url: 'https://www.chatwoot.com/pricing', @@ -8,6 +9,7 @@ export default [ }, { id: 2, + trigger_only_during_business_hours: false, trigger_rules: { time_on_page: 6, url: 'https://www.chatwoot.com/about', diff --git a/app/javascript/widget/helpers/specs/campaignHelper.spec.js b/app/javascript/widget/helpers/specs/campaignHelper.spec.js index db6da98f4..f0107465d 100644 --- a/app/javascript/widget/helpers/specs/campaignHelper.spec.js +++ b/app/javascript/widget/helpers/specs/campaignHelper.spec.js @@ -3,8 +3,12 @@ import { formatCampaigns, filterCampaigns, } from '../campaignHelper'; -import campaigns from './camapginFixtures'; -describe('#Campagin Helper', () => { +import campaigns from './campaignFixtures'; + +global.chatwootWebChannel = { + workingHoursEnabled: false, +}; +describe('#Campaigns Helper', () => { describe('stripTrailingSlash', () => { it('should return striped trailing slash if url with trailing slash is passed', () => { expect( @@ -14,15 +18,17 @@ describe('#Campagin Helper', () => { }); describe('formatCampaigns', () => { - it('should return formated campaigns if camapgins are passed', () => { + it('should return formatted campaigns if campaigns are passed', () => { expect(formatCampaigns({ campaigns })).toStrictEqual([ { id: 1, timeOnPage: 3, + triggerOnlyDuringBusinessHours: false, url: 'https://www.chatwoot.com/pricing', }, { id: 2, + triggerOnlyDuringBusinessHours: false, timeOnPage: 6, url: 'https://www.chatwoot.com/about', }, @@ -30,7 +36,7 @@ describe('#Campagin Helper', () => { }); }); describe('filterCampaigns', () => { - it('should return filtered campaigns if formatted camapgins are passed', () => { + it('should return filtered campaigns if formatted campaigns are passed', () => { expect( filterCampaigns({ campaigns: [ diff --git a/app/javascript/widget/i18n/locale/fr.json b/app/javascript/widget/i18n/locale/fr.json index da79037b1..080eec810 100644 --- a/app/javascript/widget/i18n/locale/fr.json +++ b/app/javascript/widget/i18n/locale/fr.json @@ -64,7 +64,7 @@ "PLACEHOLDER": "Dites-nous en plus..." }, "EMAIL_TRANSCRIPT": { - "BUTTON_TEXT": "Request a conversation transcript", + "BUTTON_TEXT": "Demander une transcription de conversation", "SEND_EMAIL_SUCCESS": "La transcription du chat a été envoyée avec succès", "SEND_EMAIL_ERROR": "Une erreur est survenue, veuillez réessayer" } diff --git a/app/javascript/widget/i18n/locale/it.json b/app/javascript/widget/i18n/locale/it.json index 539639e11..9cb535b39 100644 --- a/app/javascript/widget/i18n/locale/it.json +++ b/app/javascript/widget/i18n/locale/it.json @@ -10,7 +10,7 @@ }, "TEAM_AVAILABILITY": { "ONLINE": "Siamo online", - "OFFLINE": "We are away at the moment" + "OFFLINE": "Al momento non è disponibile nessun operatore" }, "REPLY_TIME": { "IN_A_FEW_MINUTES": "In genere risponde in pochi minuti", @@ -18,11 +18,11 @@ "IN_A_DAY": "In genere le risposte in un giorno" }, "START_CONVERSATION": "Avvia Conversazione", - "START_NEW_CONVERSATION": "Start a new conversation", + "START_NEW_CONVERSATION": "Avvia una nuova conversazione", "UNREAD_VIEW": { "VIEW_MESSAGES_BUTTON": "Vedi nuovi messaggi", "CLOSE_MESSAGES_BUTTON": "Chiudi", - "COMPANY_FROM": "from", + "COMPANY_FROM": "da", "BOT": "Bot" }, "BUBBLE": { @@ -52,20 +52,20 @@ } } }, - "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit", + "FILE_SIZE_LIMIT": "Il file supera il limite di {MAXIMUM_FILE_UPLOAD_SIZE} per poter essere allegato", "CHAT_FORM": { "INVALID": { - "FIELD": "Invalid field" + "FIELD": "Campo non valido" } }, "CSAT": { - "TITLE": "Rate your conversation", - "SUBMITTED_TITLE": "Thank you for submitting the rating", - "PLACEHOLDER": "Tell us more..." + "TITLE": "Valuta la conversazione", + "SUBMITTED_TITLE": "Grazie per aver inviato la valutazione", + "PLACEHOLDER": "Dicci di più..." }, "EMAIL_TRANSCRIPT": { - "BUTTON_TEXT": "Request a conversation transcript", - "SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully", - "SEND_EMAIL_ERROR": "There was an error, please try again" + "BUTTON_TEXT": "Richiedi la trascrizione della conversazione", + "SEND_EMAIL_SUCCESS": "La trascrizione della chat è stata inviata con successo", + "SEND_EMAIL_ERROR": "Si è verificato un errore, riprova" } } diff --git a/app/javascript/widget/i18n/locale/zh_TW.json b/app/javascript/widget/i18n/locale/zh_TW.json index c858a1ef1..924fa19b2 100644 --- a/app/javascript/widget/i18n/locale/zh_TW.json +++ b/app/javascript/widget/i18n/locale/zh_TW.json @@ -13,12 +13,12 @@ "OFFLINE": "我們目前不在線上" }, "REPLY_TIME": { - "IN_A_FEW_MINUTES": "Typically replies in a few minutes", - "IN_A_FEW_HOURS": "Typically replies in a few hours", - "IN_A_DAY": "Typically replies in a day" + "IN_A_FEW_MINUTES": "通常在幾分鐘內回覆", + "IN_A_FEW_HOURS": "通常在幾小時內回覆", + "IN_A_DAY": "通常在一天內回覆" }, "START_CONVERSATION": "開始對話", - "START_NEW_CONVERSATION": "Start a new conversation", + "START_NEW_CONVERSATION": "開始一個新對話", "UNREAD_VIEW": { "VIEW_MESSAGES_BUTTON": "查看新訊息", "CLOSE_MESSAGES_BUTTON": "關閉", @@ -55,7 +55,7 @@ "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit", "CHAT_FORM": { "INVALID": { - "FIELD": "Invalid field" + "FIELD": "無效的欄位" } }, "CSAT": { diff --git a/app/javascript/widget/mixins/availability.js b/app/javascript/widget/mixins/availability.js index 1508a825d..730c09c95 100644 --- a/app/javascript/widget/mixins/availability.js +++ b/app/javascript/widget/mixins/availability.js @@ -58,5 +58,9 @@ export default { closeMinute: workingHourConfig.close_minutes, }; }, + isInBusinessHours() { + const { workingHoursEnabled } = window.chatwootWebChannel; + return workingHoursEnabled ? this.isInBetweenTheWorkingHours : true; + }, }, }; diff --git a/app/javascript/widget/store/modules/campaign.js b/app/javascript/widget/store/modules/campaign.js index 8fe7f06e2..c99c876cd 100644 --- a/app/javascript/widget/store/modules/campaign.js +++ b/app/javascript/widget/store/modules/campaign.js @@ -14,14 +14,20 @@ const state = { activeCampaign: {}, }; -const resetCampaignTimers = (campaigns, currentURL) => { +const resetCampaignTimers = ( + campaigns, + currentURL, + websiteToken, + isInBusinessHours +) => { const formattedCampaigns = formatCampaigns({ campaigns }); // Find all campaigns that matches the current URL const filteredCampaigns = filterCampaigns({ campaigns: formattedCampaigns, currentURL, + isInBusinessHours, }); - campaignTimer.initTimers({ campaigns: filteredCampaigns }); + campaignTimer.initTimers({ campaigns: filteredCampaigns }, websiteToken); }; export const getters = { @@ -31,13 +37,21 @@ export const getters = { }; export const actions = { - fetchCampaigns: async ({ commit }, { websiteToken, currentURL }) => { + fetchCampaigns: async ( + { commit }, + { websiteToken, currentURL, isInBusinessHours } + ) => { try { const { data: campaigns } = await getCampaigns(websiteToken); commit('setCampaigns', campaigns); commit('setError', false); commit('setHasFetched', true); - resetCampaignTimers(campaigns, currentURL); + resetCampaignTimers( + campaigns, + currentURL, + websiteToken, + isInBusinessHours + ); } catch (error) { commit('setError', true); commit('setHasFetched', true); @@ -45,20 +59,29 @@ export const actions = { }, initCampaigns: async ( { getters: { getCampaigns: campaigns }, dispatch }, - { currentURL, websiteToken } + { currentURL, websiteToken, isInBusinessHours } ) => { if (!campaigns.length) { - dispatch('fetchCampaigns', { websiteToken, currentURL }); + dispatch('fetchCampaigns', { + websiteToken, + currentURL, + isInBusinessHours, + }); } else { - resetCampaignTimers(campaigns, currentURL); + resetCampaignTimers( + campaigns, + currentURL, + websiteToken, + isInBusinessHours + ); } }, - startCampaign: async ( - { getters: { getCampaigns: campaigns }, commit }, - { campaignId } - ) => { + startCampaign: async ({ commit }, { websiteToken, campaignId }) => { + const { data: campaigns } = await getCampaigns(websiteToken); const campaign = campaigns.find(item => item.id === campaignId); - commit('setActiveCampaign', campaign); + if (campaign) { + commit('setActiveCampaign', campaign); + } }, executeCampaign: async ({ commit }, { campaignId, websiteToken }) => { diff --git a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js index ab5a74702..a9bd07fa2 100644 --- a/app/javascript/widget/store/modules/specs/campaign/actions.spec.js +++ b/app/javascript/widget/store/modules/specs/campaign/actions.spec.js @@ -15,22 +15,40 @@ describe('#actions', () => { API.get.mockResolvedValue({ data: campaigns }); await actions.fetchCampaigns( { commit }, - { websiteToken: 'XDsafmADasd', currentURL: 'https://chatwoot.com' } + { + websiteToken: 'XDsafmADasd', + currentURL: 'https://chatwoot.com', + isInBusinessHours: true, + } ); expect(commit.mock.calls).toEqual([ ['setCampaigns', campaigns], ['setError', false], ['setHasFetched', true], ]); - expect(campaignTimer.initTimers).toHaveBeenCalledWith({ - campaigns: [{ id: 11, timeOnPage: '20', url: 'https://chatwoot.com' }], - }); + expect(campaignTimer.initTimers).toHaveBeenCalledWith( + { + campaigns: [ + { + id: 11, + timeOnPage: '20', + url: 'https://chatwoot.com', + triggerOnlyDuringBusinessHours: false, + }, + ], + }, + 'XDsafmADasd' + ); }); it('sends correct actions if API is error', async () => { API.get.mockRejectedValue({ message: 'Authentication required' }); await actions.fetchCampaigns( { commit }, - { websiteToken: 'XDsafmADasd', currentURL: 'https://www.chatwoot.com' } + { + websiteToken: 'XDsafmADasd', + currentURL: 'https://www.chatwoot.com', + isInBusinessHours: true, + } ); expect(commit.mock.calls).toEqual([ ['setError', true], @@ -38,13 +56,11 @@ describe('#actions', () => { ]); }); }); - describe('#initCampaigns', () => { const actionParams = { websiteToken: 'XDsafmADasd', currentURL: 'https://chatwoot.com', }; - it('sends correct actions if campaigns are empty', async () => { await actions.initCampaigns( { dispatch, getters: { getCampaigns: [] } }, @@ -59,20 +75,31 @@ describe('#actions', () => { actionParams ); expect(dispatch.mock.calls).toEqual([]); - expect(campaignTimer.initTimers).toHaveBeenCalledWith({ - campaigns: [{ id: 11, timeOnPage: '20', url: 'https://chatwoot.com' }], - }); + expect(campaignTimer.initTimers).toHaveBeenCalledWith( + { + campaigns: [ + { + id: 11, + timeOnPage: '20', + url: 'https://chatwoot.com', + triggerOnlyDuringBusinessHours: false, + }, + ], + }, + 'XDsafmADasd' + ); }); }); describe('#startCampaign', () => { it('reset campaign if campaign id is not present in the campaign list', async () => { + API.get.mockResolvedValue({ data: campaigns }); await actions.startCampaign( { dispatch, getters: { getCampaigns: campaigns }, commit }, { campaignId: 32 } ); - expect(commit.mock.calls).toEqual([['setActiveCampaign', undefined]]); }); it('start campaign if campaign id passed', async () => { + API.get.mockResolvedValue({ data: campaigns }); await actions.startCampaign( { dispatch, getters: { getCampaigns: campaigns }, commit }, { campaignId: 1 } diff --git a/app/jobs/labels/update_job.rb b/app/jobs/labels/update_job.rb new file mode 100644 index 000000000..8834c97cc --- /dev/null +++ b/app/jobs/labels/update_job.rb @@ -0,0 +1,11 @@ +class Labels::UpdateJob < ApplicationJob + queue_as :default + + def perform(new_label_title, old_label_title, account_id) + Labels::UpdateService.new( + new_label_title: new_label_title, + old_label_title: old_label_title, + account_id: account_id + ).perform + end +end diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index b834c41ea..08597a54d 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -3,10 +3,12 @@ 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 + send_on_facebook_page(message) when 'Channel::TwitterProfile' ::Twitter::SendOnTwitterService.new(message: message).perform when 'Channel::TwilioSms' @@ -15,6 +17,18 @@ class SendReplyJob < ApplicationJob ::Line::SendOnLineService.new(message: message).perform when 'Channel::Telegram' ::Telegram::SendOnTelegramService.new(message: message).perform + when 'Channel::Whatsapp' + ::Whatsapp::SendOnWhatsappService.new(message: message).perform + end + end + + private + + def send_on_facebook_page(message) + if message.conversation.additional_attributes['type'] == 'instagram_direct_message' + ::Instagram::SendOnInstagramService.new(message: message).perform + else + ::Facebook::SendOnFacebookService.new(message: message).perform end end end diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb new file mode 100644 index 000000000..f12bd3545 --- /dev/null +++ b/app/jobs/webhooks/instagram_events_job.rb @@ -0,0 +1,86 @@ +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) + return unless @inbox + + @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/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb new file mode 100644 index 000000000..315f08907 --- /dev/null +++ b/app/jobs/webhooks/whatsapp_events_job.rb @@ -0,0 +1,13 @@ +class Webhooks::WhatsappEventsJob < ApplicationJob + queue_as :default + + def perform(params = {}) + return unless params[:phone_number] + + channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) + return unless channel + + # TODO: pass to appropriate provider service from here + Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params['whatsapp'].with_indifferent_access).perform + end +end diff --git a/app/listeners/action_cable_listener.rb b/app/listeners/action_cable_listener.rb index dfedc5ba7..a2388774f 100644 --- a/app/listeners/action_cable_listener.rb +++ b/app/listeners/action_cable_listener.rb @@ -111,6 +111,13 @@ class ActionCableListener < BaseListener broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data) end + def contact_deleted(event) + contact, account = extract_contact_and_account(event) + tokens = user_tokens(account, account.agents) + + broadcast(account, tokens, CONTACT_DELETED, contact.push_event_data) + end + private def typing_event_listener_tokens(account, conversation, user) diff --git a/app/listeners/notification_listener.rb b/app/listeners/notification_listener.rb index b619dfa88..cf2490606 100644 --- a/app/listeners/notification_listener.rb +++ b/app/listeners/notification_listener.rb @@ -58,7 +58,7 @@ class NotificationListener < BaseListener return if message.content.blank? - mentioned_ids = message.content.scan(%r{\(mention://(user|team)/(\d+)/(.+)\)}).map(&:second).uniq + mentioned_ids = message.content.scan(%r{\(mention://(user|team)/(\d+)/(.+?)\)}).map(&:second).uniq return if mentioned_ids.blank? diff --git a/app/mailboxes/application_mailbox.rb b/app/mailboxes/application_mailbox.rb index 5f61f5f7c..29a408635 100644 --- a/app/mailboxes/application_mailbox.rb +++ b/app/mailboxes/application_mailbox.rb @@ -1,19 +1,18 @@ class ApplicationMailbox < ActionMailbox::Base + include MailboxHelper + # Last part is the regex for the UUID # Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com - REPLY_EMAIL_USERNAME_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i + REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i + CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9\-]*?)/messages/(\d+?)@(\w+\.\w+)} def self.reply_mail? proc do |inbound_mail_obj| is_a_reply_email = false inbound_mail_obj.mail.to&.each do |email| - username = email.split('@')[0] - match_result = username.match(REPLY_EMAIL_USERNAME_PATTERN) - if match_result - is_a_reply_email = true - break - end + is_a_reply_email = true if reply_uuid_mail?(email) end + is_a_reply_email = true if in_reply_to_mail?(inbound_mail_obj, is_a_reply_email) is_a_reply_email end end @@ -22,7 +21,7 @@ class ApplicationMailbox < ActionMailbox::Base proc do |inbound_mail_obj| is_a_support_email = false inbound_mail_obj.mail.to&.each do |email| - channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email) + channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) if channel.present? is_a_support_email = true break @@ -36,6 +35,30 @@ class ApplicationMailbox < ActionMailbox::Base proc { |_mail| true } end + # checks if follow this pattern then send it to reply_mailbox + # + def self.in_reply_to_mail?(inbound_mail_obj, is_a_reply_email) + return if is_a_reply_email + + in_reply_to = inbound_mail_obj.mail.in_reply_to + + return false if in_reply_to.blank? + + return true if in_reply_to.match(CONVERSATION_MESSAGE_ID_PATTERN) + + message = Message.find_by(source_id: in_reply_to) + return true if message.present? + + false + end + + # checks if follow this pattern send it to reply_mailbox + # reply+@ + def self.reply_uuid_mail?(email) + conversation_uuid = email.split('@')[0] + conversation_uuid.match(REPLY_EMAIL_UUID_PATTERN) + end + # routing should be defined below the referenced procs # routes as a reply to existing conversations diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb index 73bd20524..55f7663ff 100644 --- a/app/mailboxes/mailbox_helper.rb +++ b/app/mailboxes/mailbox_helper.rb @@ -11,7 +11,9 @@ module MailboxHelper content_type: 'incoming_email', source_id: processed_mail.message_id, content_attributes: { - email: processed_mail.serialized_data + email: processed_mail.serialized_data, + cc_email: processed_mail.cc, + bcc_email: processed_mail.bcc } ) end diff --git a/app/mailboxes/reply_mailbox.rb b/app/mailboxes/reply_mailbox.rb index c74a9c887..000ad5138 100644 --- a/app/mailboxes/reply_mailbox.rb +++ b/app/mailboxes/reply_mailbox.rb @@ -1,6 +1,4 @@ class ReplyMailbox < ApplicationMailbox - include MailboxHelper - attr_accessor :conversation_uuid, :processed_mail # Last part is the regex for the UUID @@ -8,8 +6,8 @@ class ReplyMailbox < ApplicationMailbox EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i before_processing :conversation_uuid_from_to_address, + :find_relative_conversation, :verify_decoded_params, - :find_conversation, :decorate_mail def process @@ -19,10 +17,18 @@ class ReplyMailbox < ApplicationMailbox private + def find_relative_conversation + if @conversation_uuid + find_conversation_with_uuid + elsif mail.in_reply_to.present? + find_conversation_with_in_reply_to + end + end + def conversation_uuid_from_to_address mail.to.each do |email| username = email.split('@')[0] - match_result = username.match(ApplicationMailbox::REPLY_EMAIL_USERNAME_PATTERN) + match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN) if match_result @conversation_uuid = match_result.captures break @@ -35,11 +41,38 @@ class ReplyMailbox < ApplicationMailbox raise 'Conversation uuid not found' if conversation_uuid.nil? end - def find_conversation + # find conversation uuid from below pattern + # reply+@ + def find_conversation_with_uuid @conversation = Conversation.find_by(uuid: conversation_uuid) validate_resource @conversation end + def find_conversation_by_uuid(match_result) + @conversation_uuid = match_result.captures[0] + + find_conversation_with_uuid + end + + def find_conversation_by_message_id(in_reply_to) + @message = Message.find_by(source_id: in_reply_to) + @conversation = @message.conversation if @message.present? + @conversation_uuid = @conversation.uuid if @conversation.present? + end + + # find conversation uuid from below pattern + # + def find_conversation_with_in_reply_to + in_reply_to = mail.in_reply_to + match_result = in_reply_to.match(ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN) if in_reply_to.present? + + if match_result + find_conversation_by_uuid(match_result) + else + find_conversation_by_message_id(in_reply_to) + end + end + def validate_resource(resource) raise "#{resource.class.name} not found" if resource.nil? diff --git a/app/mailboxes/support_mailbox.rb b/app/mailboxes/support_mailbox.rb index 335bdefab..887232dd6 100644 --- a/app/mailboxes/support_mailbox.rb +++ b/app/mailboxes/support_mailbox.rb @@ -1,6 +1,4 @@ class SupportMailbox < ApplicationMailbox - include MailboxHelper - attr_accessor :channel, :account, :inbox, :conversation, :processed_mail before_processing :find_channel, @@ -11,7 +9,7 @@ class SupportMailbox < ApplicationMailbox def process ActiveRecord::Base.transaction do find_or_create_contact - create_conversation + find_or_create_conversation create_message add_attachments_to_message end @@ -21,7 +19,7 @@ class SupportMailbox < ApplicationMailbox def find_channel mail.to.each do |email| - @channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email) + @channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase) break if @channel.present? end raise 'Email channel/inbox not found' if @channel.nil? @@ -41,20 +39,31 @@ class SupportMailbox < ApplicationMailbox @processed_mail = MailPresenter.new(mail, @account) end - def create_conversation - @conversation = ::Conversation.create!({ - account_id: @account.id, - inbox_id: @inbox.id, - contact_id: @contact.id, - contact_inbox_id: @contact_inbox.id, - additional_attributes: { - source: 'email', - mail_subject: @processed_mail.subject, - initiated_at: { - timestamp: Time.now.utc - } - } - }) + def find_conversation_by_in_reply_to + return if in_reply_to.blank? + + @account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first + end + + def in_reply_to + mail['In-Reply-To'].try(:value) + end + + def find_or_create_conversation + @conversation = find_conversation_by_in_reply_to || ::Conversation.create!({ + account_id: @account.id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id, + additional_attributes: { + in_reply_to: in_reply_to, + source: 'email', + mail_subject: @processed_mail.subject, + initiated_at: { + timestamp: Time.now.utc + } + } + }) end def find_or_create_contact @@ -82,6 +91,6 @@ class SupportMailbox < ApplicationMailbox end def identify_contact_name - processed_mail.from.first.split('@').first + processed_mail.sender_name || processed_mail.from.first.split('@').first end end diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index b9e439e2b..a36450ec7 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -2,14 +2,14 @@ class ConversationReplyMailer < ApplicationMailer default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ') layout :choose_layout - def reply_with_summary(conversation, message_queued_time) + def reply_with_summary(conversation, last_queued_id) return unless smtp_config_set_or_development? init_conversation_attributes(conversation) return if conversation_already_viewed? - recap_messages = @conversation.messages.chat.where('created_at < ?', message_queued_time).last(10) - new_messages = @conversation.messages.chat.where('created_at >= ?', message_queued_time) + recap_messages = @conversation.messages.chat.where('id < ?', last_queued_id).last(10) + new_messages = @conversation.messages.chat.where('id >= ?', last_queued_id) @messages = recap_messages + new_messages @messages = @messages.select(&:email_reply_summarizable?) @@ -19,17 +19,19 @@ class ConversationReplyMailer < ApplicationMailer reply_to: reply_email, subject: mail_subject, message_id: custom_message_id, - in_reply_to: in_reply_to_email + in_reply_to: in_reply_to_email, + cc: cc_bcc_emails[0], + bcc: cc_bcc_emails[1] }) end - def reply_without_summary(conversation, message_queued_time) + def reply_without_summary(conversation, last_queued_id) return unless smtp_config_set_or_development? init_conversation_attributes(conversation) return if conversation_already_viewed? - @messages = @conversation.messages.chat.where(message_type: [:outgoing, :template]).where('created_at >= ?', message_queued_time) + @messages = @conversation.messages.chat.where(message_type: [:outgoing, :template]).where('id >= ?', last_queued_id) @messages = @messages.reject { |m| m.template? && !m.input_csat? } return false if @messages.count.zero? @@ -43,6 +45,26 @@ class ConversationReplyMailer < ApplicationMailer }) end + def email_reply(message) + return unless smtp_config_set_or_development? + + init_conversation_attributes(message.conversation) + @message = message + + reply_mail_object = mail({ + to: @contact&.email, + from: from_email_with_name, + reply_to: reply_email, + subject: mail_subject, + message_id: custom_message_id, + in_reply_to: in_reply_to_email, + cc: cc_bcc_emails[0], + bcc: cc_bcc_emails[1] + }) + + message.update(source_id: reply_mail_object.message_id) + end + def conversation_transcript(conversation, to_email) return unless smtp_config_set_or_development? @@ -100,7 +122,7 @@ class ConversationReplyMailer < ApplicationMailer def reply_email if should_use_conversation_email_address? - "#{assignee_name} " + "#{assignee_name} from #{@inbox.name} " else @inbox.email_address || @agent&.email end @@ -125,7 +147,9 @@ class ConversationReplyMailer < ApplicationMailer end def custom_message_id - "" + last_message = @message || @messages&.last + + "" end def in_reply_to_email @@ -142,13 +166,22 @@ class ConversationReplyMailer < ApplicationMailer nil end + def cc_bcc_emails + content_attributes = @conversation.messages.outgoing.last&.content_attributes + + return [] unless content_attributes + return [] unless content_attributes[:cc_emails] || content_attributes[:bcc_emails] + + [content_attributes[:cc_emails], content_attributes[:bcc_emails]] + end + def inbound_email_enabled? @inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain .present? && @account.support_email.present? end def choose_layout - return false if action_name == 'reply_without_summary' + return false if action_name == 'reply_without_summary' || action_name == 'email_reply' 'mailer/base' end diff --git a/app/models/account.rb b/app/models/account.rb index 0b8287be5..8ce2a4beb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -53,6 +53,7 @@ class Account < ApplicationRecord has_many :api_channels, dependent: :destroy, class_name: '::Channel::Api' has_many :line_channels, dependent: :destroy, class_name: '::Channel::Line' has_many :telegram_channels, dependent: :destroy, class_name: '::Channel::Telegram' + has_many :whatsapp_channels, dependent: :destroy, class_name: '::Channel::Whatsapp' has_many :canned_responses, dependent: :destroy has_many :webhooks, dependent: :destroy has_many :labels, dependent: :destroy 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/attachment.rb b/app/models/attachment.rb index 64637163d..0db94b9ba 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -25,6 +25,7 @@ class Attachment < ApplicationRecord enum file_type: [:image, :audio, :video, :file, :location, :fallback] def push_event_data + return unless file_type return base_data.merge(location_metadata) if file_type.to_sym == :location return base_data.merge(fallback_data) if file_type.to_sym == :fallback diff --git a/app/models/campaign.rb b/app/models/campaign.rb index 5257c2c2a..fea99aa30 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -2,22 +2,23 @@ # # Table name: campaigns # -# id :bigint not null, primary key -# audience :jsonb -# campaign_status :integer default("active"), not null -# campaign_type :integer default("ongoing"), not null -# description :text -# enabled :boolean default(TRUE) -# message :text not null -# scheduled_at :datetime -# title :string not null -# trigger_rules :jsonb -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null -# display_id :integer not null -# inbox_id :bigint not null -# sender_id :integer +# id :bigint not null, primary key +# audience :jsonb +# campaign_status :integer default("active"), not null +# campaign_type :integer default("ongoing"), not null +# description :text +# enabled :boolean default(TRUE) +# message :text not null +# scheduled_at :datetime +# title :string not null +# trigger_only_during_business_hours :boolean default(FALSE) +# trigger_rules :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# display_id :integer not null +# inbox_id :bigint not null +# sender_id :integer # # Indexes # 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/models/channel/telegram.rb b/app/models/channel/telegram.rb index e0d020f2f..16e6427e3 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -32,14 +32,10 @@ class Channel::Telegram < ApplicationRecord "https://api.telegram.org/bot#{bot_token}" end - def send_message_on_telegram(message, chat_id) - response = HTTParty.post("#{telegram_api_url}/sendMessage", - body: { - chat_id: chat_id, - text: message - }) + def send_message_on_telegram(message) + return send_message(message) if message.attachments.empty? - response.parsed_response['result']['message_id'] if response.success? + send_attachments(message) end def get_telegram_profile_image(user_id) @@ -80,4 +76,46 @@ class Channel::Telegram < ApplicationRecord }) errors.add(:bot_token, 'error setting up the webook') unless response.success? end + + def send_message(message) + response = message_request(message.conversation[:additional_attributes]['chat_id'], message.content) + response.parsed_response['result']['message_id'] if response.success? + end + + def send_attachments(message) + send_message(message) unless message.content.nil? + + telegram_attachments = [] + message.attachments.each do |attachment| + telegram_attachment = {} + + case attachment[:file_type] + when 'image' + telegram_attachment[:type] = 'photo' + when 'file' + telegram_attachment[:type] = 'document' + end + telegram_attachment[:media] = attachment.file_url + telegram_attachments << telegram_attachment + end + + response = attachments_request(message.conversation[:additional_attributes]['chat_id'], telegram_attachments) + response.parsed_response['result'].first['message_id'] if response.success? + end + + def attachments_request(chat_id, attachments) + HTTParty.post("#{telegram_api_url}/sendMediaGroup", + body: { + chat_id: chat_id, + media: attachments.to_json + }) + end + + def message_request(chat_id, text) + HTTParty.post("#{telegram_api_url}/sendMessage", + body: { + chat_id: chat_id, + text: text + }) + end end 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/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb new file mode 100644 index 000000000..f0e79a5cf --- /dev/null +++ b/app/models/channel/whatsapp.rb @@ -0,0 +1,67 @@ +# == Schema Information +# +# Table name: channel_whatsapp +# +# id :bigint not null, primary key +# phone_number :string not null +# provider :string default("default") +# provider_config :jsonb +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null +# +# Indexes +# +# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE +# + +class Channel::Whatsapp < ApplicationRecord + include Channelable + + self.table_name = 'channel_whatsapp' + EDITABLE_ATTRS = [:phone_number, { provider_config: {} }].freeze + + validates :phone_number, presence: true, uniqueness: true + before_save :validate_provider_config + + def name + 'Whatsapp' + end + + # all this should happen in provider service . but hack mode on + def api_base_path + # provide the environment variable when testing against sandbox : 'https://waba-sandbox.360dialog.io/v1' + ENV.fetch('360DIALOG_BASE_URL', 'https://waba.360dialog.io/v1') + end + + # Extract later into provider Service + def send_message(phone_number, message) + HTTParty.post( + "#{api_base_path}/messages", + headers: { 'D360-API-KEY': provider_config['api_key'], 'Content-Type': 'application/json' }, + body: { + to: phone_number, + text: { body: message }, + type: 'text' + }.to_json + ) + end + + def has_24_hour_messaging_window? + true + end + + private + + # Extract later into provider Service + def validate_provider_config + response = HTTParty.post( + "#{api_base_path}/configs/webhook", + headers: { 'D360-API-KEY': provider_config['api_key'], 'Content-Type': 'application/json' }, + body: { + url: "#{ENV['FRONTEND_URL']}/webhooks/whatsapp/#{phone_number}" + }.to_json + ) + errors.add(:bot_token, 'error setting up the webook') unless response.success? + 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/contact.rb b/app/models/contact.rb index b81b520f2..267dfab72 100644 --- a/app/models/contact.rb +++ b/app/models/contact.rb @@ -48,6 +48,7 @@ class Contact < ApplicationRecord before_validation :prepare_email_attribute after_create_commit :dispatch_create_event, :ip_lookup after_update_commit :dispatch_update_event + after_destroy_commit :dispatch_destroy_event def get_source_id(inbox_id) contact_inboxes.find_by!(inbox_id: inbox_id).source_id @@ -73,7 +74,8 @@ class Contact < ApplicationRecord id: id, name: name, avatar: avatar_url, - type: 'contact' + type: 'contact', + account: account.webhook_data } end @@ -98,4 +100,8 @@ class Contact < ApplicationRecord def dispatch_update_event Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self) end + + def dispatch_destroy_event + Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self) + end end diff --git a/app/models/contact_inbox.rb b/app/models/contact_inbox.rb index fe268b605..6f36b8735 100644 --- a/app/models/contact_inbox.rb +++ b/app/models/contact_inbox.rb @@ -61,7 +61,7 @@ class ContactInbox < ApplicationRecord end def validate_email_source_id - errors.add(:source_id, "invalid source id for Email inbox. valid Regex #{Device.email_regexp}") unless Devise.email_regexp.match?(source_id) + errors.add(:source_id, "invalid source id for Email inbox. valid Regex #{Devise.email_regexp}") unless Devise.email_regexp.match?(source_id) end def valid_source_id_format? diff --git a/app/models/conversation.rb b/app/models/conversation.rb index f5d4f9680..bc4db57b6 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -5,7 +5,9 @@ # id :integer not null, primary key # additional_attributes :jsonb # agent_last_seen_at :datetime +# assignee_last_seen_at :datetime # contact_last_seen_at :datetime +# custom_attributes :jsonb # identifier :string # last_activity_at :datetime not null # snoozed_until :datetime diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 437e7fd99..dbd64f820 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -83,6 +83,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Email' end + def twilio? + channel_type == 'Channel::TwilioSms' + end + def inbox_type channel.name end diff --git a/app/models/label.rb b/app/models/label.rb index b53260f58..9b551141d 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -25,6 +25,8 @@ class Label < ApplicationRecord format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE }, uniqueness: { scope: :account_id } + after_update_commit :update_associated_models + before_validation do self.title = title.downcase if attribute_present?('title') end @@ -40,4 +42,12 @@ class Label < ApplicationRecord def events account.events.where(conversation_id: conversations.pluck(:id)) end + + private + + def update_associated_models + return unless title_previously_changed? + + Labels::UpdateJob.perform_later(title, title_previously_was, account_id) + end end diff --git a/app/models/message.rb b/app/models/message.rb index d731bf488..7a6fe0bc0 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -5,7 +5,7 @@ # id :integer not null, primary key # content :text # content_attributes :json -# content_type :integer default("text") +# content_type :integer default("text"), not null # external_source_ids :jsonb # message_type :integer not null # private :boolean default(FALSE) @@ -32,10 +32,13 @@ class Message < ApplicationRecord include MessageFilterHelpers NUMBER_OF_PERMITTED_ATTACHMENTS = 15 + before_validation :ensure_content_type + validates :account_id, presence: true validates :inbox_id, presence: true validates :conversation_id, presence: true validates_with ContentAttributeValidator + validates :content_type, presence: true # when you have a temperory id in your frontend and want it echoed back via action cable attr_accessor :echo_id @@ -133,6 +136,10 @@ class Message < ApplicationRecord private + def ensure_content_type + self.content_type ||= Message.content_types[:text] + end + def execute_after_create_commit_callbacks # rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911 reopen_conversation @@ -198,17 +205,15 @@ class Message < ApplicationRecord end def trigger_notify_via_mail + return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email' + # will set a redis key for the conversation so that we don't need to send email for every new message # last few messages coupled together is sent every 2 minutes rather than one email for each message # if redis key exists there is an unprocessed job that will take care of delivering the email return if Redis::Alfred.get(conversation_mail_key).present? - Redis::Alfred.setex(conversation_mail_key, Time.zone.now) - if inbox.inbox_type == 'Email' - ConversationReplyEmailWorker.perform_in(2.seconds, conversation.id, Time.zone.now) - else - ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, Time.zone.now) - end + Redis::Alfred.setex(conversation_mail_key, id) + ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id) end def conversation_mail_key diff --git a/app/models/team.rb b/app/models/team.rb index 9f6c2f3ed..1b998eb4c 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -40,4 +40,12 @@ class Team < ApplicationRecord def remove_member(user_id) team_members.find_by(user_id: user_id)&.destroy end + + def messages + account.messages.where(conversation_id: conversations.pluck(:id)) + end + + def events + account.events.where(conversation_id: conversations.pluck(:id)) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 89ed7be84..6f5e67bee 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 @@ -77,7 +78,7 @@ class User < ApplicationRecord has_many :inbox_members, dependent: :destroy has_many :inboxes, through: :inbox_members, source: :inbox has_many :messages, as: :sender - has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', dependent: :nullify + has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify has_many :notifications, dependent: :destroy has_many :notification_settings, dependent: :destroy @@ -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/models/working_hour.rb b/app/models/working_hour.rb index b37be4eb1..890a452ba 100644 --- a/app/models/working_hour.rb +++ b/app/models/working_hour.rb @@ -43,10 +43,10 @@ class WorkingHour < ApplicationRecord def open_at?(time) return false if closed_all_day? - time.hour >= open_hour && - time.min >= open_minutes && - time.hour <= close_hour && - time.min <= close_minutes + open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes }) + close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes }) + + time.between?(open_time, close_time) end def open_now? diff --git a/app/policies/contact_policy.rb b/app/policies/contact_policy.rb index fb4cd4009..9013014d7 100644 --- a/app/policies/contact_policy.rb +++ b/app/policies/contact_policy.rb @@ -30,4 +30,8 @@ class ContactPolicy < ApplicationPolicy def create? true end + + def destroy? + @account_user.administrator? + end end diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index 57c96558e..306fda78c 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -4,12 +4,13 @@ class Conversations::EventDataPresenter < SimpleDelegator additional_attributes: additional_attributes, can_reply: can_reply?, channel: inbox.try(:channel_type), + contact_inbox: contact_inbox, id: display_id, inbox_id: inbox_id, - contact_inbox: contact_inbox, messages: push_messages, meta: push_meta, status: status, + snoozed_until: snoozed_until, unread_count: unread_incoming_messages.count, **push_timestamps } diff --git a/app/presenters/mail_presenter.rb b/app/presenters/mail_presenter.rb index 24b097a63..67acc31ec 100644 --- a/app/presenters/mail_presenter.rb +++ b/app/presenters/mail_presenter.rb @@ -82,6 +82,10 @@ class MailPresenter < SimpleDelegator @mail.from.map(&:downcase) end + def sender_name + Mail::Address.new(@mail[:from].value).name + end + def original_sender @mail['X-Original-Sender'].try(:value) || from.first end diff --git a/app/services/contacts/contactable_inboxes_service.rb b/app/services/contacts/contactable_inboxes_service.rb index 1950b190c..fcd91d4c3 100644 --- a/app/services/contacts/contactable_inboxes_service.rb +++ b/app/services/contacts/contactable_inboxes_service.rb @@ -9,12 +9,18 @@ class Contacts::ContactableInboxesService private def get_contactable_inbox(inbox) - return twilio_contactable_inbox(inbox) if inbox.channel_type == 'Channel::TwilioSms' - return email_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Email' - return api_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Api' - return website_contactable_inbox(inbox) if inbox.channel_type == 'Channel::WebWidget' - - nil + case inbox.channel_type + when 'Channel::TwilioSms' + twilio_contactable_inbox(inbox) + when 'Channel::Whatsapp' + whatsapp_contactable_inbox(inbox) + when 'Channel::Email' + email_contactable_inbox(inbox) + when 'Channel::Api' + api_contactable_inbox(inbox) + when 'Channel::WebWidget' + website_contactable_inbox(inbox) + end end def website_contactable_inbox(inbox) @@ -39,6 +45,13 @@ class Contacts::ContactableInboxesService { source_id: @contact.email, inbox: inbox } end + def whatsapp_contactable_inbox(inbox) + return unless @contact.phone_number + + # Remove the plus since thats the format 360 dialog uses + { source_id: @contact.phone_number.delete('+'), inbox: inbox } + end + def twilio_contactable_inbox(inbox) return if @contact.phone_number.blank? diff --git a/app/services/facebook/send_on_facebook_service.rb b/app/services/facebook/send_on_facebook_service.rb index a0c75db92..12e416ef7 100644 --- a/app/services/facebook/send_on_facebook_service.rb +++ b/app/services/facebook/send_on_facebook_service.rb @@ -9,8 +9,9 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService send_message_to_facebook fb_text_message_params if message.content.present? send_message_to_facebook fb_attachment_message_params if message.attachments.present? rescue Facebook::Messenger::FacebookError => e - Rails.logger.info e - channel.authorization_error! + Sentry.capture_exception(e) + # TODO : handle specific errors or else page will get disconnected + # channel.authorization_error! end def send_message_to_facebook(delivery_params) diff --git a/app/services/instagram/message_text.rb b/app/services/instagram/message_text.rb new file mode 100644 index 000000000..9fc1164a3 --- /dev/null +++ b/app/services/instagram/message_text.rb @@ -0,0 +1,52 @@ +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) + # person can connect the channel and then delete the inbox + return if @inbox.blank? + + 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..dde8cf3d0 --- /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) + # TODO : handle specific errors or else page will get disconnected + # 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 + ) + + Rails.logger.info("Instagram response: #{response} : #{message_content}") if response[:body] + + 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..021a132e2 --- /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/app/services/labels/update_service.rb b/app/services/labels/update_service.rb new file mode 100644 index 000000000..300331b09 --- /dev/null +++ b/app/services/labels/update_service.rb @@ -0,0 +1,35 @@ +class Labels::UpdateService + pattr_initialize [:new_label_title!, :old_label_title!, :account_id!] + + def perform + tagged_conversations.find_in_batches do |conversation_batch| + conversation_batch.each do |conversation| + conversation.label_list.remove(old_label_title) + conversation.label_list.add(new_label_title) + conversation.save! + end + end + + tagged_contacts.find_in_batches do |contact_batch| + contact_batch.each do |contact| + contact.label_list.remove(old_label_title) + contact.label_list.add(new_label_title) + contact.save! + end + end + end + + private + + def tagged_conversations + account.conversations.tagged_with(old_label_title) + end + + def tagged_contacts + account.contacts.tagged_with(old_label_title) + end + + def account + @account ||= Account.find(account_id) + end +end diff --git a/app/services/line/incoming_message_service.rb b/app/services/line/incoming_message_service.rb index 80e10c2e1..e3c85ef99 100644 --- a/app/services/line/incoming_message_service.rb +++ b/app/services/line/incoming_message_service.rb @@ -14,20 +14,63 @@ class Line::IncomingMessageService set_contact set_conversation - # TODO: iterate over the events and handle the attachments in future - # https://github.com/line/line-bot-sdk-ruby#synopsis + parse_events + end + + private + + def parse_events + params[:events].each do |event| + next unless message_created? event + + attach_files event['message'] + end + end + + def message_created?(event) + return unless event_type_message?(event) + @message = @conversation.messages.create( - content: params[:events].first['message']['text'], + content: event['message']['text'], account_id: @inbox.account_id, inbox_id: @inbox.id, message_type: :incoming, sender: @contact, - source_id: (params[:events].first['message']['id']).to_s + source_id: event['message']['id'].to_s + ) + @message + end + + def attach_files(message) + return unless message_type_non_text?(message['type']) + + response = inbox.channel.client.get_message_content(message['id']) + + file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}" + temp_file = Tempfile.new(file_name) + temp_file.binmode + temp_file << response.body + temp_file.rewind + + @message.attachments.new( + account_id: @message.account_id, + file_type: file_content_type(response), + file: { + io: temp_file, + filename: file_name, + content_type: response.content_type + } ) @message.save! end - private + def event_type_message?(event) + event['type'] == 'message' + end + + def message_type_non_text?(type) + [Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type) + end def account @account ||= inbox.account @@ -70,4 +113,8 @@ class Line::IncomingMessageService avatar_url: line_contact_info['pictureUrl'] } end + + def file_content_type(file_content) + file_type(file_content.content_type) + end end diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 4e63808bd..bed23b2f8 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -14,14 +14,18 @@ class MessageTemplates::HookExecutionService delegate :contact, to: :conversation def trigger_templates - # TODO: let's see whether this is needed and remove this and related logic if not - # ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? + ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message? ::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting? ::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect? ::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey? end def should_send_out_of_office_message? + # should not send if its a tweet message + return false if conversation.tweet? + # should not send for outbound messages + return false unless message.incoming? + inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present? end diff --git a/app/services/telegram/incoming_message_service.rb b/app/services/telegram/incoming_message_service.rb index ac1459887..da4b1a3bf 100644 --- a/app/services/telegram/incoming_message_service.rb +++ b/app/services/telegram/incoming_message_service.rb @@ -30,10 +30,6 @@ class Telegram::IncomingMessageService params.dig(:message, :chat, :type) == 'private' end - def account - @account ||= inbox.account - end - def set_contact contact_inbox = ::ContactBuilder.new( source_id: params[:message][:from][:id], @@ -90,13 +86,14 @@ class Telegram::IncomingMessageService end def file_content_type - params[:message][:photo].present? ? :image : file_type(params[:message][:document][:mime_type]) + return :image if params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present? + return :audio if params[:message][:voice].present? || params[:message][:audio].present? + return :video if params[:message][:video].present? + + file_type(params[:message][:document][:mime_type]) end def attach_files - file = params[:message][:document] - file ||= params[:message][:photo]&.last - return unless file attachment_file = Down.download( @@ -108,9 +105,17 @@ class Telegram::IncomingMessageService file_type: file_content_type, file: { io: attachment_file, - filename: attachment_file.original_filename, + filename: attachment_file, content_type: attachment_file.content_type } ) end + + def file + @file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence + end + + def visual_media_params + params[:message][:photo].presence&.last || params.dig(:message, :sticker, :thumb).presence || params[:message][:video].presence + end end diff --git a/app/services/telegram/send_on_telegram_service.rb b/app/services/telegram/send_on_telegram_service.rb index e6e98ff39..c4b27bc4f 100644 --- a/app/services/telegram/send_on_telegram_service.rb +++ b/app/services/telegram/send_on_telegram_service.rb @@ -8,7 +8,7 @@ class Telegram::SendOnTelegramService < Base::SendOnChannelService def perform_reply ## send reply to telegram message api # https://core.telegram.org/bots/api#sendmessage - message_id = channel.send_message_on_telegram(message.content, conversation[:additional_attributes]['chat_id']) + message_id = channel.send_message_on_telegram(message) message.update!(source_id: message_id) if message_id.present? end diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb index 1133bbf70..c722bd6bf 100644 --- a/app/services/twilio/send_on_twilio_service.rb +++ b/app/services/twilio/send_on_twilio_service.rb @@ -9,9 +9,9 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService begin twilio_message = client.messages.create(**message_params) rescue Twilio::REST::TwilioError => e - Rails.logger.info "Twilio Error: #{e.message}" + Sentry.capture_exception(e) end - message.update!(source_id: twilio_message.sid) + message.update!(source_id: twilio_message.sid) if twilio_message end def message_params diff --git a/app/services/whatsapp/incoming_message_service.rb b/app/services/whatsapp/incoming_message_service.rb new file mode 100644 index 000000000..c654c75e9 --- /dev/null +++ b/app/services/whatsapp/incoming_message_service.rb @@ -0,0 +1,61 @@ +# Find the various telegram payload samples here: https://core.telegram.org/bots/webhooks#testing-your-bot-with-updates +# https://core.telegram.org/bots/api#available-types + +class Whatsapp::IncomingMessageService + pattr_initialize [:inbox!, :params!] + + def perform + set_contact + return unless @contact + + set_conversation + + return if params[:messages].blank? + + @message = @conversation.messages.create( + content: params[:messages].first.dig(:text, :body), + account_id: @inbox.account_id, + inbox_id: @inbox.id, + message_type: :incoming, + sender: @contact, + source_id: params[:messages].first[:id].to_s + ) + @message.save! + end + + private + + def account + @account ||= inbox.account + end + + def set_contact + contact_params = params[:contacts]&.first + return if contact_params.blank? + + contact_inbox = ::ContactBuilder.new( + source_id: contact_params[:wa_id], + inbox: inbox, + contact_attributes: { name: contact_params.dig(:profile, :name), phone_number: "+#{params[:messages].first[:from]}" } + ).perform + + @contact_inbox = contact_inbox + @contact = contact_inbox.contact + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id + } + end + + def set_conversation + @conversation = @contact_inbox.conversations.last + return if @conversation + + @conversation = ::Conversation.create!(conversation_params) + end +end diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb new file mode 100644 index 000000000..4f93d5085 --- /dev/null +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -0,0 +1,11 @@ +class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService + private + + def channel_class + Channel::Whatsapp + end + + def perform_reply + channel.send_message(message.conversation.contact_inbox.source_id, message.content) + 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/accounts/channels/twilio_channels/create.json.jbuilder b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder index 8b27a80a7..981c1dec0 100644 --- a/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder +++ b/app/views/api/v1/accounts/channels/twilio_channels/create.json.jbuilder @@ -1,6 +1 @@ -json.id @inbox.id -json.channel_id @inbox.channel_id -json.name @inbox.name -json.channel_type @inbox.channel_type -json.enable_auto_assignment @inbox.enable_auto_assignment -json.phone_number @inbox.channel.phone_number +json.partial! 'api/v1/models/inbox.json.jbuilder', resource: @inbox diff --git a/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder b/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder new file mode 100644 index 000000000..1ca512802 --- /dev/null +++ b/app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder @@ -0,0 +1 @@ +json.custom_attributes @conversation.custom_attributes diff --git a/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder index db58b3faa..e1a3e1125 100644 --- a/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder +++ b/app/views/api/v1/accounts/conversations/messages/index.json.jbuilder @@ -4,6 +4,7 @@ json.meta do json.contact @conversation.contact.push_event_data json.assignee @conversation.assignee.push_event_data if @conversation.assignee.present? json.agent_last_seen_at @conversation.agent_last_seen_at + json.assignee_last_seen_at @conversation.assignee_last_seen_at end json.payload do diff --git a/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder b/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder index d6c99e254..00cd070d2 100644 --- a/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder +++ b/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder @@ -3,6 +3,7 @@ end json.payload do json.success @status - json.current_status @conversation.status json.conversation_id @conversation.display_id + json.current_status @conversation.status + json.snoozed_until @conversation.snoozed_until end diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 12879b3d5..d08672e77 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -24,14 +24,17 @@ else json.messages conversation.unread_messages.includes([:user, { attachments: [{ file_attachment: [:blob] }] }]).last(10).map(&:push_event_data) end -json.inbox_id conversation.inbox_id -json.status conversation.status -json.muted conversation.muted? -json.can_reply conversation.can_reply? -json.timestamp conversation.last_activity_at.to_i -json.contact_last_seen_at conversation.contact_last_seen_at.to_i -json.agent_last_seen_at conversation.agent_last_seen_at.to_i -json.unread_count conversation.unread_incoming_messages.count -json.additional_attributes conversation.additional_attributes json.account_id conversation.account_id +json.additional_attributes conversation.additional_attributes +json.agent_last_seen_at conversation.agent_last_seen_at.to_i +json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i +json.can_reply conversation.can_reply? +json.contact_last_seen_at conversation.contact_last_seen_at.to_i +json.custom_attributes conversation.custom_attributes +json.inbox_id conversation.inbox_id json.labels conversation.label_list +json.muted conversation.muted? +json.snoozed_until conversation.snoozed_until +json.status conversation.status +json.timestamp conversation.last_activity_at.to_i +json.unread_count conversation.unread_incoming_messages.count diff --git a/app/views/api/v1/models/_agent.json.jbuilder b/app/views/api/v1/models/_agent.json.jbuilder index 59a31603d..fd7119ca5 100644 --- a/app/views/api/v1/models/_agent.json.jbuilder +++ b/app/views/api/v1/models/_agent.json.jbuilder @@ -1,9 +1,11 @@ -json.account_id resource.account.id +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/_campaign.json.jbuilder b/app/views/api/v1/models/_campaign.json.jbuilder index 2ecce8bbd..ca1aa9e90 100644 --- a/app/views/api/v1/models/_campaign.json.jbuilder +++ b/app/views/api/v1/models/_campaign.json.jbuilder @@ -17,5 +17,6 @@ if resource.campaign_type == 'one_off' json.audience resource.audience end json.trigger_rules resource.trigger_rules +json.trigger_only_during_business_hours resource.trigger_only_during_business_hours json.created_at resource.created_at json.updated_at resource.updated_at diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 20e1ca36c..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) @@ -38,6 +39,7 @@ json.reauthorization_required resource.channel.try(:reauthorization_required?) i ## Twilio Attributes json.phone_number resource.channel.try(:phone_number) +json.medium resource.channel.try(:medium) if resource.twilio? ## Email Channel Attributes json.forward_to_email resource.channel.try(:forward_to_email) diff --git a/app/views/api/v1/models/_user.json.jbuilder b/app/views/api/v1/models/_user.json.jbuilder index 5c0cdeec9..39c552c40 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,10 @@ json.accounts do json.name account_user.account.name json.active_at account_user.active_at json.role account_user.role + # the actual availability user has configured + json.availability account_user.availability + # availability derived from presence + 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/campaigns/index.json.jbuilder b/app/views/api/v1/widget/campaigns/index.json.jbuilder index 48a551490..b6660fe2a 100644 --- a/app/views/api/v1/widget/campaigns/index.json.jbuilder +++ b/app/views/api/v1/widget/campaigns/index.json.jbuilder @@ -1,6 +1,7 @@ json.array! @campaigns do |campaign| json.id campaign.display_id json.trigger_rules campaign.trigger_rules + json.trigger_only_during_business_hours campaign.trigger_only_during_business_hours json.message campaign.message json.sender campaign.sender&.slice(:name, :avatar_url) end diff --git a/app/views/api/v1/widget/configs/create.json.jbuilder b/app/views/api/v1/widget/configs/create.json.jbuilder index 8a7696a87..8fbd5a502 100644 --- a/app/views/api/v1/widget/configs/create.json.jbuilder +++ b/app/views/api/v1/widget/configs/create.json.jbuilder @@ -16,7 +16,7 @@ json.chatwoot_website_channel do json.csat_survey_enabled @web_widget.inbox.csat_survey_enabled json.working_hours @web_widget.inbox.working_hours json.out_of_office_message @web_widget.inbox.out_of_office_message - json.utc_off_set ActiveSupport::TimeZone[@web_widget.inbox.timezone].formatted_offset + json.utc_off_set ActiveSupport::TimeZone[@web_widget.inbox.timezone].now.formatted_offset end json.chatwoot_widget_defaults do json.use_inbox_avatar_for_bot ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) 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/api/v2/accounts/reports/teams.csv.erb b/app/views/api/v2/accounts/reports/teams.csv.erb new file mode 100644 index 000000000..55c679273 --- /dev/null +++ b/app/views/api/v2/accounts/reports/teams.csv.erb @@ -0,0 +1,19 @@ +<% headers = [ + I18n.t('reports.team_csv.team_name'), + I18n.t('reports.team_csv.conversations_count'), + I18n.t('reports.team_csv.avg_first_response_time'), + I18n.t('reports.team_csv.avg_resolution_time') + ] +%> +<%= CSV.generate_line headers %> +<% Current.account.teams.each do |team| %> + <% team_report = V2::ReportBuilder.new(Current.account, { + type: :team, + id: team.id, + since: params[:since], + until: params[:until] + }).summary %> + <% row = [ team.name, team_report[:conversations_count], (team_report[:avg_first_response_time]/60).to_i, (team_report[:avg_resolution_time]/60).to_i ] %> + <%= CSV.generate_line row %> +<% end %> +<%= CSV.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %> diff --git a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb new file mode 100644 index 000000000..197c6ce60 --- /dev/null +++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb @@ -0,0 +1,8 @@ +<% if @message.content %> + <%= CommonMarker.render_html(@message.content).html_safe %> +<% end %> +<% if @message.attachments %> + <% @message.attachments.each do |attachment| %> + attachment [click here to view] + <% 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/app/views/widgets/show.html.erb b/app/views/widgets/show.html.erb index 04f8ac0e6..6c4d38f88 100644 --- a/app/views/widgets/show.html.erb +++ b/app/views/widgets/show.html.erb @@ -23,7 +23,7 @@ csatSurveyEnabled: <%= @web_widget.inbox.csat_survey_enabled %>, workingHours: <%= @web_widget.inbox.working_hours.to_json.html_safe %>, outOfOfficeMessage: <%= @web_widget.inbox.out_of_office_message.to_json.html_safe %>, - utcOffset: '<%= ActiveSupport::TimeZone[@web_widget.inbox.timezone].formatted_offset %>' + utcOffset: '<%= ActiveSupport::TimeZone[@web_widget.inbox.timezone].now.formatted_offset %>' } window.chatwootWidgetDefaults = { useInboxAvatarForBot: <%= ActiveModel::Type::Boolean.new.cast(ENV.fetch('USE_INBOX_AVATAR_FOR_BOT', false)) %>, diff --git a/app/workers/conversation_reply_email_worker.rb b/app/workers/conversation_reply_email_worker.rb index 1f97b43ee..0eddecaf7 100644 --- a/app/workers/conversation_reply_email_worker.rb +++ b/app/workers/conversation_reply_email_worker.rb @@ -3,14 +3,14 @@ class ConversationReplyEmailWorker include Sidekiq::Worker sidekiq_options queue: :mailers - def perform(conversation_id, queued_time) + def perform(conversation_id, last_queued_id) @conversation = Conversation.find(conversation_id) # send the email - if @conversation.messages.incoming&.last&.content_type == 'incoming_email' || email_inbox? - ConversationReplyMailer.with(account: @conversation.account).reply_without_summary(@conversation, queued_time).deliver_later + if @conversation.messages.incoming&.last&.content_type == 'incoming_email' + ConversationReplyMailer.with(account: @conversation.account).reply_without_summary(@conversation, last_queued_id).deliver_later else - ConversationReplyMailer.with(account: @conversation.account).reply_with_summary(@conversation, queued_time).deliver_later + ConversationReplyMailer.with(account: @conversation.account).reply_with_summary(@conversation, last_queued_id).deliver_later end # delete the redis set from the first new message on the conversation diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb new file mode 100644 index 000000000..1dc3c704b --- /dev/null +++ b/app/workers/email_reply_worker.rb @@ -0,0 +1,14 @@ +class EmailReplyWorker + include Sidekiq::Worker + sidekiq_options queue: :mailers + + def perform(message_id) + message = Message.find(message_id) + + return unless message.outgoing? || message.input_csat? + return if message.private? + + # send the email + ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_later + end +end diff --git a/config/app.yml b/config/app.yml index 1963e5b06..eaaed2980 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '1.20.0' + version: '1.21.0' development: <<: *shared diff --git a/config/initializers/01_redis.rb b/config/initializers/01_redis.rb index c5607bb2c..caa01e9c1 100644 --- a/config/initializers/01_redis.rb +++ b/config/initializers/01_redis.rb @@ -3,8 +3,8 @@ redis = Rails.env.test? ? MockRedis.new : Redis.new(Redis::Config.app) # Alfred # Add here as you use it for more features # Used for Round Robin, Conversation Emails & Online Presence -$alfred = Redis::Namespace.new('alfred', redis: redis, warning: true) +$alfred = ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis::Namespace.new('alfred', redis: redis, warning: true) } # Velma : Determined protector # used in rack attack -$velma = Redis::Namespace.new('velma', redis: redis, warning: true) +$velma = ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis::Namespace.new('velma', redis: redis, warning: true) } diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 1e1aa9cf8..39556b6f2 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 = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false diff --git a/config/locales/ar.yml b/config/locales/ar.yml index abbcf59f4..84a215405 100644 --- a/config/locales/ar.yml +++ b/config/locales/ar.yml @@ -27,6 +27,9 @@ ar: invalid_email: لقد قمت بإدخال عنوان بريد إلكتروني غير صالح email_already_exists: "لقد قمت بالفعل بتسجيل حساب سابقاً بالعنوان %{email}" failed: فشلت عملية التسجيل + contacts: + import: + failed: File is blank reports: period: فترة التبليغ %{since} إلى %{until} agent_csv: @@ -34,6 +37,11 @@ ar: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: اسم الفريق + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[محادثة جديدة] - #%{display_id} تم إنشاؤها في %{inbox_name}" @@ -73,6 +81,9 @@ ar: transcript_subject: "نص المحادثة" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ca.yml b/config/locales/ca.yml index 10e1e251d..483aed22a 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -27,6 +27,9 @@ ca: invalid_email: Heu introduït un correu electrònic no vàlid email_already_exists: "Ja us heu registrat amb el compte %{email}" failed: El registre ha fallat + contacts: + import: + failed: File is blank reports: period: Període d'informes %{since} a %{until} agent_csv: @@ -34,6 +37,11 @@ ca: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[Nova conversació] - #%{display_id} ha estat creada a %{inbox_name}" @@ -73,6 +81,9 @@ ca: transcript_subject: "Transcripció de conversa" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/cs.yml b/config/locales/cs.yml index 2a68c3b8e..665880ae1 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -27,6 +27,9 @@ cs: invalid_email: Zadali jste neplatný e-mail email_already_exists: "Již jste se zaregistrovali k účtu s %{email}" failed: Registrace se nezdařila + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ cs: conversations_count: Conversations count avg_first_response_time: Prům. doba první odpovědi (minuty) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Prům. doba první odpovědi (minuty) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ cs: transcript_subject: "Přepis konverzace" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/da.yml b/config/locales/da.yml index 1f3761687..dc427f1b8 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -27,6 +27,9 @@ da: invalid_email: Du har indtastet en ugyldig e-mail email_already_exists: "Du har allerede tilmeldt dig en konto med %{email}" failed: Tilmelding mislykkedes + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ da: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ da: transcript_subject: "Samtaleudskrift" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/de.yml b/config/locales/de.yml index 9a462ce5f..5fa29e807 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -27,6 +27,9 @@ de: invalid_email: Sie haben eine ungültige E-Mail-Adresse eingegeben email_already_exists: "Sie haben sich bereits für ein Konto bei %{email} angemeldet." failed: Anmeldung gescheitert + contacts: + import: + failed: File is blank reports: period: Berichtszeitraum von %{since} bis %{until} agent_csv: @@ -34,6 +37,11 @@ de: conversations_count: Anzahl Gespräche avg_first_response_time: Durchschnittliche Reaktionszeit (Minuten) avg_resolution_time: Durchschnittliche Auflösungszeit (Minuten) + team_csv: + team_name: Teamname + conversations_count: Anzahl Gespräche + avg_first_response_time: Durchschnittliche Reaktionszeit (Minuten) + avg_resolution_time: Durchschnittliche Auflösungszeit (Minuten) notifications: notification_title: conversation_creation: "[Neues Gespräch] - #%{display_id} wurde in %{inbox_name} erstellt" @@ -73,6 +81,9 @@ de: transcript_subject: "Gesprächsprotokoll" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/el.yml b/config/locales/el.yml index d0034103f..a1edad4a5 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -27,6 +27,9 @@ el: invalid_email: Έχετε καταχωρήσει ακατάλληλο email email_already_exists: "Έχει ήδη καταχωρηθεί λογαριασμός στο %{email}" failed: Η εγγραφή απέτυχε + contacts: + import: + failed: Το αρχείο είναι κενό reports: period: Περίοδος αναφοράς %{since} έως %{until} agent_csv: @@ -34,6 +37,11 @@ el: conversations_count: Αριθμός συνομιλιών avg_first_response_time: Μέσος χρόνος πρώτης ανταπόκρισης (λεπτά) avg_resolution_time: Μέσος χρόνος επίλυσης (λεπτά) + team_csv: + team_name: Όνομα ομάδας + conversations_count: Αριθμός συνομιλιών + avg_first_response_time: Μέσος χρόνος πρώτης ανταπόκρισης (λεπτά) + avg_resolution_time: Μέσος χρόνος επίλυσης (λεπτά) notifications: notification_title: conversation_creation: "[Νέα συνομιλία] - #%{display_id} δημιουργήθηκε στο %{inbox_name}" @@ -73,6 +81,9 @@ el: transcript_subject: "Μεταγραφή Συνομιλίας" survey: response: "Παρακαλώ αξιολογήστε αυτήν την συνομιλία, %{link}" + contacts: + online: + delete: "%{contact_name} είναι Online, παρακαλώ προσπαθήστε ξανά αργότερα" integration_apps: slack: name: "Slack" diff --git a/config/locales/en.yml b/config/locales/en.yml index 061d3055e..12cb8ce0f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -41,6 +41,9 @@ en: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} @@ -49,6 +52,11 @@ en: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: @@ -89,7 +97,9 @@ en: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" - + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/es.yml b/config/locales/es.yml index 5f59f9f29..ce81ae977 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -27,6 +27,9 @@ es: invalid_email: Ha introducido un correo electrónico no válido email_already_exists: "Ya te has registrado en una cuenta con %{email}" failed: Registro fallido + contacts: + import: + failed: File is blank reports: period: Reportando el periodo desde %{since} hasta %{until} agent_csv: @@ -34,6 +37,11 @@ es: conversations_count: Cantidad de conversaciones avg_first_response_time: Promedio de tiempo de la primera respuesta (Minutos) avg_resolution_time: Promedio de tiempo de resolución (Minutos) + team_csv: + team_name: Nombre del equipo + conversations_count: Cantidad de conversaciones + avg_first_response_time: Promedio de tiempo de la primera respuesta (Minutos) + avg_resolution_time: Promedio de tiempo de resolución (Minutos) notifications: notification_title: conversation_creation: "[Nueva conversación] - #%{display_id} ha sido creado en %{inbox_name}" @@ -73,6 +81,9 @@ es: transcript_subject: "Transcripción de la conversación" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/fa.yml b/config/locales/fa.yml index db1110774..a812d0d04 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -27,6 +27,9 @@ fa: invalid_email: ایمیل وارد شده معتبر نیست email_already_exists: "قبلا کاربری با ایمیل %{email} ثبت نام کرده است." failed: ثبت نام ناموفق بود + contacts: + import: + failed: فایل خالی است reports: period: زمان گزارش از %{since} تا %{until} agent_csv: @@ -34,6 +37,11 @@ fa: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: نام تیم + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[گفتگو جدید] - #%{display_id} در %{inbox_name} ایجاد شده است" @@ -73,6 +81,9 @@ fa: transcript_subject: "متن مکالمه" survey: response: "لطفاً به مکالمه امتیاز دهید %{link}" + contacts: + online: + delete: "%{contact_name} آنلاین است ، لطفاً بعداً دوباره امتحان کنید" integration_apps: slack: name: "Slack" diff --git a/config/locales/fi.yml b/config/locales/fi.yml index 6e2991b86..14f7e957a 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -27,6 +27,9 @@ fi: invalid_email: Olet syöttänyt virheellisen sähköpostin email_already_exists: "Olet jo rekisteröitynyt tiliin sähköpostin %{email} kanssa" failed: Rekisteröityminen epäonnistui + contacts: + import: + failed: File is blank reports: period: Raportointijakso %{since} – %{until} agent_csv: @@ -34,6 +37,11 @@ fi: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ fi: transcript_subject: "Keskustelukopio" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index ec5bf35e3..1e3a81eca 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -27,6 +27,9 @@ fr: invalid_email: Vous avez entré un courriel non valide email_already_exists: "Vous avez déjà créé un compte avec %{email}" failed: L'inscription a échoué + contacts: + import: + failed: File is blank reports: period: Période de rapport %{since} à %{until} agent_csv: @@ -34,6 +37,11 @@ fr: conversations_count: Nombre de conversations avg_first_response_time: Temps de réponse moyen (Minutes) avg_resolution_time: Temps moyen de résolution (Minutes) + team_csv: + team_name: Nom de l'équipe + conversations_count: Nombre de conversations + avg_first_response_time: Temps de réponse moyen (Minutes) + avg_resolution_time: Temps moyen de résolution (Minutes) notifications: notification_title: conversation_creation: "[Nouvelle conversation] - #%{display_id} a été créé dans %{inbox_name}" @@ -47,8 +55,8 @@ fr: status: resolved: "La conversation a été marquée résolue par %{user_name}" open: "La conversation a été ré-ouverte par %{user_name}" - pending: "Conversation was marked as pending by %{user_name}" - snoozed: "Conversation was snoozed by %{user_name}" + pending: "La conversation a été marquée comme en attente par %{user_name}" + snoozed: "La conversation a été reportée par %{user_name}" auto_resolved: "La conversation a été marquée comme résolue par le système en raison de %{duration} jours d'inactivité" assignee: self_assigned: "%{user_name} s'est auto-assigné cette conversation" @@ -72,7 +80,10 @@ fr: email_subject: "Nouveaux messages dans cette conversation" transcript_subject: "Transcription de conversation" survey: - response: "Please rate this conversation, %{link}" + response: "Merci de noter cette conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/he.yml b/config/locales/he.yml index 67d6ddde5..4d62c77aa 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -27,6 +27,9 @@ he: invalid_email: הכנסת מייל לא תקין email_already_exists: "כבר נרשמת לחשבון עם %{email}" failed: הרשמה נכשלה + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ he: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ he: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/hi.yml b/config/locales/hi.yml index 34f4ab2d0..3fe668eb8 100644 --- a/config/locales/hi.yml +++ b/config/locales/hi.yml @@ -27,6 +27,9 @@ hi: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ hi: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ hi: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/hu.yml b/config/locales/hu.yml index dee6af3ec..bb06a8b3d 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -27,6 +27,9 @@ hu: invalid_email: Hibás e-mailcímet adtál meg email_already_exists: "Ezzel az e-mailcímmel már van fiók: %{email}" failed: Feliratkozás sikertelen + contacts: + import: + failed: File is blank reports: period: Jelentési időszak %{since}-tól %{until}-ig agent_csv: @@ -34,6 +37,11 @@ hu: conversations_count: Beszélgetésszám avg_first_response_time: Átlagos első reakcióidő (perc) avg_resolution_time: Átlagos megoldási idő (perc) + team_csv: + team_name: Csapatnév + conversations_count: Beszélgetésszám + avg_first_response_time: Átlagos első reakcióidő (perc) + avg_resolution_time: Átlagos megoldási idő (perc) notifications: notification_title: conversation_creation: "[Új beszélgetés] - #%{display_id} létrejött itt:%{inbox_name}" @@ -73,6 +81,9 @@ hu: transcript_subject: "Beszélgetés kivonat" survey: response: "Kérlek értékeld a beszélgetést, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/id.yml b/config/locales/id.yml index c1369d4e6..73ed4a3f8 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -27,6 +27,9 @@ id: invalid_email: Anda telah memasukkan email yang tidak valid email_already_exists: "Anda telah mendaftar untuk sebuah akun dengan %{email}" failed: Pendaftaran gagal + contacts: + import: + failed: File kosong reports: period: Periode pelaporan %{since} hingga %{until} agent_csv: @@ -34,6 +37,11 @@ id: conversations_count: Jumlah percakapan avg_first_response_time: Rata-rata waktu respons pertama (Menit) avg_resolution_time: Rata-rata waktu resolusi (Menit) + team_csv: + team_name: Nama Tim + conversations_count: Jumlah percakapan + avg_first_response_time: Rata-rata waktu respons pertama (Menit) + avg_resolution_time: Rata-rata waktu resolusi (Menit) notifications: notification_title: conversation_creation: "[Percakapan baru] - #%{display_id} telah dibuat di %{inbox_name}" @@ -47,8 +55,8 @@ id: status: resolved: "Percakapan ditandai selesai oleh %{user_name}" open: "Percakapan telah dibuka kembali oleh %{user_name}" - pending: "Conversation was marked as pending by %{user_name}" - snoozed: "Conversation was snoozed by %{user_name}" + pending: "Percakapan telah ditandai sebagai tertunda oleh %{user_name}" + snoozed: "Percakapan telah ditunda oleh %{user_name}" auto_resolved: "Percakapan ditandai terselesaikan oleh sistem karena tidak ada aktifitas dalam %{duration} hari" assignee: self_assigned: "%{user_name} menetapkan sendiri percakapan ini" @@ -67,12 +75,15 @@ id: greeting_message_body: "%{account_name} biasanya membalas dalam beberapa jam." ways_to_reach_you_message_body: "Beri tim cara untuk menghubungi Anda." email_input_box_message_body: "Dapatkan pemberitahuan melalui email" - csat_input_message_body: "Please rate the conversation" + csat_input_message_body: "Silakan beri peringkat percakapan ini" reply: email_subject: "Pesan baru pada percakapan ini" transcript_subject: "Transkrip Percakapan" survey: - response: "Please rate this conversation, %{link}" + response: "Silakan beri peringkat percakapan ini, %{link}" + contacts: + online: + delete: "%{contact_name} sedang Online, silakan coba lagi nanti" integration_apps: slack: name: "Slack" @@ -82,7 +93,7 @@ id: description: "Webhook event memberi Anda informasi realtime tentang apa yang terjadi di akun Anda. Anda dapat menggunakan webhook untuk mengkomunikasikan acara ke aplikasi favorit Anda seperti Slack atau GitHub. Klik Konfigurasi untuk mengatur webhook Anda." dialogflow: name: "Dialogflow" - description: "Build chatbots using Dialogflow and connect them to your inbox quickly. Let the bots handle the queries before handing them off to a customer service agent." + description: "Pembuatan chatbot menggunakan Dialogflow dan menghubungkannya ke kotak masuk Anda secepatnya. Biarkan bot menangani semua permintaan sebelum menyerahkannya pada agen layanan pelanggan." fullcontact: name: "Fullcontact" description: "FullContact integration helps to enrich visitor profiles. Identify the users as soon as they share their email address and offer them tailored customer service. Connect your FullContact to your account by sharing the FullContact API Key." diff --git a/config/locales/it.yml b/config/locales/it.yml index 674711fff..30d01385a 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -27,62 +27,73 @@ it: invalid_email: Hai inserito un'email non valida email_already_exists: "Ti sei già registrato per un account con %{email}" failed: Iscrizione fallita + contacts: + import: + failed: Il file è vuoto reports: - period: Reporting period %{since} to %{until} + period: Periodo di segnalazione da %{since} a %{until} agent_csv: - agent_name: Agent name - conversations_count: Conversations count - avg_first_response_time: Avg first response time (Minutes) - avg_resolution_time: Avg resolution time (Minutes) + agent_name: Nome dell'agente + conversations_count: Conteggio conversazioni + avg_first_response_time: Tempo medio di prima risposta (minuti) + avg_resolution_time: Tempo medio di risoluzione (minuti) + team_csv: + team_name: Nome del team + conversations_count: Conteggio conversazioni + avg_first_response_time: Tempo medio di prima risposta (minuti) + avg_resolution_time: Tempo medio di risoluzione (minuti) notifications: notification_title: - conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" - conversation_assignment: "[Assigned to you] - #%{display_id} has been assigned to you" - assigned_conversation_new_message: "[New message] - #%{display_id} %{content}" - conversation_mention: "You have been mentioned in conversation [ID - %{display_id}] by %{name}" + conversation_creation: "[Nuova conversazione] - #%{display_id} è stato creato in %{inbox_name}" + conversation_assignment: "[Assegnato a te] - #%{display_id} ti è stato assegnato" + assigned_conversation_new_message: "[Nuovo messaggio] - #%{display_id} %{content}" + conversation_mention: "Sei stato menzionato nella conversazione [ID - %{display_id}] da %{name}" conversations: messages: - deleted: This message was deleted + deleted: Questo messaggio è stato eliminato activity: status: resolved: "Conversazione segnata da %{user_name}" open: "La conversazione è stata riaperta da %{user_name}" - pending: "Conversation was marked as pending by %{user_name}" - snoozed: "Conversation was snoozed by %{user_name}" - auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity" + pending: "La conversazione è stata contrassegnata come in attesa da %{user_name}" + snoozed: "La conversazione è stata sospesa da %{user_name}" + auto_resolved: "La conversazione è stata segnata risolta dal sistema a causa di %{duration} giorni di inattività" assignee: - self_assigned: "%{user_name} self-assigned this conversation" + self_assigned: "%{user_name} auto-assegnato a questa conversazione" assigned: "Assegnato a %{assignee_name} da %{user_name}" removed: "Conversazione non assegnata da %{user_name}" team: - assigned: "Assigned to %{team_name} by %{user_name}" - assigned_with_assignee: "Assigned to %{assignee_name} via %{team_name} by %{user_name}" - removed: "Unassigned from %{team_name} by %{user_name}" + assigned: "Assegnato a %{team_name} da %{user_name}" + assigned_with_assignee: "Assegnato a %{assignee_name} via %{team_name} da %{user_name}" + removed: "Assegnato a %{team_name} da %{user_name}" labels: - added: "%{user_name} added %{labels}" - removed: "%{user_name} removed %{labels}" - muted: "%{user_name} has muted the conversation" - unmuted: "%{user_name} has unmuted the conversation" + added: "%{user_name} ha aggiunto %{labels}" + removed: "%{user_name} rimosso %{labels}" + muted: "%{user_name} ha silenziato la conversazione" + unmuted: "%{user_name} ha attivato le notifiche alla conversazione" templates: greeting_message_body: "%{account_name}, in genere, risponde in poche ore." ways_to_reach_you_message_body: "Offri alla squadra un modo per raggiungerti." email_input_box_message_body: "Ricevi una notifica via email" - csat_input_message_body: "Please rate the conversation" + csat_input_message_body: "Valuta la conversazione" reply: email_subject: "Nuovi messaggi in questa conversazione" - transcript_subject: "Conversation Transcript" + transcript_subject: "Trascrizione della Conversazione" survey: - response: "Please rate this conversation, %{link}" + response: "Valuta questa conversazione, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" - description: "Slack is a chat tool that brings all your communication together in one place. By integrating Slack, you can get notified of all the new conversations in your account right inside your Slack." + description: "Slack è uno strumento di chat che porta tutta la vostra comunicazione insieme in un unico posto. Integrando Slack, puoi ricevere una notifica di tutte le nuove conversazioni nel tuo account direttamente all'interno della tua Slack." webhooks: name: "Webhooks" - description: "Webhook events provide you the realtime information about what's happening in your account. You can make use of the webhooks to communicate the events to your favourite apps like Slack or Github. Click on Configure to set up your webhooks." + description: "Gli eventi Webhook ti forniscono le informazioni in tempo reale su ciò che sta accadendo nel tuo account. Puoi utilizzare i webhooks per comunicare gli eventi alle tue app preferite, come Slack o Github. Clicca su Configura per configurare i tuoi webhook." dialogflow: name: "Dialogflow" - description: "Build chatbots using Dialogflow and connect them to your inbox quickly. Let the bots handle the queries before handing them off to a customer service agent." + description: "Costruisci chatbots usando Dialogflow e collegali rapidamente alla tua casella di posta. Lasciate che i bot gestiscano le domande prima di consegnarle a un agente di servizio clienti." fullcontact: name: "Fullcontact" - description: "FullContact integration helps to enrich visitor profiles. Identify the users as soon as they share their email address and offer them tailored customer service. Connect your FullContact to your account by sharing the FullContact API Key." + description: "L'integrazione di FullContact aiuta ad arricchire i profili dei visitatori. Identificare gli utenti non appena condividono il loro indirizzo email e offrono loro un servizio clienti personalizzato. Collega il tuo FullContact al tuo account condividendo la chiave API FullContent." diff --git a/config/locales/ja.yml b/config/locales/ja.yml index ab66452d9..2dce8c5a3 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -27,6 +27,9 @@ ja: invalid_email: 無効なメールアドレスを入力しました。 email_already_exists: "あなたは既に %{email} でアカウントにサインアップしています" failed: サインアップに失敗しました + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ ja: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ja: transcript_subject: "会話の記録" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ko.yml b/config/locales/ko.yml index aff5ecf68..8679615c0 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -27,6 +27,9 @@ ko: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: 보고 기간 %{since} - %{until} agent_csv: @@ -34,6 +37,11 @@ ko: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ko: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ml.yml b/config/locales/ml.yml index 8bb399b61..cebc0a25d 100644 --- a/config/locales/ml.yml +++ b/config/locales/ml.yml @@ -27,6 +27,9 @@ ml: invalid_email: നിങ്ങൾ ഒരു അസാധുവായ ഇമെയിൽ നൽകി email_already_exists: "നിങ്ങൾ ഇതിനകം ഈ %{email} ഉപയോഗിച്ചു ഒരു അക്കൗണ്ടിനായി സൈൻ അപ്പ് ചെയ്തു" failed: സൈനപ്പ് പരാജയപ്പെട്ടു + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ ml: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ml: transcript_subject: "സംഭാഷണ ട്രാൻസ്ക്രിപ്റ്റ്" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ne.yml b/config/locales/ne.yml index e06ca1d77..280b24ed6 100644 --- a/config/locales/ne.yml +++ b/config/locales/ne.yml @@ -27,6 +27,9 @@ ne: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ ne: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ne: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 103dcd97e..4e46809b7 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -27,6 +27,9 @@ nl: invalid_email: Je hebt een ongeldig e-mailadres ingevoerd email_already_exists: "Je hebt je al aangemeld voor een account bij %{email}" failed: Aanmelden mislukt + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ nl: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ nl: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/no.yml b/config/locales/no.yml index 56a1499b6..56b563584 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -27,6 +27,9 @@ invalid_email: Du har angitt en ugyldig e-post email_already_exists: "Du har allerede registrert en konto med %{email}" failed: Registrering mislyktes + contacts: + import: + failed: File is blank reports: period: Rapporteringsperiode %{since} til %{until} agent_csv: @@ -34,6 +37,11 @@ conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ transcript_subject: "Kopi av samtale" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 3880116eb..4c0d3fa59 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -27,6 +27,9 @@ pl: invalid_email: Wprowadzono nieprawidłowy adres e-mail email_already_exists: "Już zarejestrowałeś się na konto %{email}" failed: Rejestracja nie powiodła się + contacts: + import: + failed: File is blank reports: period: Okres raportowania od %{since} do %{until} agent_csv: @@ -34,6 +37,11 @@ pl: conversations_count: Liczba rozmów avg_first_response_time: Średni czas pierwszej odpowiedzi (minuty) avg_resolution_time: Średni czas do rozwiązania problemu (minuty) + team_csv: + team_name: Team name + conversations_count: Liczba rozmów + avg_first_response_time: Średni czas pierwszej odpowiedzi (minuty) + avg_resolution_time: Średni czas do rozwiązania problemu (minuty) notifications: notification_title: conversation_creation: "[Nowa rozmowa] - #%{display_id} został utworzony w %{inbox_name}" @@ -73,6 +81,9 @@ pl: transcript_subject: "Transkrypcja rozmowy" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/pt.yml b/config/locales/pt.yml index e5accba56..74aa0c94d 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -27,6 +27,9 @@ pt: invalid_email: Você digitou um email inválido email_already_exists: "Você já se inscreveu para uma conta com %{email}" failed: Falha na inscrição + contacts: + import: + failed: File is blank reports: period: Período do relatório de %{since} a %{until} agent_csv: @@ -34,6 +37,11 @@ pt: conversations_count: Número de conversas avg_first_response_time: Média do tempo de resposta (minutos) avg_resolution_time: Média do tempo de resolução (minutos) + team_csv: + team_name: Nome da equipa + conversations_count: Número de conversas + avg_first_response_time: Média do tempo de resposta (minutos) + avg_resolution_time: Média do tempo de resolução (minutos) notifications: notification_title: conversation_creation: "[Nova conversa] - #%{display_id} foi criada em %{inbox_name}" @@ -73,6 +81,9 @@ pt: transcript_subject: "Transcrição da conversa" survey: response: "Por favor, avalie esta conversa, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/pt_BR.yml b/config/locales/pt_BR.yml index 85410e057..a102a2572 100644 --- a/config/locales/pt_BR.yml +++ b/config/locales/pt_BR.yml @@ -27,6 +27,9 @@ pt_BR: invalid_email: Você digitou um email inválido email_already_exists: "Você já se cadastrou para uma conta com %{email}" failed: Registro falhou + contacts: + import: + failed: File is blank reports: period: Reportando o período %{since} a %{until} agent_csv: @@ -34,6 +37,11 @@ pt_BR: conversations_count: Contagem de conversas avg_first_response_time: Tempo médio de primeira resposta (minutos) avg_resolution_time: Tempo médio de resolução (minutos) + team_csv: + team_name: Nome do departamento + conversations_count: Contagem de conversas + avg_first_response_time: Tempo médio de primeira resposta (minutos) + avg_resolution_time: Tempo médio de resolução (minutos) notifications: notification_title: conversation_creation: "[Nova conversa] - #%{display_id} foi criado em %{inbox_name}" @@ -73,6 +81,9 @@ pt_BR: transcript_subject: "Transcrição da conversa" survey: response: "Por favor, classifique esta conversa, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ro.yml b/config/locales/ro.yml index 7f882a3fe..aab105b76 100644 --- a/config/locales/ro.yml +++ b/config/locales/ro.yml @@ -27,6 +27,9 @@ ro: invalid_email: Ați introdus un e-mail invalid email_already_exists: "V-ați înregistrat deja cu un cont cu %{email}" failed: Înregistrare eșuată + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ ro: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ro: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 49c6f87ff..a41316924 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -27,6 +27,9 @@ ru: invalid_email: Вы ввели неверный email email_already_exists: "Вы уже зарегистрировались для учётной записи с %{email}" failed: Ошибка регистрации + contacts: + import: + failed: Пустой файл reports: period: Отчётный период с %{since} по %{until} agent_csv: @@ -34,6 +37,11 @@ ru: conversations_count: Количество бесед avg_first_response_time: Среднее время первого ответа (в минутах) avg_resolution_time: Среднее время решения (в минутах) + team_csv: + team_name: Название команды + conversations_count: Количество бесед + avg_first_response_time: Среднее время первого ответа (в минутах) + avg_resolution_time: Среднее время решения (в минутах) notifications: notification_title: conversation_creation: "[Новая беседа] - #%{display_id} была создана в %{inbox_name}" @@ -73,6 +81,9 @@ ru: transcript_subject: "Субтитры общения" survey: response: "Пожалуйста, оцените этот разговор, %{link}" + contacts: + online: + delete: "%{contact_name} в сети, повторите попытку позже" integration_apps: slack: name: "Slack" diff --git a/config/locales/sk.yml b/config/locales/sk.yml index cb722276c..678f3f717 100644 --- a/config/locales/sk.yml +++ b/config/locales/sk.yml @@ -27,6 +27,9 @@ sk: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ sk: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ sk: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 83f93f1c7..e50b55beb 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -27,6 +27,9 @@ sv: invalid_email: Du har angett en ogiltig e-postadress email_already_exists: "Du har redan registrerat dig för ett konto med %{email}" failed: Registrering misslyckades + contacts: + import: + failed: File is blank reports: period: Rapporteringsperiod %{since} till %{until} agent_csv: @@ -34,6 +37,11 @@ sv: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[Ny konversation] - #%{display_id} har skapats i %{inbox_name}" @@ -73,6 +81,9 @@ sv: transcript_subject: "Konversationstranskribering" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/ta.yml b/config/locales/ta.yml index cf7fdd8ef..0cded539f 100644 --- a/config/locales/ta.yml +++ b/config/locales/ta.yml @@ -27,6 +27,9 @@ ta: invalid_email: நீங்கள் தவறான ஈ-மெயிலை உள்ளிட்டுள்ளீர்கள் email_already_exists: "நீங்கள் ஏற்கனவே %{email} கொண்டு கணக்கிற்கு பதிவு செய்துள்ளீர்கள்" failed: உள்நுழையும் முயறிசி தோல்வி அடைந்துள்ளது + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ ta: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ ta: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/th.yml b/config/locales/th.yml index a660e8aaa..bfeaedc3a 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -27,6 +27,9 @@ th: invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" failed: Signup failed + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ th: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ th: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 41439702f..c041686ce 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -27,6 +27,9 @@ tr: invalid_email: Geçersiz bir e-posta girdiniz email_already_exists: "%{email} ile zaten bir hesaba kaydoldunuz" failed: Kayıt başarısız oldu + contacts: + import: + failed: File is blank reports: period: Raporlama aralığı %{since}'dan %{until}'a agent_csv: @@ -34,6 +37,11 @@ tr: conversations_count: Konuşma sayısı avg_first_response_time: Ortalama cevap süresi(dakika) avg_resolution_time: Ortalama çözüm üretme süresi(dakika) + team_csv: + team_name: Team name + conversations_count: Konuşma sayısı + avg_first_response_time: Ortalama cevap süresi(dakika) + avg_resolution_time: Ortalama çözüm üretme süresi(dakika) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ tr: transcript_subject: "Konuşma Metni" survey: response: "Lütfen konuşmayı oylayın, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/uk.yml b/config/locales/uk.yml index 8e9e8b304..63bb1b0e0 100644 --- a/config/locales/uk.yml +++ b/config/locales/uk.yml @@ -27,6 +27,9 @@ uk: invalid_email: Ви ввели неправильну адресу електронної пошти email_already_exists: "Ви вже зареєстровані з адресою %{email}" failed: Помилка реєстрації + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ uk: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ uk: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/vi.yml b/config/locales/vi.yml index 4226a0a5a..5574fd0f8 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -27,6 +27,9 @@ vi: invalid_email: Bạn đã nhập một email không hợp lệ email_already_exists: "Bạn đã đăng ký một tài khoản với %{email}" failed: Đăng ký thât bại + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ vi: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ vi: transcript_subject: "Bản ghi cuộc hội thoại" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/zh_CN.yml b/config/locales/zh_CN.yml index 4525dc4aa..c54b1f99d 100644 --- a/config/locales/zh_CN.yml +++ b/config/locales/zh_CN.yml @@ -27,6 +27,9 @@ zh_CN: invalid_email: 您输入了一个无效的电子邮件 email_already_exists: "您已经注册了 %{email} 的帐户" failed: 注册失败 + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ zh_CN: conversations_count: Conversations count avg_first_response_time: Avg first response time (Minutes) avg_resolution_time: Avg resolution time (Minutes) + team_csv: + team_name: Team name + conversations_count: Conversations count + avg_first_response_time: Avg first response time (Minutes) + avg_resolution_time: Avg resolution time (Minutes) notifications: notification_title: conversation_creation: "[New conversation] - #%{display_id} has been created in %{inbox_name}" @@ -73,6 +81,9 @@ zh_CN: transcript_subject: "Conversation Transcript" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/locales/zh_TW.yml b/config/locales/zh_TW.yml index 952839eb1..889269d2d 100644 --- a/config/locales/zh_TW.yml +++ b/config/locales/zh_TW.yml @@ -27,6 +27,9 @@ zh_TW: invalid_email: 您輸入的電子郵件無效。 email_already_exists: "您已經註冊了一個帳號%{email}" failed: 註冊失敗。 + contacts: + import: + failed: File is blank reports: period: Reporting period %{since} to %{until} agent_csv: @@ -34,6 +37,11 @@ zh_TW: conversations_count: 對話數量 avg_first_response_time: 平均第一次回覆時間(分鐘) avg_resolution_time: 平均解決時間(分鐘) + team_csv: + team_name: 團隊名稱 + conversations_count: 對話數量 + avg_first_response_time: 平均第一次回覆時間(分鐘) + avg_resolution_time: 平均解決時間(分鐘) notifications: notification_title: conversation_creation: "[新對話] - #%{display_id} 已經在 %{inbox_name} 中被建立" @@ -73,6 +81,9 @@ zh_TW: transcript_subject: "對話紀錄" survey: response: "Please rate this conversation, %{link}" + contacts: + online: + delete: "%{contact_name} is Online, please try again later" integration_apps: slack: name: "Slack" diff --git a/config/routes.rb b/config/routes.rb index d7b8cb276..3d629d2fd 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 @@ -72,10 +72,11 @@ Rails.application.routes.draw do post :toggle_status post :toggle_typing_status post :update_last_seen + post :custom_attributes end end - resources :contacts, only: [:index, :show, :update, :create] do + resources :contacts, only: [:index, :show, :update, :create, :destroy] do collection do get :active get :search @@ -158,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 @@ -187,6 +192,7 @@ Rails.application.routes.draw do get :agents get :inboxes get :labels + get :teams end end end @@ -249,6 +255,9 @@ 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' + post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload' + get 'webhooks/instagram', to: 'webhooks/instagram#verify' + post 'webhooks/instagram', to: 'webhooks/instagram#events' namespace :twitter do resource :callback, only: [:show] diff --git a/db/migrate/20210708140842_remove_notifications_without_primary_actor.rb b/db/migrate/20210708140842_remove_notifications_without_primary_actor.rb index 3d2583400..1c365bd69 100644 --- a/db/migrate/20210708140842_remove_notifications_without_primary_actor.rb +++ b/db/migrate/20210708140842_remove_notifications_without_primary_actor.rb @@ -2,7 +2,7 @@ class RemoveNotificationsWithoutPrimaryActor < ActiveRecord::Migration[6.0] def change deleted_ids = [] Notification.where(primary_actor_type: 'Conversation').pluck(:primary_actor_id).uniq.each_slice(1000) do |id_list| - deleted_ids << id_list - Conversation.where(id: id_list).pluck(:id) + deleted_ids << (id_list - Conversation.where(id: id_list).pluck(:id)) end Notification.where(primary_actor_type: 'Conversation', primary_actor_id: deleted_ids).destroy_all end 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/migrate/20210916060144_add_custom_attributes_to_conversations.rb b/db/migrate/20210916060144_add_custom_attributes_to_conversations.rb new file mode 100644 index 000000000..ef5f8cac1 --- /dev/null +++ b/db/migrate/20210916060144_add_custom_attributes_to_conversations.rb @@ -0,0 +1,5 @@ +class AddCustomAttributesToConversations < ActiveRecord::Migration[6.1] + def change + add_column :conversations, :custom_attributes, :jsonb, default: {} + end +end diff --git a/db/migrate/20210916112533_add_whatsapp_channel.rb b/db/migrate/20210916112533_add_whatsapp_channel.rb new file mode 100644 index 000000000..7e1f35951 --- /dev/null +++ b/db/migrate/20210916112533_add_whatsapp_channel.rb @@ -0,0 +1,11 @@ +class AddWhatsappChannel < ActiveRecord::Migration[6.1] + def change + create_table :channel_whatsapp do |t| + t.integer :account_id, null: false + t.string :phone_number, null: false, index: { unique: true } + t.string :provider, default: 'default' + t.jsonb :provider_config, default: {} + t.timestamps + end + end +end diff --git a/db/migrate/20210922082754_add_assignee_last_seen.rb b/db/migrate/20210922082754_add_assignee_last_seen.rb new file mode 100644 index 000000000..41ff17818 --- /dev/null +++ b/db/migrate/20210922082754_add_assignee_last_seen.rb @@ -0,0 +1,5 @@ +class AddAssigneeLastSeen < ActiveRecord::Migration[6.1] + def change + add_column :conversations, :assignee_last_seen_at, :datetime + end +end diff --git a/db/migrate/20210923132659_set_content_type_text_for_the_old_messages.rb b/db/migrate/20210923132659_set_content_type_text_for_the_old_messages.rb new file mode 100644 index 000000000..d9af6f19b --- /dev/null +++ b/db/migrate/20210923132659_set_content_type_text_for_the_old_messages.rb @@ -0,0 +1,5 @@ +class SetContentTypeTextForTheOldMessages < ActiveRecord::Migration[6.1] + def change + change_column_null(:messages, :content_type, false, 0) + end +end 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..00b2d4999 --- /dev/null +++ b/db/migrate/20210923190418_add_online_status_to_account_users.rb @@ -0,0 +1,18 @@ +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 + end + + # 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| + availability = user.availability + user.account_users.update(availability: availability) + end + end + end +end diff --git a/db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb b/db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb new file mode 100644 index 000000000..480f17d66 --- /dev/null +++ b/db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb @@ -0,0 +1,5 @@ +class AddTriggerOnlyDuringBusinessHoursCollectToCampaigns < ActiveRecord::Migration[6.1] + def change + add_column :campaigns, :trigger_only_during_business_hours, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 88af58036..9ad249a38 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_08_29_124254) do +ActiveRecord::Schema.define(version: 2021_09_29_150415) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -35,6 +35,8 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) 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" @@ -61,6 +63,14 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.index ["message_id", "message_checksum"], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true end + create_table "actions", force: :cascade do |t| + t.string "name", null: false + t.jsonb "execution_list", default: {}, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["name"], name: "index_actions_on_name" + end + create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -137,6 +147,7 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.integer "campaign_status", default: 0, null: false t.jsonb "audience", default: [] t.datetime "scheduled_at" + t.boolean "trigger_only_during_business_hours", default: false t.index ["account_id"], name: "index_campaigns_on_account_id" t.index ["campaign_status"], name: "index_campaigns_on_campaign_status" t.index ["campaign_type"], name: "index_campaigns_on_campaign_type" @@ -181,6 +192,7 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) 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 +256,16 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.index ["website_token"], name: "index_channel_web_widgets_on_website_token", unique: true end + create_table "channel_whatsapp", force: :cascade do |t| + t.integer "account_id", null: false + t.string "phone_number", null: false + t.string "provider", default: "default" + t.jsonb "provider_config", default: {} + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true + end + create_table "contact_inboxes", force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" @@ -289,12 +311,14 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.datetime "agent_last_seen_at" t.jsonb "additional_attributes", default: {} t.bigint "contact_inbox_id" - t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false t.string "identifier" t.datetime "last_activity_at", default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" t.bigint "campaign_id" t.datetime "snoozed_until" + t.jsonb "custom_attributes", default: {} + t.datetime "assignee_last_seen_at" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id"], name: "index_conversations_on_account_id" t.index ["assignee_id", "account_id"], name: "index_conversations_on_assignee_id_and_account_id" @@ -506,7 +530,7 @@ ActiveRecord::Schema.define(version: 2021_08_29_124254) do t.boolean "private", default: false t.integer "status", default: 0 t.string "source_id" - t.integer "content_type", default: 0 + t.integer "content_type", default: 0, null: false t.json "content_attributes", default: {} t.string "sender_type" t.bigint "sender_id" diff --git a/docker/Dockerfile b/docker/Dockerfile index 7b595a111..e51a86379 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,6 +33,11 @@ WORKDIR /app COPY Gemfile Gemfile.lock ./ +# natively compile grpc and protobuf to support alpine musl (dialogflow-docker workflow) +# https://github.com/googleapis/google-cloud-ruby/issues/13306 +RUN apk add --no-cache musl ruby-full ruby-dev gcc make musl-dev openssl openssl-dev g++ linux-headers +RUN bundle config set --local force_ruby_platform true + # Do not install development or test gems in production RUN if [ "$RAILS_ENV" = "production" ]; then \ bundle config set without 'development test'; bundle install -j 4 -r 3; \ @@ -71,6 +76,9 @@ ENV EXECJS_RUNTIME ${EXECJS_RUNTIME} ARG RAILS_SERVE_STATIC_FILES=true ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES} +ARG BUNDLE_FORCE_RUBY_PLATFORM=1 +ENV BUNDLE_FORCE_RUBY_PLATFORM ${BUNDLE_FORCE_RUBY_PLATFORM} + ARG RAILS_ENV=production ENV RAILS_ENV ${RAILS_ENV} ENV BUNDLE_PATH="/gems" diff --git a/docker/entrypoints/rails.sh b/docker/entrypoints/rails.sh index 8bdf0551c..77657f6ee 100755 --- a/docker/entrypoints/rails.sh +++ b/docker/entrypoints/rails.sh @@ -20,6 +20,7 @@ done echo "Database ready to accept connections." +#install missing gems for local dev as we are using base image compiled for production bundle install BUNDLE="bundle check" diff --git a/lib/events/types.rb b/lib/events/types.rb index c47691cd2..9c0f04fce 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -35,6 +35,7 @@ module Events::Types CONTACT_CREATED = 'contact.created' CONTACT_UPDATED = 'contact.updated' CONTACT_MERGED = 'contact.merged' + CONTACT_DELETED = 'contact.deleted' # agent events AGENT_ADDED = 'agent.added' diff --git a/lib/integrations/dialogflow/processor_service.rb b/lib/integrations/dialogflow/processor_service.rb index 66911313f..bb5485296 100644 --- a/lib/integrations/dialogflow/processor_service.rb +++ b/lib/integrations/dialogflow/processor_service.rb @@ -18,7 +18,7 @@ class Integrations::Dialogflow::ProcessorService # TODO: might needs to change this to a way that we fetch the updated value from event data instead # cause the message.updated event could be that that the message was deleted - return message.content_attributes['submitted_values']&.dig 'value' if event_name == 'message.updated' + return message.content_attributes['submitted_values']&.first&.dig('value') if event_name == 'message.updated' message.content end diff --git a/lib/integrations/slack/incoming_message_builder.rb b/lib/integrations/slack/incoming_message_builder.rb index 93f23d3a4..e655eef45 100644 --- a/lib/integrations/slack/incoming_message_builder.rb +++ b/lib/integrations/slack/incoming_message_builder.rb @@ -79,7 +79,7 @@ class Integrations::Slack::IncomingMessageBuilder def create_message return unless conversation - conversation.messages.create( + @message = conversation.messages.create( message_type: :outgoing, account_id: conversation.account_id, inbox_id: conversation.inbox_id, @@ -89,10 +89,46 @@ class Integrations::Slack::IncomingMessageBuilder sender: sender ) + process_attachments(params[:event][:files]) if params[:event][:files].present? + { status: 'success' } end def slack_client @slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token) end + + # TODO: move process attachment for facebook instagram and slack in one place + # https://api.slack.com/messaging/files + def process_attachments(attachments) + attachments.each do |attachment| + tempfile = Down::NetHttp.download(attachment[:url_private], headers: { 'Authorization' => "Bearer #{integration_hook.access_token}" }) + + attachment_params = { + file_type: file_type(attachment), + account_id: @message.account_id, + external_url: attachment[:url_private], + file: { + io: tempfile, + filename: tempfile.original_filename, + content_type: tempfile.content_type + } + } + + attachment_obj = @message.attachments.new(attachment_params) + attachment_obj.file.content_type = attachment[:mimetype] + attachment_obj.save! + end + end + + def file_type(attachment) + return if attachment[:mimetype] == 'text/plain' + + case attachment[:filetype] + when 'png', 'jpeg', 'gif', 'bmp', 'tiff', 'jpg' + :image + when 'pdf' + :file + end + end end diff --git a/lib/integrations/slack/send_on_slack_service.rb b/lib/integrations/slack/send_on_slack_service.rb index ba3e68669..5d8b52e24 100644 --- a/lib/integrations/slack/send_on_slack_service.rb +++ b/lib/integrations/slack/send_on_slack_service.rb @@ -45,8 +45,15 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService end def send_message - return if message_content.blank? + post_message if message_content.present? + upload_file if message.attachments.any? + rescue Slack::Web::Api::Errors::AccountInactive => e + Rails.logger.info e + hook.authorization_error! + hook.disable if hook.enabled? + end + def post_message @slack_message = slack_client.chat_postMessage( channel: hook.reference_id, text: message_content, @@ -54,10 +61,28 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService thread_ts: conversation.identifier, icon_url: avatar_url(message.sender) ) - rescue Slack::Web::Api::Errors::AccountInactive => e - Rails.logger.info e - hook.authorization_error! - hook.disable if hook.enabled? + end + + def upload_file + result = slack_client.files_upload({ + channels: hook.reference_id, + initial_comment: 'Attached File!', + thread_ts: conversation.identifier + }.merge(file_information)) + Rails.logger.info(result) + end + + def file_type + File.extname(message.attachments.first.file_url).strip.downcase[1..] + end + + def file_information + { + filename: message.attachments.first.file.filename, + filetype: file_type, + content: message.attachments.first.file.download, + title: message.attachments.first.file.filename + } end def sender_name(sender) 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/package.json b/package.json index 50072edb5..59d534ee8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "1.20.0", + "version": "1.21.0", "license": "MIT", "scripts": { "eslint": "eslint app/javascript --fix", diff --git a/public/downloads/import-contacts-sample.csv b/public/downloads/import-contacts-sample.csv new file mode 100644 index 000000000..a11edbc07 --- /dev/null +++ b/public/downloads/import-contacts-sample.csv @@ -0,0 +1,26 @@ +id,name,email,identifier,phone_number,ip_address,custom_attribute_1,custom_attribute_2 +1,Clarice Uzzell,cuzzell0@mozilla.org,bb4e11cd-0f23-49da-a123-dcc1fec6852c,+498963648018,70.61.11.201,Random-value-1,Random-value-1 +2,Marieann Creegan,mcreegan1@cornell.edu,e60bab4c-9fbb-47eb-8f75-42025b789c47,+15417543010,168.186.4.241,Random-value0,Random-value0 +3,Nancey Windibank,nwindibank2@bluehost.com,f793e813-4210-4bf3-a812-711418de25d2,+15417543011,73.44.41.59,Random-value1,Random-value1 +4,Sibel Stennine,sstennine3@yellowbook.com,d6e35a2d-d093-4437-a577-7df76316b937,+15417543011,115.249.27.155,Random-value2,Random-value2 +5,Tina O'Lunney,tolunney4@si.edu,3540d40a-5567-4f28-af98-5583a7ddbc56,+15417543011,219.181.212.8,Random-value3,Random-value3 +6,Quinn Neve,qneve5@army.mil,ba0e1bf0-c74b-41ce-8a2d-0b08fa0e5aa5,+15417543011,231.210.115.166,Random-value4,Random-value4 +7,Karylin Gaunson,kgaunson6@tripod.com,d24cac79-c81b-4b84-a33e-0441b7c6a981,+15417543011,160.189.41.11,Random-value5,Random-value5 +8,Jamison Shenton,jshenton7@upenn.edu,29a7a8c0-c7f7-4af9-852f-761b1a784a7a,+15417543011,53.94.18.201,Random-value6,Random-value6 +9,Gavan Threlfall,gthrelfall8@spotify.com,847d4943-ddb5-47cc-8008-ed5092c675c5,+15417543011,18.87.247.249,Random-value7,Random-value7 +10,Katina Hemmingway,khemmingway9@ameblo.jp,8f0b5efd-b6a8-4f1e-a1e3-b0ea8c9e3048,+15417543011,25.191.96.124,Random-value8,Random-value8 +11,Jillian Deinhard,jdeinharda@canalblog.com,bd952787-1b05-411f-9975-b916ec0950cc,+15417543011,11.211.174.93,Random-value9,Random-value9 +12,Blake Finden,bfindenb@wsj.com,12c95613-e49d-4fa2-86fb-deabb6ebe600,+15417543011,47.26.205.153,Random-value10,Random-value10 +13,Liane Maxworthy,lmaxworthyc@un.org,36b68e4c-40d6-4e09-bf59-7db3b27b18f0,+15417543011,157.196.34.166,Random-value11,Random-value11 +14,Martynne Ledley,mledleyd@sourceforge.net,1856bceb-cb36-415c-8ffc-0527f3f750d8,+15417543011,109.231.152.148,Random-value12,Random-value12 +15,Katharina Ruffli,krufflie@huffingtonpost.com,604de5c9-b154-4279-8978-41fb71f0f773,+15417543011,20.43.146.179,Random-value13,Random-value13 +16,Tucker Simmance,tsimmancef@bbc.co.uk,0a8fc3a7-4986-4a51-a503-6c7f974c90ad,+15417543011,179.76.226.171,Random-value14,Random-value14 +17,Wenona Martinson,wmartinsong@census.gov,0e5ea6e3-6824-4e78-a6f5-672847eafa17,+15417543011,92.243.194.160,Random-value15,Random-value15 +18,Gretna Vedyasov,gvedyasovh@lycos.com,6becf55b-a7b5-48f6-8788-b89cae85b066,+15417543011,25.22.86.101,Random-value16,Random-value16 +19,Lurline Abdon,labdoni@archive.org,afa9429f-9034-4b06-9efa-980e01906ebf,+15417543011,150.249.116.118,Random-value17,Random-value17 +20,Fiann Norcliff,fnorcliffj@istockphoto.com,59f72dec-14ba-4d6e-b17c-0d962e69ffac,+15417543011,237.167.197.197,Random-value18,Random-value18 +21,Zed Linn,zlinnk@phoca.cz,95f7bc56-be92-4c9c-ad58-eff3e63c7bea,+15417543011,88.102.64.113,Random-value19,Random-value19 +22,Averyl Simyson,asimysonl@livejournal.com,bde1fe59-c9bd-440c-bb39-79fe61dac1d1,+15417543011,141.248.89.29,Random-value20,Random-value20 +23,Camella Blackadder,cblackadderm@nifty.com,0c981752-5857-487c-b9b5-5d0253df740a,+15417543011,118.123.138.115,Random-value21,Random-value21 +24,Aurie Spatig,aspatign@printfriendly.com,4cf22bfb-2c3f-41d1-9993-6e3758e457ba,+15417543011,157.45.102.235,Random-value22,Random-value22 +25,Adrienne Bellard,abellardo@cnn.com,f10f9b8d-38ac-4e17-8a7d-d2e6a055f944,+15417543011,170.73.198.47,Random-value23,Random-value23 \ No newline at end of file diff --git a/public/integrations/channels/badges/instagram-dm.png b/public/integrations/channels/badges/instagram-dm.png new file mode 100644 index 000000000..185a92f45 Binary files /dev/null and b/public/integrations/channels/badges/instagram-dm.png differ diff --git a/public/integrations/channels/badges/line.png b/public/integrations/channels/badges/line.png new file mode 100644 index 000000000..1beeb4eb3 Binary files /dev/null and b/public/integrations/channels/badges/line.png differ diff --git a/public/integrations/channels/badges/messenger.png b/public/integrations/channels/badges/messenger.png new file mode 100644 index 000000000..4bddb68d8 Binary files /dev/null and b/public/integrations/channels/badges/messenger.png differ diff --git a/public/integrations/channels/badges/sms.png b/public/integrations/channels/badges/sms.png new file mode 100644 index 000000000..ac3fb56d2 Binary files /dev/null and b/public/integrations/channels/badges/sms.png differ diff --git a/public/integrations/channels/badges/telegram.png b/public/integrations/channels/badges/telegram.png new file mode 100644 index 000000000..511430872 Binary files /dev/null and b/public/integrations/channels/badges/telegram.png differ diff --git a/public/integrations/channels/badges/twitter-dm.png b/public/integrations/channels/badges/twitter-dm.png new file mode 100644 index 000000000..980115fbb Binary files /dev/null and b/public/integrations/channels/badges/twitter-dm.png differ diff --git a/public/integrations/channels/badges/twitter-tweet.png b/public/integrations/channels/badges/twitter-tweet.png new file mode 100644 index 000000000..7af6a02d9 Binary files /dev/null and b/public/integrations/channels/badges/twitter-tweet.png differ diff --git a/public/integrations/channels/badges/whatsapp.png b/public/integrations/channels/badges/whatsapp.png new file mode 100644 index 000000000..589fc7a30 Binary files /dev/null and b/public/integrations/channels/badges/whatsapp.png differ diff --git a/spec/assets/attachment.pdf b/spec/assets/attachment.pdf new file mode 100644 index 000000000..e69de29bb diff --git a/spec/assets/sample.mp4 b/spec/assets/sample.mp4 new file mode 100644 index 000000000..a203d0cdf Binary files /dev/null and b/spec/assets/sample.mp4 differ 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/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 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/accounts/contacts_controller_spec.rb b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb index 4b6bbf6db..13d2649f3 100644 --- a/spec/controllers/api/v1/accounts/contacts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/contacts_controller_spec.rb @@ -105,6 +105,20 @@ RSpec.describe 'Contacts API', type: :request do expect(account.data_imports.first.import_file.attached?).to eq(true) end end + + context 'when file is empty' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'returns Unprocessable Entity' do + post "/api/v1/accounts/#{account.id}/contacts/import", + headers: admin.create_new_auth_token + + json_response = JSON.parse(response.body) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json_response['error']).to eq('File is blank') + end + end end describe 'GET /api/v1/accounts/{account.id}/contacts/active' do @@ -362,4 +376,53 @@ RSpec.describe 'Contacts API', type: :request do end end end + + describe 'DELETE /api/v1/accounts/{account.id}/contacts/:id', :contact_delete do + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + it 'deletes the contact for administrator user' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(false) + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(contact.conversations).to be_empty + expect(contact.inboxes).to be_empty + expect(contact.contact_inboxes).to be_empty + expect(contact.csat_survey_responses).to be_empty + expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(response).to have_http_status(:success) + end + + it 'does not delete the contact if online' do + allow(::OnlineStatusTracker).to receive(:get_presence).and_return(true) + + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns unauthorized for agent user' do + delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index d32608ba2..605e75b56 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -35,7 +35,7 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(conversation.messages.first.content).to eq(params[:content]) end - it 'creates an outgoing message with a specific bot sender' do + it 'creates an outgoing text message with a specific bot sender' do agent_bot = create(:agent_bot) time_stamp = Time.now.utc.to_s params = { content: 'test-message', external_created_at: time_stamp, sender_type: 'AgentBot', sender_id: agent_bot.id } @@ -50,6 +50,7 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(response_data['content_attributes']['external_created_at']).to eq time_stamp expect(conversation.messages.count).to eq(1) expect(conversation.messages.last.sender_id).to eq(agent_bot.id) + expect(conversation.messages.last.content_type).to eq('text') end it 'creates a new outgoing message with attachment' do diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 7b66656c2..025932175 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -33,7 +33,7 @@ RSpec.describe 'Conversations API', type: :request do expect(body[:data][:payload].first[:messages].first[:id]).to eq(message.id) end - it 'returns conversations with empty messages array for conversations with out messages ' do + it 'returns conversations with empty messages array for conversations with out messages' do get "/api/v1/accounts/#{account.id}/conversations", headers: agent.create_new_auth_token, as: :json @@ -170,6 +170,7 @@ RSpec.describe 'Conversations API', type: :request do context 'when it is an authenticated user' do let(:agent) { create(:user, account: account, role: :agent) } + let(:team) { create(:team, account: account) } it 'will not create a new conversation if agent does not have access to inbox' do allow(Rails.configuration.dispatcher).to receive(:dispatch) @@ -250,6 +251,19 @@ RSpec.describe 'Conversations API', type: :request do params: { contact_id: contact.id, inbox_id: inbox.id }, as: :json end + + it 'creates a new conversation with assignee and team' do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + post "/api/v1/accounts/#{account.id}/conversations", + headers: agent.create_new_auth_token, + params: { source_id: contact_inbox.source_id, contact_id: contact.id, inbox_id: inbox.id, assignee_id: agent.id, team_id: team.id }, + as: :json + + expect(response).to have_http_status(:success) + response_data = JSON.parse(response.body, symbolize_names: true) + expect(response_data[:meta][:assignee][:name]).to eq(agent.name) + expect(response_data[:meta][:team][:name]).to eq(team.name) + end end end end @@ -393,6 +407,19 @@ RSpec.describe 'Conversations API', type: :request do expect(response).to have_http_status(:success) expect(conversation.reload.agent_last_seen_at).not_to eq nil end + + it 'updates assignee last seen' do + conversation.update!(assignee_id: agent.id) + + expect(conversation.reload.assignee_last_seen_at).to eq nil + + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.assignee_last_seen_at).not_to eq nil + end end end @@ -496,4 +523,37 @@ RSpec.describe 'Conversations API', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/conversations/:id/custom_attributes' do + let(:conversation) { create(:conversation, account: account) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:agent) { create(:user, account: account, role: :agent) } + let(:custom_attributes) { { user_id: 1001, created_date: '23/12/2012', subscription_id: 12 } } + let(:valid_params) { { custom_attributes: custom_attributes } } + + before do + create(:inbox_member, user: agent, inbox: conversation.inbox) + end + + it 'updates last seen' do + post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes", + headers: agent.create_new_auth_token, + params: valid_params, + as: :json + + expect(response).to have_http_status(:success) + expect(conversation.reload.custom_attributes).not_to eq nil + expect(conversation.reload.custom_attributes.count).to eq 3 + end + end + end end diff --git a/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb b/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb index 3946f20c5..526879099 100644 --- a/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/csat_survey_responses_controller_spec.rb @@ -47,6 +47,17 @@ RSpec.describe 'CSAT Survey Responses API', type: :request do expect(response_data.pluck('id')).to include(csat_3_days_ago.id) expect(response_data.pluck('id')).not_to include(csat_10_days_ago.id) end + + it 'returns csat responses even if the agent is deleted from account' do + deleted_agent_csat = create(:csat_survey_response, account: account, assigned_agent: agent) + deleted_agent_csat.assigned_agent.account_users.destroy_all + + get "/api/v1/accounts/#{account.id}/csat_survey_responses", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + 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/spec/controllers/api/v2/accounts/report_controller_spec.rb b/spec/controllers/api/v2/accounts/report_controller_spec.rb index 201c53f16..123119fb5 100644 --- a/spec/controllers/api/v2/accounts/report_controller_spec.rb +++ b/spec/controllers/api/v2/accounts/report_controller_spec.rb @@ -192,4 +192,37 @@ RSpec.describe 'Reports API', type: :request do end end end + + describe 'GET /api/v2/accounts/:account_id/reports/teams' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + params = { + since: 30.days.ago.to_i.to_s, + until: Time.zone.today.to_time.to_i.to_s + } + + it 'returns unauthorized for teams' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv", + params: params, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns summary' do + get "/api/v2/accounts/#{account.id}/reports/teams.csv", + params: params, + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:success) + end + end + end end diff --git a/spec/controllers/installation/onboarding_controller_spec.rb b/spec/controllers/installation/onboarding_controller_spec.rb index 7b1416616..aeeb667a4 100644 --- a/spec/controllers/installation/onboarding_controller_spec.rb +++ b/spec/controllers/installation/onboarding_controller_spec.rb @@ -54,7 +54,7 @@ RSpec.describe 'Installation::Onboarding API', type: :request do end context 'when onboarding is not successfull' do - it ' does not deletes the redis key' do + it 'does not deletes the redis key' do allow(AccountBuilder).to receive(:new).and_raise('error') post '/installation/onboarding', params: { user: {} } expect(::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)).not_to eq nil diff --git a/spec/controllers/webhooks/instagram_controller_spec.rb b/spec/controllers/webhooks/instagram_controller_spec.rb new file mode 100644 index 000000000..fce701587 --- /dev/null +++ b/spec/controllers/webhooks/instagram_controller_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe 'Webhooks::InstagramController', type: :request do + describe 'POST /webhooks/instagram' do + let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access } + + it 'call the instagram events job with the params' do + allow(::Webhooks::InstagramEventsJob).to receive(:perform_later) + expect(::Webhooks::InstagramEventsJob).to receive(:perform_later) + + instagram_params = dm_params.merge(object: 'instagram') + post '/webhooks/instagram', params: instagram_params + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb new file mode 100644 index 000000000..bb6947862 --- /dev/null +++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe 'Webhooks::WhatsappController', type: :request do + describe 'POST /webhooks/whatsapp/{:phone_number}' do + it 'call the whatsapp events job with the params' do + allow(Webhooks::WhatsappEventsJob).to receive(:perform_later) + expect(Webhooks::WhatsappEventsJob).to receive(:perform_later) + post '/webhooks/whatsapp/123221321', params: { content: 'hello' } + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb new file mode 100644 index 000000000..ea8b563dc --- /dev/null +++ b/spec/factories/channel/channel_whatsapp.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :channel_whatsapp, class: 'Channel::Whatsapp' do + sequence(:phone_number) { |n| "+123456789#{n}1" } + account + provider_config { { 'api_key' => 'test_key' } } + + after(:create) do |channel_whatsapp| + create(:inbox, channel: channel_whatsapp, account: channel_whatsapp.account) + 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/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb index 82a479837..67b526b64 100644 --- a/spec/finders/conversation_finder_spec.rb +++ b/spec/finders/conversation_finder_spec.rb @@ -48,6 +48,15 @@ describe ::ConversationFinder do end end + context 'with status all' do + let(:params) { { status: 'all' } } + + it 'returns all conversations' do + result = conversation_finder.perform + expect(result[:conversations].length).to be 5 + end + end + context 'with assignee_type assigned' do let(:params) { { assignee_type: 'assigned' } } diff --git a/spec/fixtures/files/reply_mail_without_uuid.eml b/spec/fixtures/files/reply_mail_without_uuid.eml new file mode 100644 index 000000000..e2a5e2378 --- /dev/null +++ b/spec/fixtures/files/reply_mail_without_uuid.eml @@ -0,0 +1,631 @@ +From: Sony Mathew +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 20 Apr 2020 04:20:20 -0400 +In-Reply-To: +To: "Replies" +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@chatwoot.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Let's talk about these images: + + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1" + + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar1.jpeg +Content-Type: image/jpg; + name="avatar1.jpeg" +Content-Id: <7AAEB353-2341-4D46-A054-5CA5CB2363B7> + +/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAICAgIC +AQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0ODg4OCQsQEQ8OEQ0O +Dg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg7/wAARCADwAPADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAEC +AwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx +wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1 +dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ +2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QA +tREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk +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 +mk8VWW5WRGJGAOaAP//Z + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar2.jpg +Content-Type: image/jpg; + x-unix-mode=0700; + name="avatar2.jpg" +Content-Id: <4594E827-6E69-4329-8691-6BC35E3E73A0> + +/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/b +AIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgIC +AwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD +AwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAwwDDAwERAAIRAQMRAf/EAJ8AAQABBAMBAQEAAAAAAAAA +AAAKBAUJCwYHCAMBAgEBAAAAAAAAAAAAAAAAAAAAABAAAAUDAgMDBgcIDQkDDQAAAQIEBQYAAwcR +CCESCTEVCkFRIhMUFvBhcYGhJRfRMiMkNEQ1RZGxweFCUpIzZGV1JhhyslRVNkZWZhliooXSQ3SE +tJWltbYnN0c4EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCZBQKBQKBQKBQKBQKBQKBQ +KBQKBQKBQKBQKBQKBQKBQKBQKBQKBQWx5emeOta98f3VuZGVrS3lrk7O61M3NrejTkNdvqlq5Xcs +pkqezbKJjHOYpSgGojQYANyviVenfgaW3oTEHKb7gHhArXoXlxxc0JvdZsUodbY27cikSppSvZb6 +gOUl1AVTYEAE3rNNOYMEm4TxW26yTqpg2bdMRYvx3GHATJonJZOgdJXPmVPy8ntt21edrcRurrg+ +kBbqG/btiOmhu2g8CSnxE3VNmDYytxM6oowtaAL65dFoZF2pY8GAnKJnQxGw1u+U33wgBShrQdPM +vW+6qLGtfVybeFkxZdflHtN609Xmt8RN1wAAoEY0DsgWImVPoH80mt27Yjx5deNB3rFvEh9WCOub +WsX5yi8sQN9m2nUMsgxLjT2N0t2xKInXLGqMtjv7ScpdBuW1JDDqIjxoM1u1HxW7M+SWMRbd7g5F +D2NcYqF9yhixwXuqdpumJpad1sJcvWrrqAL2nryJFNy6Qgie3bOIBbEJRe27ebtc3dxoZXtyzXCc +otdq4FlZYZXC4kfG2+Jeb1DtGXiw3SJru6dgX0tvXThrQenKBQKBQKBQKBQKBQKBQKBQKBQYSeph +1ytsXT0FTA0xbmac93UN28mxxE3JIRujl09u57JencgAb5Ga3cuFD8XtW7yoxe0pO2gg0b9+rZvP +6i6qylyO5+7GLmFVfVt2OscJHZsiSQ94BtlUPd4VKpS9LCWB5fWKrpihx5Sl10oMSIqR1HUR1146 +9uvl19HtoK781Q/2n+5QUH5z8PPQV3/tnw/71BXJvxT6fi8/Z2UHJ4tFJZkSTs8Lg0ed5XK5AttN +zKwMSG+4ujktvmALSZGkTW7l67cMPmCg9o5B2cdQzY9djuRJlhzPWCbqv1LixS1K1SNi5DEL623c +7ybAKWwchR15bhg7eIUEifpY+JVkTE+x/BHUDXGd4/fBIzR/PlhKPfTSpADWbBMhI7YFBajOYCBc +Xk1ukH0jEENaCbFFpVG5vHmiWRF7bZHG35Cncmd6aFVpa3uCFVaLesKE6i0YxTFPbOHAdDFHgIAI +CFBf6BQKBQKBQKBQKBQKBQKCMv11OtNY2gs7ptZ24OFh23JylmEsjfkVwiqzixmcieqLqWyJ+aVr +rVz8DZH0rIDzCADpoGPboreHMkW9Qj5u96liTIzRAZUrtOcAx+rdlDJN8sqV4FWLZpLHS7656aIo +BTltprRQsqlxhMYp7dq2UboTacK9NvY9t+xPewpjHbfjVqx8sSqEjo2OjEnka55tqiGIoO7vT8Dg +7OF64U2nMe8IgHZppQQQfEJ+H6S7L7DtvK2nWHl329v8l5cjY6BECxVhlwergijcm5SjtgZRAlq8 +RslNdIUyC7ct2zmOU5TAESX8z+HnoKJL2j8v7g0H5w/JPj+P4a0BUq7ePw+nz0E9jwj/AE2Udxsm +fUVyrFrV4664rx7t6tvCIpwt2kl4PfOeN1tQQQAwqLZG5IoL2CRTyj5aCdEub0DolvIXNCjcUSm1 +csKEa5NZVpb9m6USXbN5OoJctXbVwg6GKYBAQ4DQRr+rr4dDbZvVgshyTtfhUK2/bqmlOpdWVfGW +61GoBkdXbIa8ZjmDA02SNTcvcTl5bTmmTkulum1vhcLxKEYDpCdRrOnTO3XOWwPeXdeI5i1NNluP +npvl9+6f7IJYF8bCBzQq7pzFtRZUpOT1glEbHs90t4no+kAT+kaxI4pEy9AqTrUK2xaVI1iS9bUJ +lSa+QtyyoT37RjW71m7bMBimKIgIDqFBU0CgUCgUCgUCgUCgUGKzq79Q9i6eO1aQTpCpRKswTYiu +KYejd2+T2pZIr6fS++GSAB791BHbN4t+5oXQTCUuvGgjLeHG6cl/qPbu8m75N2CZVkPHWGpOkfzp +5OQ65Bk/OUgv3XZsSuYKRPacGKHI7IrlScdSGunR2TlNaOctBsj7Fiyms2k6azaTp7FolmxYsWyW +rNmzaKBLVq1atgUlu1bIUAKUAAAANAoPrQWeQR5hljI6xmUMzXIo6+oFLW9MT2gTObQ7Nqy0awrQ +OLestXkqxIpsnEp7dwpimAdBCghudSfwl2NcuSCUZd2Fzdpw9InY6h6XYKmdpXexyudLXOptJYZI +khVLjEbKm9qS2lUWVKW2JgALlq2GgBHxhPhiOpE/7as/Zwk0eYsbTnFze9OuOtvjqrTOmSM0I4+7 +LrMqWoAQqLzbErZGtEYGg1s4g/Dy+QxRMGFHG+2LPGYXlHHscYPyVM3pfbC8ibmaGP70pv2DGKUL +5CIUN7WwJhD0/vfjoJaPTU8JZknIl6N5R6g0hT46xw4NKd3SYThjlfPlRdfUjbuWEcwcrjeLNEk/ +sxhNct2rqtYU/oHt2h1Ggnu4exFjvAmL4LhrE0ZQQ7HOOI22xSIxxtJyJm1na7BbFggnHW4pVXhA +bl+9cE12/eOa4cROYREOyaBQa+XxgGxhDCMr4i34wtuOmTZbtWsW5XOmKIWPfOKNhbkQfLnIGltS +7RhMZKc3DmFvKP3wjqF48Md1K5PMweNjebphfdL7SzLJHgd1kLlcV3lSBtU2SOsGa3NUIivsJm+9 +7Witc4mLas3ilDQtBMqoFAoFAoFAoFAoFB8VCiwksXlSq/ZTJk9s96+oUXCWbFizbKJ7l29duGLb +t27ZQETGMIAABxoNX/1p98jjvl3mzt7aXE9/E2LVazHmLURD2DprjWzq7tpzkIDZExDqH9eUTibm +H8Fbth5KDYR+HYwy14e6SO1m4lb7KN6ykySDLUpv27RCXXF1l0lde71ag5fSunLGkKG2UREdCkAA +4aUGbugCADwENQ8w8aBQfO7cC1auXRLcOFsh7gktWzXbpgIUTCW3bIBj3DmANAKACIjwCgpCAS/e +IIDp6kx7ptPKY2gAA/JQfidpa0hiHStremNaKYtsydGnsmtlOYTnKQbdsokKc5hEQDtEdaC4UCgU +Cgj5+J3xEqyp0kM0OSBDaXK8US7HGTDgYgGvWG5FI7MZdFCc2giQydJJxunEP/NkNrQau7EGQ5Ji +nJkVn8QdlrFJog+tr+yu7ddMnWIVzcqtqLF+xdIICUwDb0EOwQHQeA0G2w2gbi4dus254tzhCXhK +8t8vjLed1OmuWznb5OjTWk0jaVZLf8wqQuhLgCQQAeUSm00EKD0rQKBQKBQKBQKBQYlut1ubXbWO +nTnCZMTiLZLJkhR4tiiq2cbaiw6zk9xsNfTXwOT2W/aRet5Lo6gS4YvDUQoNW46PrQivpk61ztGW +rroXVgCcTnIa+fUx1BilMW2c4mEePy0G622Iw+O4/wBku0OFRK0isxuM7aMHtLOVuuWryE6NNjaN +lKoTX7BSWb9tUYRu+sKABcE/MAcaD1dQKBQBDUBAfLw830hxoLQf1SC6UeNmxasHualtmPZspLHK +a9bEpREbYgI6lEA8/wAlBdSmKcpTFEDFMAGKIdggYAEB+cBoP4vXSWLZrlwwFKXzjpqI9gB8YjQW +2y4GG4BLhP54/wCD5jgQxfSIF21y6CTnT+tKUQ5uY5y3ADiUAMF3oFB4Z6m8KbMh9O/exEni0N9v +cdsmY1N4gEC4cDssId3xNdtkEBAbthU2kOT/ALRQoNL4nD2NYIeYdO3X0QEQ7aDYIeFmyndlW0TM ++MxMa8kxpldsdm9SJtS+zz6NlOdKUv8AB9Qoi57g+f11BKBoFAoFAoFAoFAoIpfi152WMbI8KR0i +w1hVKc33r9pKUwgC20xxFyC6BygIc9uxdd7RhDjoIgPkoNcua4e5cG4cTHOY/MYREREwiP7NBvBe +l0uXOXTd2IrHJEsbV9zaXgQipA4Wrllakup8ax1ONhTZugW5bu2wtAAgIBpQe76AIgACIjoAcREe +AAAdoiNB8Qv2xONvX0y66lABHQOYQKOoAIemAagHaNB9dQ05tQ5dNddQ008+vZpQWNUrTXb3qzcp +xMnulKU9/wBnT3Ut/kC8e5d04h+D4AHk184UHztPyG1rbOJilAR5TAUTfMIAGutBa3dxBbb5bHMU +SCPIUeHMP8YQ837lB/aVVoOqvUBDyhpw8w0HJUd8bpClObU/IFwoiUSH9UcTerLeIIiJVFsoAFzy +c3Zp2AFbQeVN9hil2R7wzHHlKG13PomNygflKGKpXqPIPA+geTy0GlBWffD8o/51BNM8JS9ONtZv +Djw3rFtoVo8av1lvIP4a2tTukyRmv3g17RtLhL2UE0WgUCgUCgUCgUCgh/8AjCGlMp2p7Wng6stt +W2ZolSROiHTnVWnOKoTqb5fLokFvIA+T8JQQcNnWIVGfd2G2/CiYnObKObcaQq9+CG+W2ifpc1IX +G+eyACNy2nQXbhzBpxKUaDelxliQReOMEaa09lI2x9mbGVAmT2rdiwnRtiOyjT2bNm0Utu1bt2rI +ABSgAAHZQVaxWFg4FtiQ14SGONsboENyWiX7xdAMUwaXj2BII9oBqIdlBVCYR7f2KC3UFOqt3QKJ +AuH5f4uo6fHwAaC2+y+16ir7POH08KCt7rS+cP2A+7QPZE3mH6fuUH77P/Qfh/JoKyg/U14SXA5j +Dym4DqIjp5vpoOJ5ax/H8uYryTiiUnvkjWUIHLsdSAyQRBWVmmzEvi7mKbTiCgErocCeY+lBpZd2 +m3SZbUdy2cds02Xd9SHBeUJjClr4Hpd7g0iItEv11/3gYKCVR4R9vtlf95DlYDntCz4vT+u/j21S +6Sr0oecdCGPQTXaBQKBQKBQKBQKCLb4rKIR6QbNMHurtaLfcmrOV9sarImEDntP8JfAXmt6CHpWr +jYnNrx0oInPRCxoz3erHsAI1K7qhzJuIgkgMRSNs9oGZg1dH0pScgBqJPRAR1EAoNxGN843Qt8AD +yiAcR+5QR1Ooz4jzZhsNnMrwcwMUv3K5+h7TpJofj9zZWSC49lP8GLZJys73rgRyQX7YahbbrT3d +IA6HKU4CABHpk3jHd6QKwVx3aztNjbWIAX2GSzLKTtr8Yuhfcoga/EAUFc2+Mf3frA0DaXtNeOPa +15IyeI/94pgoMqnTj8UhjfdHlCC4J3UYSLt0nWTJaywqE5CiEyPK8SvElebxGyMM0q942Zhfcf3X +125gtc4GDm5dRAOYaCWpQUiVL8PN+/QQ3OoH1jOo9uN3DZS2X9IzA2ZFkh27zyXQTN+VIdjxld3P +vVnezNjQDPKZ3rAYBH9CmMIupROIecAAKDBpmRq8QgjUShFuN3vTrESOLNQOMp+0bffhSJtLQDqB +XMAeAxfNRLHwAA/RDpp+1QYiWzN+6FVOu8nffbJ2SQoBB0993Leq8urZ3v8A1QeBzKZn0+IWvT4q +DKTsK8R1vk2kZPi7TmfODvu/2/23Vojk0hE1d7kqyCMYKYmrphSXL7sfkJn7Ti1kdtQMJQAQ0HUA +m+QPxCfSAnDWmWWN5kVjKhU294Wm/IePcowJ3brQhprdK9QaxbtmAB4gBjfL5KCAP4gPJ22rNfU5 +y7mnajlOMZpxrleB4hkT7MIZcF5izbPysbxE3NquDykL6wxWJrMbQA9Iwhx0AaDNN4S1M8e3b0FC +VOnCKWLWJk15UY/40L5dPMBR2LVvTUycECS+Y4j96blAO0aCZ5QKBQKBQKBQKBQYM/EO4xJPem5P +pARImvrcZS2GS21fvCBbyZKpd7UeUilEe25cUO6fmAOIlKPmoINnTK3UxjZJvuwFuum2OpTkyE4Z +c5iL3F4OBe9yu+QoS9RaIGZuYpyiIP8A5wEKCQ7uS8X5uElTXKGDbltAjWGUrzGnlCyTbNUvenOU +NBjFO1HdmdmjAM7H3+xHuCYCmOblEA0HQKDExs26DnUV3vxlFml3RxnB2Pp45XZmhylufeXwMhT8 +XYAuuuQmqJtZTzx8HW+U/enATAIiHlEAzhYu8LvDMZKyyM3UIycabg26vr1C9t+MAaGrQdRKP2mm +KYAHTyCA0F5y34bN/kCZY7RLf02SjITO26RVFnzaviyVxN0DlE3MaXQUzyLAAiGnDUdRDyaiAYM8 +oYFyj0otyeL5Vvw6dm3XPuHXuSs7jB5diPvnHsAljtFHwrmBsdZExmDQVhyOQR1BolRTdnENNaDK +Blfxg+7G/OnxZhHadt4iGNwM19yseXnibSzIAl9EXQXV3gL2xx0TDqIaAQvKABxHUdA63HxW/Unl +qGQSpJi3Z5F4/ieLhkWVxVwieVQNkZnGaw6BGibPec34t23dD31B0AS+lp5Q5QoOhJh1A+rX1386 +W9rGHHuNbfIRJYy7yLJGOsKvr3j7EzTEBFgJKcsbh8i3CGkE+j4mAQ7qETcwiHDt5gza4A6B3Tn2 +0MXvbuDWOG7KQRdqNJJRlLca7PjRiaHtEVZjGdgHHZjCxmjhdeAu4mNoABrwoPW2y/ed0ud0ORXv +CWzxZhsJXAGszj7jt2BwxQ1S2Ksxu6TyvEoOeoz2PFHiJR0EAEB7BARD31kba/t9yvGFsUyvh3Dm +QIo8/Vy1km2N2R2Hhpr8dBCt6znSwwl0+ZPineNgfHLXNNs8kyc1QbKe1rI7w+OsAiUodWIHNnJE +5e1iL8aNT0veoB/qF8HTsAAAI++e5/D8jZJnT1ivCLbtZxVkV2iLrjzCceenx4izXFoqyvbaY55i +7CMhyCY0gHi7jxHWgl5eEmWorsa3jWb7mnB7vLsNqSs6e4AEO3Eb5oN1yJa11OFpUrJaEwcC84AP +bQTIKBQKBQKBQKBQKCPZ4jPcPCsebPl2AHO69qJtmpU33GFmYjmtOQoYxIGle4qVRSiIHTequksc +nZ68dfIFBBh2vwfI0/3Asm3LHolheeMzzvEeN8WSuTOs0x/KsRZta8nsznE3geYBf8fyMH7s8oDx +DQaDPb0pYvuT6j3VaSX+pI+SjLci6ceL3ZtW4yy0I92s+WMTTU8DaIhLCRnWNv0jYJ4dzdXh2MU/ +fpifWg6agITplStUrVe1q13tqseK5d8PIOlBgx8QPA92k32Crmnaf7+LUiKeM7ln6LYl78+0KW4o +Bk/VHuu9C/yCOd//AKXaKDzx4dSD7x4ZtVyGl3HtmS4phN5nTQ2bWoTlYH4ssi7Ya4c+RXpqZnYw +zqOY7K/HAjQDi5kIBSunDXURDNNvK2kId4exbcxiFZF41JLE2xbMEETROds4OLXm6KAcuO8gM98x +PUsV2APxRN3mBuflDXTloI6fhK4Dt9zHC90kTybtWw/KMxYancPkqLN8ziLLPpd3TkFmMIY7A85Z +3oWD3AFhAPqsSB6Qh5tQkwdUeMQzGfTr3gzKL7b8Y5YdYdg+YSRBjBbDYKaLu7mRsOhNKXW3dh9w +twIFZdjyU33hh5DCAlHQShHa8LDi9hadjOfshNK7+8OXNxgRt9XdndETx8ycGj/43QSBdxuCEm4P +A+Y9uKxc5oUm4HF0wxMK9t7Gd2lgatP66ZuHf7L81BHe6ZHh+M17EN48WzfuXzHA3oMSRl7DFcVx +uZ5M7OztKmUYAMseBcw/2eIwvLqAtPn8gaUEqBL3okV+1pVzWALWvu1chcmgHZp7p83yUGHjr5R9 +ieOlFveUqW9sSJkbZjaaMKAoCLU1O8Vm7KDQDOUA9IwmenTh5aCHV1KWmxHdg/QbhC1LzP6TZ5uO +kysugfWTRkLcFbcojb119LU5TG4/xvMFBx7p17jcydOp4xbvMxzOMQzmBZOl0txHljDzbKl16bRd +AjXWHYbmTIkoaSPbNdlFhLaVMbmhJdsGOTQ5DFEQoNlHivKLbleIMM2jxrfccla2x0QqSmE4GbHp +suX+cdQDQwAfT5qDtSgUCgUCgUCgUETPxDcXfse7jNj+5ppXNa3uWe43bEKGSfWzS0SyJ5RhcoaO +9v8AlygjZ573hbp5tv1w7lzdchbmXeDti3EQ6P5Sm7bEGSAyt5+z3JzG5RVryC0NJWMDSKBmJ3WG +gCPcGgajprQSfMJRMuyfxRW7HEKoXEsP6guK5dluDLiCUbLs65B0ycJ7RiiJDWTZbLKWwpg4CXQa +CT5QUaVVp5/xIfN+/wAaAr/G/wAr/HVfk4UFvkGdf8PuG8pziQssZVQnHkXyTlqUrnF4eOEUijG9 +Sd2NoZjHUQBlHTycezsoMLPhNNvnuVsGnO5h2bzJJDu/zJLZShAAKBWzH+OxNBIeQphEdBLctuY6 +/J5xoJQMij7FL2B7ikhRNrvHpK3Okek7IvKHdjq1OjQLW7tPEDehctjoYO3lEeIDxoIa3RwiA7AN +5HUU6W+QFzgilLPlQu4/AhrjV6tpyHiY7G9ldOQvKbnOeDPZXICiJf0F26gBRCSeKoEqpC7Jfypl +dGdyQh5wag00HXgOtBV5NnJslCyilhp2l1ZnQoA8md7YgRpddSnEvJbIb0x4jrrx7NOyg4y1pXT8 +7XfDs17aDAx4g7Ib/LdueEenzicBes8dQDO0Qx4yxZsIU7qGPYm+A4yt3MU4lAxDSQxSj26APYPZ +QYZPFRY5YsK7gOnxgloAyOI4J6fzXG0S5uDTkaGWde6PfHZwKJmQP2aBlLpCRjaJ4f1futywxlR7 +t8zZOwRkhWdxKbvfE+KHV91iWJWsOTlKHu+Yrq66mExtRDQALxCWZ0q/alfT72rq1n5WtxhD/wD5 +Hp5aDIZQKBQKBQKBQKCL14ophVq9r+K5Ck/U07efYfi/uS9a0EZPrfZFxTlffxK8m4VXtL28TTBW +3SS5hfGsNGj/ABAGxkzDKXaI+XmJa0B48mtBN73lbC5b1I9suwrfFgmWtUY3z7ZIdCMhY/kYWrdt +jmjw3JmxzmmJ5RbIIFtxpLPWZRbACh9+U/EObloPXm1LdXjvdjDn52iqxpZMrwt1LG8+YQcTC15D +xLlYSlGXNRmgSF79jwHEQaHYBHgADrx0APTir8U8vk83k+bWg/fzVc6+w/VKP8vfHL6paWj/AMX+ +UaCNv1I92D71DsksXRz6c8rLkCZZalLa2b3dw8F0v4v287fGF4tnmMQGSWjA1Pr8b1gFdTaemIGa +LfMZzMABLBwfhuD7ccJYtwTjdEDPAMSwaKY4iiINeDNFGfuu2A83pCY5SgJhH+FrpQdsJvJ8P41B +gz6wnTbypukJivdxsukjfj/f/tMdTyHELm6EZgi2Vo0S6VydcR5FtvJu47idQBvQM4hyl5R1HlNq +UPCeFOuBt5azDjPf/GZ506dzzO5dwZAx9mqGzUMUXJMICYrziXIIGfikjwELqIOhjAHaBjF0MIZF +Eu/rZGri77LEu9LaWtjzK1+8b4ubcwMjt3Q0/tUHhHcZ1uNpcIi/dO3CVf4tdx8zax+x3B+JWd6l +nvdLHbQIi0O/uv31Qc66WHS63GuO4OTdVPqj3W163sTNqux/C+IG7u0IBtYx4RoBsZSILbWpOity +K3HjgQtv9TEMfm5nQR5Qjz+LmB0V7+MXNuhraJm2kwFGhMJSgCh/c5llq6a3buCHMPs9sSmOUo6f +hCCID6IgHpjrR9SC5vF6cnSowLAUDiimu8xtxvnrIMXazEud2M+PALBWuLFOXQOSRZfHlAB/ia8d +QoJSu0zF/wBiO2nCGJ/y33LgTPG/bv7JZBoPR9AoFAoFAoFAoMOvXMw39rGwXIyRIhBarhbozzVA +P9k/pfj8TBQa62eJVUTS+1yFja1qTJzV/cicOXfbT3O7RN7/AL3C0eaR6/pag2anh3Mypsx9LXEF +62495OGN5ZkrH0qvCcTnM7BML2QiWzBza2/ZYvPENkA/iWwHy0HPN43R7xtuDzIi3XbfcqSvZjvX +ZGwW8mfMTs4OrZLWs+pjNWWcdg+MdvIFsSgUoauZD6E1ETcKDyFHtiXXwg6ebMEX6i+05+b5MYDM +02mOCZrbl0UtgICcrLFzEfrdoxihwEXMwAPHQQDSg4M2+H73R7glKr/qNdXTc1uBijwcRf8AF2F2 +pmwjFXkumoC7uTWF23eAohwL3SXXyjQZttm2w/arsDxiGLNrmH43jOK8omkrwQDuUolbm29jrMZW +5mF7kdz0dNTjoAhwAvHUPWXtSpWq/onZ5h+AUFd+SUFD7Uk9q9l0/wDUfJ+z2UHBMo4bxLm2PjE8 +xYrgmWY7x+pMiw5klzUHER5iklBDgIjrx4caDwE49EjpMOrkDou2CbdRXAGpu7Yd3UA8R491tTtb +IbT4gD5KD1pgfZ/tX2rpXNLt027YcweR8H2F5W4pxvH4k6ufLobldXdqt235+AptBATGMOvHXhQe +kknsn5pr8+v33x6+Wg14HiKMUZW3j9YiFbb9vURXZAzI9xHHUNjsUI+WmollRaht2bLpA9PGhAj0 +dZmY6ozsJjcvIQBAQ5h1DovpMbQVmY+pt7v+/Jct4n6frX9nEWnBQegiTs7Y+EYv3vEQcQAAjcgf +w710oJ/qVKlSJUKRJr+JfRQf1QKBQKBQKBQKDic8i7XNobKom7IfbWmTtbxG1yH+1vmANaCGht+w +Oyv8c6pHRrzBFYo4T52juYNyWyZ2dmcXV0bc/wAUgx3N3LjsSctz3kf2TldR5TlHl10HXSg9U+Df +3NN7rBd3W0tZfIkfrMoie6WItvrhMdQzZBisfx3kO1bsj/NhFFsRiwnEOBu+Sa6CHEJwPs4fF9H/ +AJNBQ+zAl0EBAddfPpQflB1JnCKTycYkyhEcUTdBjPJcjgkvjeOcid0keCwGfO7DcTxKXXmo2oHL +H3w/rBLrzCIB2jwoMT3To2z9YzblPTx3eDvOwTuxwCtI5OIuLnH5wXNrQ6uQga2ETl4gQl6NW9Ox +zMJigI8ogI60GUjcUz5vkGEpy1bcJ1BcfZsXNJhx3NskRI8uikTdzCTleHWKtgiZ8KQAN6Iej6VB +h56c2xfqt7eNwE2yrvi6kJd0uMZLFXhrQ4SaQmYM5JU5PRQtSlqLKGhlDH5I6Ajyg0l4ajxDhQZ4 +Ff5KOn5X5PP2cPh5qDivtSpIPzfP+6HkoKH2lV/pv+dQcqa1X417Jp9zt+Sg12uaN+cIxR1yOpzv +QTPI2JRt4wtm3Fe3VtsN9u9ZmO5M7dCttcJLaMUD2jJ2YJY4vbsbTmuWkYDqACUaDM54c/bSrw5s +tXZMkKD+9mdZR7ye3f1T+qOFBIWoFAoFAoFAoFAoFBg86r3Txyfm5+xzuw2nvjnC90uF3RncmNdG +/ql1d+6v0R3R3pr/AHjoIJER3X7sOkf1DU+VouwXMZZvhzurWziAPaNtSwuYQ7IjgeVSeASdgZrV +n2mPTNoUtdwTW1FtQiUprSlOa2otWrpA2nPTb6lO3/qc7a2XPOD3MELml9mZMq4rdVqe/MsRTsUv +r1cZkVqyFoVbcrAh77S627RErqjD1hAt3SKE9gMgntQ+Yfo+5QWSg/j2oPMH0/coHeof6CP0UFEq +fvZPzHTt+L46Cu70/Ffa/L5+Oumnm81BQqn5KrS+bT4aUHFFXYHyfuhQf1/ROPtn7vw+agwfdb3r +UYw6V2KbUWgp2LIW9DIzABsZYzu3QWIYPZCwRMiyjkpKmuFvJo+hV3Td3N5jW770rtchNLBL922E +SDoi9MJJ1EJBLs95hlrveh8QyevvPOtklh2yu7Op1zyZx7xt3rpbR1p043Dl5h0OYeI0GwTjEXYY +QwsUTiSHuWPMrV3axoW39UNLT+3QX6gUCgUCgUCgUCgUCgggeLB2cEQS3H27KNtyowuyW7FJOqTN +g2U4g3ktODStv3S8SlOkV3LIa66GTaeegjZ7BOoTuk6duXEud9rs7CJShU1Gjstj7y323+CZBjJr +9tRdjs4i6k9pO7toqbRbtm7bOnXIrwesSqLFz06DYRdJvxQGIN/uScdbX874cfcH7msgX77PFXKF +HUTPC08fG9pXvCuykUKDFmOPVitG3XTWEi+26oyFtiB3TnEhBCUd7UHmD6fuUFf7N8Xw/lUD2f8A +oPw/k0D2b4vh/KoOPt8qhrs5rmhqlMZeXVIURXMra8szk6l0Lz6GaymA9vgHboFB9FTCl0D5P3v2 +qDrOYzGJ48iclnk8kjHDoTDWJ1k8tlkmc0bLHo1HGNFecXh9e3dwvWELY1NiBPcvX7945Ldq2QTG +EACgiM7/ALxbO3fF9p3g2wyBr9w88spVaFNmGdJHaD4WYHG4nEidxZ48vSoshZHM3rAEt1PdsR1F +c0LcsLVFsdBCBBmvNWV9y2WpvmjNczfMk5ZyW9LX2RyZ7VjddXd9XiFhtto7VoLdhGgbFF5PbSIk +xLaZKjsBZtWyWyAUA2cnQy2vG2sdP/E7WuQmRSWesFibP6A46mC48lAxwMPZqAjpQZlaBQKBQKBQ +KBQKBQKBQYsespt8btxHT6zzHFDf3gvYYkeYM63s1c4sUXIfP56DVAL2w8eenRgumC4REoOZFdEB +AL6K5qe0YupQEwkKPKbT+EAhQZeOgffBN1hNhtwbg2+bMd+wBg14+1QqWJQt8A1/Cje5R8mg8aDb +v8PyT4/j+GtBi46n217qN7oo1jCP7Cd4LbtLTNTs7DmVwFtfWyUytvLbIDQdolUbtnfLWlspw5CC +TUTAbmERMUQwoj4YveRkFT78Zu6w2eXrLIOQuSB6a2iauzU1iGoho7yqbA/aj2cRAKDlBfDK7qZw +l93s89YjcZkHH3YvYu6Xp2M6tenMBTe802ewA4APkoL6/eEc2wtLWiV4T3abn8Y5MZTd4sU4cRhL +w1d6ejoJmhsZWLQPOACPD46DN507tr+47Zlt1JiHcJuklO7ycDOHmQo8nzkXj1jRFHUS9yxExpFe +f3seQebXiOgmHQQ8ocU6tS49vpj7/hUGKU5toWf7IGNygGqjGcis6cTaamC7oHl1Gg04lB696emH +Q3Bb4du+LbgWRbXeftl13u3ya2EaBuLec1Ks5/4iRMlPdMGnH1Q8aDb6MLWljzCxx9o/EkbM1s7a +h1/qny+Sgv8AQW/89+HmoLhQKBQKBQKBQKC30FwoOjd0DD7w7c84NOv5bi/JH0sj15vjoNU5k3aJ +Pp1tryZvAgdkr/HdvOV4nizNLClIAusEj8/tOqXGE+dA1KYI5K5AwrWk94Q5bS6xbAeBxEA7j6EC +4P8Aq6bBFVsto2ueWe2e2oAwlKFxme7N4pigICFwhDmEvEQ5g7PJQbe50Sqkirj9zjx/yaCva3T8 +04/D4fJQeC99rV1K3VPClHT2k+CWVQhK7BkSLZt74aXB3KHMVoc2SVkhs0EvqgLx1KGuocR0HQMS +/wBiHidcnKwapDuZ2lYXj638tXRx3+tw1D9Ti17fOHCgzzbOcXZ3wlt9hWPtx+cTbjctMguwSnKg +s/uuDwVze/WDpbA4gAMRTCUR0Awh8gUHc6qUflyTT5fpHtGgxndXQ43elz1AFZC857e1DMxFZQNy +cpr8Mc05T66Dwtjd10/haacNaDTzakJbu3ro8tmwUDHADlJcumMPLZT2NSXBMoU3TAUgAQ/LxOYO +QpjAGbjoqYpkkT6jG0lmnDK5w97m7uyTi2nemS9bc7sOl8WI5wJ8aS3RG4ZskLW5kV2DDxPbtgOn +Gg2i1AoLH+c/Dz0F8oFAoFAoFAoFAoFB1vlpL7Xi/IyVV+SLYHMGzs/1syfPQRaPDWQ6HzfMvVo2 +25AijdNcZTeNw5slcKkIFFolTQWZ5RjfdVzm1AbZ2F3AnDjqYNNO0AxIbn+n9IugH1WtsW5NZFJr +kXZW3bho3PMXy5uS2zya3HbLtzu+G38pQ5XDL8KZLt0ycDjyyZNbtqbYAJ74gGyjwxmfGO43FOP8 +u4ombZPMbZIjDTIobNmIxitjw2udpPoa2Y1rmZnzQ4lM2jxKcvKIagIFDsNKl7pVf+m9v7vH56Dk +FAoOByhzVpPxRIuHT975NKCxtbCrVqtfzT976KDGn11Z3FcX9I7fCofndG0kkuGHTH7SdUptIlb3 +Mp8rRxlrj6BKe8Fxe8uV9yC6CK2BjWEFu9d0ApDCAQEuhP0dXrqL5oTZJyzHVSbZ1geStTlld4vn +IlsZWlgJiObVhGN63LalcpfbdwUkhUCNv3bRhzfg1CgxRDMVkVqSKvFErStCBqQtEWc8DRhiQtjM +DQ0M7Q0bXcLgDOz/APLYUEy+gUFv9l/Gu3h9H7etBcKBQKBQKBQKBQKBQcGyh/sHOPa/+F3j5P0J ++xQRlvC/6O+9LqaStJqtSf3PbES7savrbKOTnPUB4Dw7loJYm6va9hLehhqbbdNwULb5njCftpkT +83gTV3bHQSn7plUTdTEPdYJDHTH5gdS6GAwAHZqUQhPMkh3o+GO3RLIVMGnIm5vppZslZHNvfm+7 +daBZ3RfbuJrcpx/a0MwwTcKyWiAWUNBvQnRQAQ0ECmKEx3b3uuwzu6xgxZp2+ZTa8sQB5KXleo2G +jlE3YSmHubIEU5SvsDkhfVjqDpoYoBqIAAgIh3MlfnT/AE7234fL8dB+9+qvZfy7h5/39aCg70+V +YHw+fQKDxPv86tG07pr48XSDPE2aHDJXdxl8MwDDXdmc825CcfVczTyRAxDhB4+c5ih3s6CUoAI8 +dS8pgiewXDfUP8TZmtiz1uDcpZtm6c+LnYjlB2WPkdjtN0AtnLcaMUA6Ax28v5FPw74yA5k9WyAY +e6gAAABCZriLDWJ9vOI4NgfCmO2qA4rxi1BGoVGGsnIDUbiI8roJji/SGQDq6urqJjCAeUR40ETb +cSdFjbxMKF4fkhWhnmZsFSFkFwMBwdmtywvDYpcddQEQ5iXISICHkEBCgl7JPyX9j/NoP6oFAoFA +oFAoFAoFAoLC/PzDE2BdIZC+NjK0srWLkuXOf6KaGnXWgih9XLrwRdpYXzA2zmVNcndlrW8Nk3yo +2fW0TaGnT/dH/iCR8aDIn4VrbE54n2MS7cJNWRwZ5buzyh76My50OUHN1xRFCg3Y8drg8RAX57fH +QwgIBqA/PQSQ350VpHT2v804fPrxoOKT2LYlz1A3rGWWIvGZ/CZM1d2vsVkbODs0PID/AABAQEPn +4DQRoc0+HPyFhLIS3O/Sn3TzfbJOu8gcUUJty98aGY/okAzULmYX1inZhuCbQkpbbgAUOJhoPLjp +uq8UVtPfl0eybtJgm6pp/wCKW3D3vZ3v5vrfDc0839V0HE3XrHeIHWJQSR/o8NqFWHYucdrO4qVB +2eXlFk7dPLQcWM+eLE3kMKtqaWBq2rwiSD3e9OBmfF230QbHcpiCYoyiZZNy8QRIYQ+qmvXQR40H +vPYd4XXDcKfkOd+oZlVw3l5sXCDk5RxxdHp8xaDoJbnrAlMqlRiTrLpyjyCQ7qDOGuoDbEONBKwY +o+xRppQsEcZ29lZmRtam5nZG5rI2NLO0tfFqbGtqRDpbJaEB0AvAPNpwoPkqa0ventfsP0cOzhxo +I9XXg6X8o3SwKK7mtuCF0/xS7cNO42KNh9b5cxP3370O+Omnj/tHH38O9Gmg6r6afVyxhm3HSHGW +4+VNeMs8wv8Au2+e+31S0y52aP8A6fkf+t2igzgNbqldkqF2aVwrUi38hXNvyfTQXCgUCgUCgUCg +UHW+UMtY5w5F104ybOIvC48i1/HpI8A0j5P3KCMtvc8SxA8eql0T2nwgZo7fmE4m31TE+9v7ID6/ +1oIqW6rqg7vt3ar/AO7OYZQtj3/A8b/unE//AHQ10HafRdw5gHdH1MdtOEt0DN74YsyCSZlVQkXV +9bWaWSyKsBpNjxpeiEuWz3I0Pc3EoCAmARDUKDbVsLC0xlpRR6PIm5naGduaW5mZm5pBpamdqaQH +upra2rUOQpNNOH3vAAANAAAuipKlV8OPk+X5x40FjVMLX5PJ9z7oUFclSpUg/ivy60Fd7T8fw/k0 +D8W+GlB+eyJvMP0/coPrQfL2RN5h+n7lB9aC2pfaUn9NS/F8n7VBhV6kPRe26b2SPOWIosvbc9yO +neAZhhTIV2bZgctsxjfaRj0nLYnoiJBDXUr1zcAHiFBFVxb1JNxfSM3ZZg2eZhfGzM8dxPJix6Uo +G92uW2sAEAOzy3HhJYBTkMAD+hxABDy0Er7aD1DttO8aLd7YyyM1+8I/l0GcnfumWcP6oCg94UCg +UCgUCgsEl9r7qXd3d5e2/wDLHc3evze9X1DQQfeun72e8yHvD/qA+26//tb7Ffs67P8AdD3Z+oaC +I7KO9u8lv+3f336z9y+zh837v0UHFU/evtHD3k1+L3N1+fm9HWg9pdPb3g/xx7SO7vte9u/xN459 +g+zn7M/tD9Z37w91vej+73vlp2d5fgvPxoNz83e1d12vaO9Pbe7Q5u9+5u9P5sf073X9Qa/5Hk1o +L7QU9AoKigp6BQKC2pfa9f1n83cvm8nxUD8b9r/Wfb/UnN2UFyoFBqTOvl3p/wBW/ePr78a+/bRp +3f7na69yk/S3L6PNprprw018ulB0lsj94vtjg/8A/Umnegf/AIk+y37Qu3/dHy0Gyl2Xe932ZJ/e +r/Fxp3Y0d2/4tvsR95f/AAH7Gvwmn9rUHsOgUCg//9k= + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1-- + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74-- diff --git a/spec/fixtures/files/support_without_sender_name.eml b/spec/fixtures/files/support_without_sender_name.eml new file mode 100644 index 000000000..c8d3f1829 --- /dev/null +++ b/spec/fixtures/files/support_without_sender_name.eml @@ -0,0 +1,631 @@ +From: +Mime-Version: 1.0 (Apple Message framework v1244.3) +Content-Type: multipart/alternative; boundary="Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74" +Subject: Discussion: Let's debate these attachments +Date: Tue, 20 Apr 2020 04:20:20 -0400 +In-Reply-To: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +To: "Replies" +References: <4e6e35f5a38b4_479f13bb90078178@small-app-01.mail> +Message-Id: <0CB459E0-0336-41DA-BC88-E6E28C697DDB@chatwoot.com> +X-Mailer: Apple Mail (2.1244.3) + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; + charset=utf-8 + +Let's talk about these images: + + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74 +Content-Type: multipart/related; + type="text/html"; + boundary="Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1" + + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar1.jpeg +Content-Type: image/jpg; + name="avatar1.jpeg" +Content-Id: <7AAEB353-2341-4D46-A054-5CA5CB2363B7> + +/9j/4AAQSkZJRgABAQAAAQABAAD/4gxYSUNDX1BST0ZJTEUAAQEAAAxITGlubwIQAABtbnRyUkdC +IFhZWiAHzgACAAkABgAxAABhY3NwTVNGVAAAAABJRUMgc1JHQgAAAAAAAAAAAAAAAAAA9tYAAQAA +AADTLUhQICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABFj +cHJ0AAABUAAAADNkZXNjAAABhAAAAGx3dHB0AAAB8AAAABRia3B0AAACBAAAABRyWFlaAAACGAAA +ABRnWFlaAAACLAAAABRiWFlaAAACQAAAABRkbW5kAAACVAAAAHBkbWRkAAACxAAAAIh2dWVkAAAD +TAAAAIZ2aWV3AAAD1AAAACRsdW1pAAAD+AAAABRtZWFzAAAEDAAAACR0ZWNoAAAEMAAAAAxyVFJD +AAAEPAAACAxnVFJDAAAEPAAACAxiVFJDAAAEPAAACAx0ZXh0AAAAAENvcHlyaWdodCAoYykgMTk5 +OCBIZXdsZXR0LVBhY2thcmQgQ29tcGFueQAAZGVzYwAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEA +AAAAAAAAAAAAABJzUkdCIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAPNRAAEAAAABFsxYWVogAAAAAAAAAAAAAAAA +AAAAAFhZWiAAAAAAAABvogAAOPUAAAOQWFlaIAAAAAAAAGKZAAC3hQAAGNpYWVogAAAAAAAAJKAA +AA+EAAC2z2Rlc2MAAAAAAAAAFklFQyBodHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAFklFQyBo +dHRwOi8vd3d3LmllYy5jaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAABkZXNjAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAC5JRUMgNjE5NjYtMi4xIERlZmF1bHQgUkdCIGNvbG91ciBzcGFjZSAt +IHNSR0IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAsUmVmZXJlbmNlIFZpZXdpbmcg +Q29uZGl0aW9uIGluIElFQzYxOTY2LTIuMQAAAAAAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENv +bmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZpZXcAAAAA +ABOk/gAUXy4AEM8UAAPtzAAEEwsAA1yeAAAAAVhZWiAAAAAAAEwJVgBQAAAAVx/nbWVhcwAAAAAA +AAABAAAAAAAAAAAAAAAAAAAAAAAAAo8AAAACc2lnIAAAAABDUlQgY3VydgAAAAAAAAQAAAAABQAK +AA8AFAAZAB4AIwAoAC0AMgA3ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUA +mgCfAKQAqQCuALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEy +ATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMC +DAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMh +Ay0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA+AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4E +jASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3 +BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB/gICwgfCDII +RghaCG4IggiWCKoIvgjSCOcI+wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ+woRCicKPQpUCmoKgQqY +Cq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUAN +Wg10DY4NqQ3DDd4N+A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg+zD88P7BAJECYQQxBh +EH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT +5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReu +F9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9oc +AhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR+UH78f6iAVIEEgbCCY +IMQg8CEcIUghdSGhIc4h+yInIlUigiKvIt0jCiM4I2YjlCPCI/AkHyRNJHwkqyTaJQklOCVoJZcl +xyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg/KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2 +K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi+RL8cv/jA1MGwwpDDbMRIx +SjGCMbox8jIqMmMymzLUMw0zRjN/M7gz8TQrNGU0njTYNRM1TTWHNcI1/TY3NnI2rjbpNyQ3YDec +N9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA+ +oD7gPyE/YT+iP+JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXe +RiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS+JMKkxyTLpNAk1KTZNN +3E4lTm5Ot08AT0lPk0/dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYP +VlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW+VcNVyGXNZdJ114XcleGl5sXr1f +D19hX7NgBWBXYKpg/GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg/ +aJZo7GlDaZpp8WpIap9q92tPa6dr/2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfBy +S3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyB +fOF9QX2hfgF+Yn7CfyN/hH/lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuH +n4gEiGmIzokziZmJ/opkisqLMIuWi/yMY4zKjTGNmI3/jmaOzo82j56QBpBukNaRP5GokhGSepLj +k02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ/JpomtWbQpuvnByciZz3nWSd0p5Anq6f +HZ+Ln/qgaaDYoUehtqImopajBqN2o+akVqTHpTilqaYapoum/adup+CoUqjEqTepqaocqo+rAqt1 +q+msXKzQrUStuK4trqGvFq+LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm4 +0blKucK6O7q1uy67p7whvJu9Fb2Pvgq+hL7/v3q/9cBwwOzBZ8Hjwl/C28NYw9TEUcTOxUvFyMZG +xsPHQce/yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI/0sHTRNPG1EnU +y9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba+9uA3AXcit0Q3ZbeHN6i3ynfr+A24L3hROHM4lPi2+Nj +4+vkc+T85YTmDeaW5x/nqegy6LzpRunQ6lvq5etw6/vshu0R7ZzuKO6070DvzPBY8OXxcvH/8ozz +GfOn9DT0wvVQ9d72bfb794r4Gfio+Tj5x/pX+uf7d/wH/Jj9Kf26/kv+3P9t////2wBDAAICAgIC +AQICAgICAgIDAwYEAwMDAwcFBQQGCAcICAgHCAgJCg0LCQkMCggICw8LDA0ODg4OCQsQEQ8OEQ0O +Dg7/2wBDAQICAgMDAwYEBAYOCQgJDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4O +Dg4ODg4ODg4ODg4ODg7/wAARCADwAPADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAEC +AwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0Kx +wRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1 +dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ +2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QA +tREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk +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 +mk8VWW5WRGJGAOaAP//Z + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1 +Content-Transfer-Encoding: base64 +Content-Disposition: inline; + filename=avatar2.jpg +Content-Type: image/jpg; + x-unix-mode=0700; + name="avatar2.jpg" +Content-Id: <4594E827-6E69-4329-8691-6BC35E3E73A0> + +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 + +--Apple-Mail=_83444AF4-343C-4F75-AF8F-14E1E7434FC1-- + +--Apple-Mail=_33A037C7-4BB3-4772-AE52-FCF2D7535F74-- diff --git a/spec/jobs/labels/update_job_spec.rb b/spec/jobs/labels/update_job_spec.rb new file mode 100644 index 000000000..16692b2c3 --- /dev/null +++ b/spec/jobs/labels/update_job_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe Labels::UpdateJob, type: :job do + subject(:job) { described_class.perform_later(new_label_title, old_label_title, account_id) } + + let(:new_label_title) { 'new-title' } + let(:old_label_title) { 'old-title' } + let(:account_id) { 1 } + + it 'queues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(new_label_title, old_label_title, account_id) + .on_queue('default') + end +end diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index 94c7663f2..bf25a8e04 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -64,5 +64,14 @@ RSpec.describe SendReplyJob, type: :job do expect(process_service).to receive(:perform) described_class.perform_now(message.id) end + + it 'calls ::Whatsapp:SendOnWhatsappService when its line message' do + whatsapp_channel = create(:channel_whatsapp) + message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox)) + allow(::Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service) + expect(::Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message) + expect(process_service).to receive(:perform) + described_class.perform_now(message.id) + end end end diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb index 2851996b6..a7c3cffef 100644 --- a/spec/jobs/trigger_scheduled_items_job_spec.rb +++ b/spec/jobs/trigger_scheduled_items_job_spec.rb @@ -21,7 +21,7 @@ RSpec.describe TriggerScheduledItemsJob, type: :job do described_class.perform_now end - it 'triggers Conversations::ReopenSnoozedConversationsJob' do + it 'triggers Conversations::ReopenSnoozedConversationsJob' do expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once described_class.perform_now 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/lib/email_templates/db_resolver_service_spec.rb b/spec/lib/email_templates/db_resolver_service_spec.rb index 8648d8514..f581c0c29 100644 --- a/spec/lib/email_templates/db_resolver_service_spec.rb +++ b/spec/lib/email_templates/db_resolver_service_spec.rb @@ -56,7 +56,7 @@ describe ::EmailTemplates::DbResolverService do Current.account = nil end - it 'return installation template when current account dont have template' do + it 'return installation template when current account dont have template' do Current.account = create(:account) handler = ActionView::Template.registered_template_handler(:liquid) template_details = { diff --git a/spec/lib/integrations/dialogflow/processor_service_spec.rb b/spec/lib/integrations/dialogflow/processor_service_spec.rb index a0bf083f9..dc2208cd0 100644 --- a/spec/lib/integrations/dialogflow/processor_service_spec.rb +++ b/spec/lib/integrations/dialogflow/processor_service_spec.rb @@ -89,5 +89,17 @@ describe Integrations::Dialogflow::ProcessorService do expect(processor.perform).to be(nil) end end + + context 'when message updated' do + let(:message) do + create(:message, account: account, conversation: conversation, private: true, + submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }]) + end + let(:event_name) { 'message.updated' } + + it 'returns submitted value for message content' do + expect(processor.send(:message_content, message)).to eql('selected_gas') + end + end end end diff --git a/spec/lib/integrations/slack/incoming_message_builder_spec.rb b/spec/lib/integrations/slack/incoming_message_builder_spec.rb index ac89434c7..98ae9cdd1 100644 --- a/spec/lib/integrations/slack/incoming_message_builder_spec.rb +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe Integrations::Slack::IncomingMessageBuilder do let(:account) { create(:account) } let(:message_params) { slack_message_stub } + let(:message_with_attachments) { slack_attachment_stub } let(:message_without_thread_ts) { slack_message_stub_without_thread_ts } let(:verification_params) { slack_url_verification_stub } @@ -51,6 +52,17 @@ describe Integrations::Slack::IncomingMessageBuilder do builder.perform expect(conversation.messages.count).to eql(messages_count) end + + it 'saves attachment if params files present' do + expect(hook).not_to eq nil + messages_count = conversation.messages.count + builder = described_class.new(message_with_attachments) + allow(builder).to receive(:sender).and_return(nil) + builder.perform + expect(conversation.messages.count).to eql(messages_count + 1) + expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again') + expect(conversation.messages.last.attachments).to be_any + end end end end diff --git a/spec/lib/integrations/slack/send_on_slack_service_spec.rb b/spec/lib/integrations/slack/send_on_slack_service_spec.rb index 234a2180b..ef662ae15 100644 --- a/spec/lib/integrations/slack/send_on_slack_service_spec.rb +++ b/spec/lib/integrations/slack/send_on_slack_service_spec.rb @@ -9,6 +9,7 @@ describe Integrations::Slack::SendOnSlackService do create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation) end let(:slack_message) { double } + let(:file_attachment) { double } let(:slack_message_content) { double } let(:slack_client) { double } let(:builder) { described_class.new(message: message, hook: hook) } @@ -58,6 +59,36 @@ describe Integrations::Slack::SendOnSlackService do expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345' end + it 'sent attachment on slack' do + expect(slack_client).to receive(:chat_postMessage).with( + channel: hook.reference_id, + text: message.content, + username: "Contact: #{message.sender.name}", + thread_ts: conversation.identifier, + icon_url: anything + ).and_return(slack_message) + + 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') + + expect(slack_client).to receive(:files_upload).with( + channels: hook.reference_id, + initial_comment: 'Attached File!', + content: anything, + filename: attachment.file.filename, + filetype: 'png', + thread_ts: conversation.identifier, + title: anything + ).and_return(file_attachment) + + message.save! + + builder.perform + + expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345' + expect(message.attachments).to be_any + end + it 'disables hook on Slack AccountInactive error' do expect(slack_client).to receive(:chat_postMessage).with( channel: hook.reference_id, diff --git a/spec/listeners/action_cable_listener_spec.rb b/spec/listeners/action_cable_listener_spec.rb index a70ff6675..8e7aed2a1 100644 --- a/spec/listeners/action_cable_listener_spec.rb +++ b/spec/listeners/action_cable_listener_spec.rb @@ -65,4 +65,19 @@ describe ActionCableListener do listener.conversation_typing_off(event) end end + + describe '#contact_deleted' do + let(:event_name) { :'contact.deleted' } + let!(:contact) { create(:contact, account: account) } + let!(:event) { Events::Base.new(event_name, Time.zone.now, contact: contact) } + + it 'sends message to account admins, inbox agents' do + expect(ActionCableBroadcastJob).to receive(:perform_later).with( + [agent.pubsub_token, admin.pubsub_token], + 'contact.deleted', + contact.push_event_data.merge(account_id: account.id) + ) + listener.contact_deleted(event) + end + end end diff --git a/spec/listeners/notification_listener_spec.rb b/spec/listeners/notification_listener_spec.rb index 4cbf3243d..e1b3a6a56 100644 --- a/spec/listeners/notification_listener_spec.rb +++ b/spec/listeners/notification_listener_spec.rb @@ -3,7 +3,8 @@ describe NotificationListener do let(:listener) { described_class.instance } let!(:account) { create(:account) } let!(:user) { create(:user, account: account) } - let!(:agent_with_notification) { create(:user, account: account) } + let!(:first_agent) { create(:user, account: account) } + let!(:second_agent) { create(:user, account: account) } let!(:agent_with_out_notification) { create(:user, account: account) } let!(:inbox) { create(:inbox, account: account) } let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } @@ -13,12 +14,12 @@ describe NotificationListener do context 'when conversation is created' do it 'creates notifications for inbox members who have notifications turned on' do - notification_setting = agent_with_notification.notification_settings.first + notification_setting = first_agent.notification_settings.first notification_setting.selected_email_flags = [:email_conversation_creation] notification_setting.selected_push_flags = [] notification_setting.save! - create(:inbox_member, user: agent_with_notification, inbox: inbox) + create(:inbox_member, user: first_agent, inbox: inbox) conversation.reload event = Events::Base.new(event_name, Time.zone.now, conversation: conversation) @@ -48,7 +49,7 @@ describe NotificationListener do let(:event_name) { :'message.created' } before do - notification_setting = agent_with_notification.notification_settings.find_by(account_id: account.id) + notification_setting = first_agent.notification_settings.find_by(account_id: account.id) notification_setting.selected_email_flags = [:email_conversation_mention] notification_setting.selected_push_flags = [] notification_setting.save! @@ -60,14 +61,14 @@ describe NotificationListener do allow(NotificationBuilder).to receive(:new).and_return(builder) allow(builder).to receive(:perform) - create(:inbox_member, user: agent_with_notification, inbox: inbox) + create(:inbox_member, user: first_agent, inbox: inbox) conversation.reload message = build( :message, conversation: conversation, account: account, - content: "hi [#{agent_with_notification.name}](mention://user/#{agent_with_notification.id}/#{agent_with_notification.name})", + content: "hi [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name})", private: true ) @@ -75,7 +76,40 @@ describe NotificationListener do listener.message_created(event) expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention', - user: agent_with_notification, + user: first_agent, + account: account, + primary_actor: message) + end + end + + context 'when message contains multiple mentions' do + it 'creates notifications for inbox member who was mentioned' do + builder = double + allow(NotificationBuilder).to receive(:new).and_return(builder) + allow(builder).to receive(:perform) + create(:inbox_member, user: first_agent, inbox: inbox) + create(:inbox_member, user: second_agent, inbox: inbox) + conversation.reload + + message = build( + :message, + conversation: conversation, + account: account, + content: "hey [#{second_agent.name}](mention://user/#{second_agent.id}/#{second_agent.name})/ + [#{first_agent.name}](mention://user/#{first_agent.id}/#{first_agent.name}), + please look in to this?", + private: true + ) + + event = Events::Base.new(event_name, Time.zone.now, message: message) + listener.message_created(event) + + expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention', + user: second_agent, + account: account, + primary_actor: message) + expect(NotificationBuilder).to have_received(:new).with(notification_type: 'conversation_mention', + user: first_agent, account: account, primary_actor: message) end @@ -87,7 +121,7 @@ describe NotificationListener do allow(NotificationBuilder).to receive(:new).and_return(builder) allow(builder).to receive(:perform) - create(:inbox_member, user: agent_with_notification, inbox: inbox) + create(:inbox_member, user: first_agent, inbox: inbox) conversation.reload message = build( diff --git a/spec/mailboxes/application_mailbox_spec.rb b/spec/mailboxes/application_mailbox_spec.rb index be33732c4..34f7a8adc 100644 --- a/spec/mailboxes/application_mailbox_spec.rb +++ b/spec/mailboxes/application_mailbox_spec.rb @@ -6,6 +6,7 @@ RSpec.describe ApplicationMailbox, type: :mailbox do describe 'route the inbound mail to appropriate mailbox' do let(:welcome_mail) { create_inbound_email_from_fixture('welcome.eml') } let(:reply_mail) { create_inbound_email_from_fixture('reply.eml') } + let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply.eml') } let(:support_mail) { create_inbound_email_from_fixture('support.eml') } describe 'Default' do @@ -24,6 +25,13 @@ RSpec.describe ApplicationMailbox, type: :mailbox do expect(dbl).to receive(:perform_processing).and_return(true) described_class.route reply_mail end + + it 'routes reply emails to Reply Mailbox without uuid' do + dbl = double + expect(ReplyMailbox).to receive(:new).and_return(dbl) + expect(dbl).to receive(:perform_processing).and_return(true) + described_class.route reply_mail_without_uuid + end end describe 'Support' do diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb index f79ba4e5c..bc653de98 100644 --- a/spec/mailboxes/reply_mailbox_spec.rb +++ b/spec/mailboxes/reply_mailbox_spec.rb @@ -13,28 +13,55 @@ RSpec.describe ReplyMailbox, type: :mailbox do %w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments subject text_content to] end - before do - # this UUID is hardcoded in the reply.eml, that's why we are updating this - conversation.uuid = '6bdc3f4d-0bec-4515-a284-5d916fdde489' - conversation.save + context 'with reply uuid present' do + before do + # this UUID is hardcoded in the reply.eml, that's why we are updating this + conversation.uuid = '6bdc3f4d-0bec-4515-a284-5d916fdde489' + conversation.save - described_subject + described_subject + end + + it 'add the mail content as new message on the conversation' do + expect(conversation.messages.last.content).to eq("Let's talk about these images:") + end + + it 'add the attachments' do + expect(conversation.messages.last.attachments.count).to eq(2) + end + + it 'have proper content_attributes with details of email' do + expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes) + end + + it 'set proper content_type' do + expect(conversation.messages.last.content_type).to eq('incoming_email') + end end - it 'add the mail content as new message on the conversation' do - expect(conversation.messages.last.content).to eq("Let's talk about these images:") - end + context 'with in reply to email' do + let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') } + let(:described_subject) { described_class.receive reply_mail_without_uuid } + let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) } + let(:conversation_1) do + create( + :conversation, + assignee: agent, + inbox: email_channel.inbox, + account: account, + additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" } + ) + end - it 'add the attachments' do - expect(conversation.messages.last.attachments.count).to eq(2) - end + before do + conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489') + reply_mail_without_uuid.mail['In-Reply-To'] = '' + end - it 'have proper content_attributes with details of email' do - expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes) - end - - it 'set proper content_type' do - expect(conversation.messages.last.content_type).to eq('incoming_email') + it 'find channel with reply to mail' do + described_subject + expect(conversation_1.messages.last.content).to eq("Let's talk about these images:") + end end end end diff --git a/spec/mailboxes/support_mailbox_spec.rb b/spec/mailboxes/support_mailbox_spec.rb index 2998b2aa2..6b7e91fe3 100644 --- a/spec/mailboxes/support_mailbox_spec.rb +++ b/spec/mailboxes/support_mailbox_spec.rb @@ -5,6 +5,7 @@ RSpec.describe SupportMailbox, type: :mailbox do describe 'add mail as a new ticket in the email inbox' do let(:account) { create(:account) } + let(:agent) { create(:user, email: 'agent1@example.com', account: account) } let!(:channel_email) { create(:channel_email, account: account) } let(:support_mail) { create_inbound_email_from_fixture('support.eml') } let(:described_subject) { described_class.receive support_mail } @@ -32,7 +33,9 @@ RSpec.describe SupportMailbox, type: :mailbox do end it 'create a new contact as the sender of the email' do + email_sender = Mail::Address.new(support_mail.mail[:from].value).name expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first) + expect(conversation.contact.name).to eq(email_sender) end it 'add the mail content as new message on the conversation' do @@ -52,6 +55,28 @@ RSpec.describe SupportMailbox, type: :mailbox do end end + describe 'Sender without name' do + let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') } + let(:described_subject) { described_class.receive support_mail_without_sender_name } + + it 'create a new contact with the email' do + described_subject + email_sender = support_mail_without_sender_name.mail.from.first.split('@').first + expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first) + expect(conversation.contact.name).to eq(email_sender) + end + end + + describe 'Sender with upcase mail address' do + let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') } + let(:described_subject) { described_class.receive support_mail_without_sender_name } + + it 'create a new inbox with the email case insensitive' do + described_subject + expect(conversation.inbox.id).to eq(channel_email.inbox.id) + end + end + describe 'handle inbox contacts' do let(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) } let(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) } @@ -76,7 +101,46 @@ RSpec.describe SupportMailbox, type: :mailbox do it 'create new contact with original sender' do described_subject + email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name + expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value) + expect(conversation.contact.name).to eq(email_sender) + end + end + + describe 'when mail has in reply to email' do + let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') } + let(:described_subject) { described_class.receive reply_mail_without_uuid } + let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) } + + before do + email_channel + reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123' + end + + it 'create channel with reply to mail' do + described_subject + conversation_1 = Conversation.last + + expect(conversation_1.messages.last.content).to eq("Let's talk about these images:") + expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123') + end + + it 'append message to email conversation with same in reply to' do + described_subject + conversation_1 = Conversation.last + + expect(conversation_1.messages.last.content).to eq("Let's talk about these images:") + expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123') + expect(conversation_1.messages.count).to eq(1) + + reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123' + + described_class.receive reply_mail_without_uuid + + expect(conversation_1.messages.last.content).to eq("Let's talk about these images:") + expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123') + expect(conversation_1.messages.count).to eq(2) end end end diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 598549708..c89509785 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -7,6 +7,7 @@ RSpec.describe ConversationReplyMailer, type: :mailer do let!(:account) { create(:account) } let!(:agent) { create(:user, email: 'agent1@example.com', account: account) } let(:class_instance) { described_class.new } + let(:email_channel) { create(:channel_email, account: account) } before do allow(described_class).to receive(:new).and_return(class_instance) @@ -15,9 +16,28 @@ RSpec.describe ConversationReplyMailer, type: :mailer do context 'with summary' do let(:conversation) { create(:conversation, account: account, assignee: agent) } - let(:message) { create(:message, account: account, conversation: conversation) } + let(:message) do + create(:message, + account: account, + conversation: conversation, + content_attributes: { + cc_emails: 'agent_cc1@example.com', + bcc_emails: 'agent_bcc1@example.com' + }) + end + let(:cc_message) do + create(:message, + account: account, + message_type: :outgoing, + conversation: conversation, + content_attributes: { + cc_emails: 'agent_cc1@example.com', + bcc_emails: 'agent_bcc1@example.com' + }) + end let(:private_message) { create(:message, account: account, content: 'This is a private message', conversation: conversation) } - let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } + let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } + let(:cc_mail) { described_class.reply_with_summary(cc_message.conversation, message.id).deliver_now } it 'renders the subject' do expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation") @@ -37,12 +57,17 @@ RSpec.describe ConversationReplyMailer, type: :mailer do conversation.update(contact_last_seen_at: Time.zone.now) expect(mail).to eq nil end + + it 'will send email to cc and bcc email addresses' do + expect(cc_mail.cc.first).to eq(cc_message.content_attributes[:cc_emails]) + expect(cc_mail.bcc.first).to eq(cc_message.content_attributes[:bcc_emails]) + end end context 'without assignee' do let(:conversation) { create(:conversation, assignee: nil) } let(:message) { create(:message, conversation: conversation) } - let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } + let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } it 'has correct name' do expect(mail[:from].display_names).to eq(['Notifications from Inbox']) @@ -60,7 +85,7 @@ RSpec.describe ConversationReplyMailer, type: :mailer do account: account, message_type: 'outgoing').reload end - let(:mail) { described_class.reply_without_summary(message_1.conversation, Time.zone.now - 1.minute).deliver_now } + let(:mail) { described_class.reply_without_summary(message_2.conversation, message_2.id).deliver_now } before do message_2.save @@ -89,12 +114,30 @@ RSpec.describe ConversationReplyMailer, type: :mailer do end end + context 'with email reply' do + let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } + let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } + let(:mail) { described_class.email_reply(message).deliver_now } + + it 'renders the subject' do + expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation") + end + + it 'renders the body' do + expect(mail.decoded).to include message.content + end + + it 'updates the source_id' do + expect(mail.message_id).to eq message.source_id + end + end + context 'when custom domain and email is not enabled' do let(:inbox) { create(:inbox, account: account) } let(:inbox_member) { create(:inbox_member, user: agent, inbox: inbox) } let(:conversation) { create(:conversation, assignee: agent, inbox: inbox_member.inbox, account: account) } let!(:message) { create(:message, conversation: conversation, account: account) } - let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } + let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } let(:domain) { account.inbound_email_domain } it 'renders the receiver email' do @@ -118,7 +161,7 @@ RSpec.describe ConversationReplyMailer, type: :mailer do let(:inbox) { create(:inbox, account: account, email_address: 'noreply@chatwoot.com') } let(:conversation) { create(:conversation, assignee: agent, inbox: inbox, account: account) } let!(:message) { create(:message, conversation: conversation, account: account) } - let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } + let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } it 'set reply to email address as inbox email address' do expect(mail.from).to eq([inbox.email_address]) @@ -130,7 +173,7 @@ RSpec.describe ConversationReplyMailer, type: :mailer do let(:account) { create(:account) } let(:conversation) { create(:conversation, assignee: agent, account: account).reload } let(:message) { create(:message, conversation: conversation, account: account, inbox: conversation.inbox) } - let(:mail) { described_class.reply_with_summary(message.conversation, Time.zone.now).deliver_now } + let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now } before do account = conversation.account @@ -142,7 +185,7 @@ RSpec.describe ConversationReplyMailer, type: :mailer do it 'sets reply to email to be based on the domain' do reply_to_email = "reply+#{message.conversation.uuid}@#{conversation.account.domain}" - reply_to = "#{agent.available_name} <#{reply_to_email}>" + reply_to = "#{agent.available_name} from #{conversation.inbox.name} <#{reply_to_email}>" expect(mail['REPLY-TO'].value).to eq(reply_to) expect(mail.reply_to).to eq([reply_to_email]) end diff --git a/spec/models/channel/telegram_spec.rb b/spec/models/channel/telegram_spec.rb new file mode 100644 index 000000000..de174d89c --- /dev/null +++ b/spec/models/channel/telegram_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe Channel::Telegram do + let(:telegram_channel) { create(:channel_telegram) } + + context 'when a valid message and empty attachments' do + it 'send message' do + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + + telegram_message_response = double + + allow(telegram_message_response).to receive(:success?).and_return(true) + allow(telegram_message_response).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_123' } }) + allow(telegram_channel).to receive(:message_request).and_return(telegram_message_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123') + end + end + + context 'when a empty message and valid attachments' do + let(:message) do + create(:message, message_type: :outgoing, content: nil, + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + end + + it 'send image' do + telegram_attachment_response = double + 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') + + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') + end + + it 'send document' do + telegram_attachment_response = double + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: File.open(Rails.root.join('spec/assets/attachment.pdf')), filename: 'attachment.pdf', + content_type: 'application/pdf') + + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_456' }] }) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456') + end + end + + context 'when a valid message and valid attachment' do + it 'send both message and attachment' do + message = create(:message, message_type: :outgoing, content: 'test', + conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })) + + telegram_message_response = double + telegram_attachment_response = double + 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') + + allow(telegram_message_response).to receive(:success?).and_return(true) + allow(telegram_message_response).to receive(:parsed_response).and_return({ 'result' => { 'message_id' => 'telegram_456' } }) + allow(telegram_attachment_response).to receive(:success?).and_return(true) + allow(telegram_attachment_response).to receive(:parsed_response).and_return({ 'result' => [{ 'message_id' => 'telegram_789' }] }) + + allow(telegram_channel).to receive(:message_request).and_return(telegram_message_response) + allow(telegram_channel).to receive(:attachments_request).and_return(telegram_attachment_response) + expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_789') + end + end +end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 62e4030d3..9bd83ba22 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -354,6 +354,7 @@ RSpec.describe Conversation, type: :model do timestamp: conversation.last_activity_at.to_i, can_reply: true, channel: 'Channel::WebWidget', + snoozed_until: conversation.snoozed_until, contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, unread_count: 0 diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 215e9d5a6..bd8b6ad19 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -39,4 +39,20 @@ RSpec.describe Label, type: :model do expect(duplicate_label.valid?).to eq false end end + + describe '.after_update_commit' do + let(:label) { create(:label) } + + it 'calls update job' do + expect(Labels::UpdateJob).to receive(:perform_later).with('new-title', label.title, label.account_id) + + label.update(title: 'new-title') + end + + it 'does not call update job if title is not updated' do + expect(Labels::UpdateJob).not_to receive(:perform_later) + + label.update(description: 'new-description') + end + end end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 43b997b47..bc8d79ac5 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -70,6 +70,14 @@ RSpec.describe Message, type: :model do expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) end + it 'calls EmailReply worker if the channel is email' do + message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account)) + allow(EmailReplyWorker).to receive(:perform_in).and_return(true) + message.message_type = 'outgoing' + message.save! + expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id) + end + it 'wont call notify email method unless its website or email channel' do message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account)) allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true) @@ -77,4 +85,13 @@ RSpec.describe Message, type: :model do expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) end end + + context 'when content_type is blank' do + let(:message) { build(:message, content_type: nil, account: create(:account)) } + + it 'sets content_type as text' do + message.save! + expect(message.content_type).to eq 'text' + end + end end diff --git a/spec/models/working_hour_spec.rb b/spec/models/working_hour_spec.rb index d7c49b0bf..73cb4efdf 100644 --- a/spec/models/working_hour_spec.rb +++ b/spec/models/working_hour_spec.rb @@ -26,4 +26,28 @@ RSpec.describe WorkingHour do expect(described_class.today.closed_now?).to be true end end + + context 'when on friday 12:30pm' do + before do + Time.zone = 'UTC' + create(:working_hour) + travel_to '10.09.2021 12:30'.to_datetime + end + + it 'is considered to be in business hours' do + expect(described_class.today.open_now?).to be true + end + end + + context 'when on friday 17:30pm' do + before do + Time.zone = 'UTC' + create(:working_hour) + travel_to '10.09.2021 17:30'.to_datetime + end + + it 'is considered out of office' do + expect(described_class.today.closed_now?).to be true + end + end end diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index 2e04e8e75..f3ba9e140 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -22,6 +22,7 @@ RSpec.describe Conversations::EventDataPresenter do can_reply: conversation.can_reply?, channel: conversation.inbox.channel_type, timestamp: conversation.last_activity_at.to_i, + snoozed_until: conversation.snoozed_until, contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, unread_count: 0 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 diff --git a/spec/services/labels/update_service_spec.rb b/spec/services/labels/update_service_spec.rb new file mode 100644 index 000000000..a71ec3271 --- /dev/null +++ b/spec/services/labels/update_service_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +describe Labels::UpdateService do + let(:account) { create(:account) } + let(:conversation) { create(:conversation, account: account) } + let(:label) { create(:label, account: account) } + let(:contact) { conversation.contact } + + before do + conversation.label_list.add(label.title) + conversation.save! + + contact.label_list.add(label.title) + contact.save! + end + + describe '#perform' do + it 'updates associated conversations/contacts labels' do + expect(conversation.label_list).to eq([label.title]) + expect(contact.label_list).to eq([label.title]) + + described_class.new( + new_label_title: 'updated-label-title', + old_label_title: label.title, + account_id: account.id + ).perform + + expect(conversation.reload.label_list).to eq(['updated-label-title']) + expect(contact.reload.label_list).to eq(['updated-label-title']) + end + end +end diff --git a/spec/services/line/incoming_message_service_spec.rb b/spec/services/line/incoming_message_service_spec.rb index 06ff3ade3..4b03fc8f2 100644 --- a/spec/services/line/incoming_message_service_spec.rb +++ b/spec/services/line/incoming_message_service_spec.rb @@ -35,6 +35,76 @@ describe Line::IncomingMessageService do }.with_indifferent_access end + let(:image_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + }, + 'message': { + 'type': 'image', + 'id': '354718', + 'contentProvider': { + 'type': 'line' + } + } + }, + { + 'replyToken': '8cf9239d56244f4197887e939187e19e', + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + } + } + ] + }.with_indifferent_access + end + + let(:video_params) do + { + 'destination': '2342234234', + 'events': [ + { + 'replyToken': '0f3779fba3b349968c5d07db31eab56f', + 'type': 'message', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + }, + 'message': { + 'type': 'video', + 'id': '354718', + 'contentProvider': { + 'type': 'line' + } + } + }, + { + 'replyToken': '8cf9239d56244f4197887e939187e19e', + 'type': 'follow', + 'mode': 'active', + 'timestamp': 1_462_629_479_859, + 'source': { + 'type': 'user', + 'userId': 'U4af4980629' + } + } + ] + }.with_indifferent_access + end + describe '#perform' do context 'when valid text message params' do it 'creates appropriate conversations, message and contacts' do @@ -55,5 +125,63 @@ describe Line::IncomingMessageService do expect(line_channel.inbox.messages.first.content).to eq('Hello, world') end end + + context 'when valid image message params' do + it 'creates appropriate conversations, message and contacts' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + allow(line_bot).to receive(:get_message_content).and_return( + OpenStruct.new({ + body: Base64.encode64(file.read), + content_type: 'image/png' + }) + ) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + described_class.new(inbox: line_channel.inbox, params: image_params).perform + expect(line_channel.inbox.conversations).not_to eq(0) + expect(Contact.all.first.name).to eq('LINE Test') + expect(line_channel.inbox.messages.first.content).to eq(nil) + expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('image') + expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.png') + end + end + + context 'when valid video message params' do + it 'creates appropriate conversations, message and contacts' do + line_bot = double + line_user_profile = double + allow(Line::Bot::Client).to receive(:new).and_return(line_bot) + allow(line_bot).to receive(:get_profile).and_return(line_user_profile) + file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp4'), 'video/mp4') + allow(line_bot).to receive(:get_message_content).and_return( + OpenStruct.new({ + body: Base64.encode64(file.read), + content_type: 'video/mp4' + }) + ) + allow(line_user_profile).to receive(:body).and_return( + { + 'displayName': 'LINE Test', + 'userId': 'U4af4980629', + 'pictureUrl': 'https://test.com' + }.to_json + ) + described_class.new(inbox: line_channel.inbox, params: video_params).perform + expect(line_channel.inbox.conversations).not_to eq(0) + expect(Contact.all.first.name).to eq('LINE Test') + expect(line_channel.inbox.messages.first.content).to eq(nil) + expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('video') + expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.mp4') + end + end end end diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index 526d1888d..87264a6ae 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -154,25 +154,60 @@ describe ::MessageTemplates::HookExecutionService do end end - # TODO: remove this if this hook is removed - # context 'when it is after working hours' do - # it 'calls ::MessageTemplates::Template::OutOfOffice' do - # contact = create :contact - # conversation = create :conversation, contact: contact + context 'when it is after working hours' do + it 'calls ::MessageTemplates::Template::OutOfOffice' do + contact = create :contact + conversation = create :conversation, contact: contact - # conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') - # conversation.inbox.working_hours.today.update!(closed_all_day: true) + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) - # out_of_office_service = double + out_of_office_service = double - # allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) - # allow(out_of_office_service).to receive(:perform).and_return(true) + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) - # # described class gets called in message after commit - # message = create(:message, conversation: conversation) + # described class gets called in message after commit + message = create(:message, conversation: conversation) - # expect(::MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation) - # expect(out_of_office_service).to have_received(:perform) - # end - # end + expect(::MessageTemplates::Template::OutOfOffice).to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).to have_received(:perform) + end + + it 'will not calls ::MessageTemplates::Template::OutOfOffice when outgoing message' do + contact = create :contact + conversation = create :conversation, contact: contact + + conversation.inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + conversation.inbox.working_hours.today.update!(closed_all_day: true) + + out_of_office_service = double + + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(true) + + # described class gets called in message after commit + message = create(:message, conversation: conversation, message_type: 'outgoing') + + expect(::MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).not_to have_received(:perform) + end + + it 'will not call ::MessageTemplates::Template::OutOfOffice if its a tweet conversation' do + twitter_channel = create(:channel_twitter_profile) + twitter_inbox = create(:inbox, channel: twitter_channel) + twitter_inbox.update(working_hours_enabled: true, out_of_office_message: 'We are out of office') + + conversation = create(:conversation, inbox: twitter_inbox, additional_attributes: { type: 'tweet' }) + + out_of_office_service = double + + allow(::MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service) + allow(out_of_office_service).to receive(:perform).and_return(false) + + message = create(:message, conversation: conversation) + expect(::MessageTemplates::Template::OutOfOffice).not_to have_received(:new).with(conversation: message.conversation) + expect(out_of_office_service).not_to receive(:perform) + end + end end diff --git a/spec/services/round_robin/manage_service_spec.rb b/spec/services/round_robin/manage_service_spec.rb index 8d0696b26..0deef3109 100644 --- a/spec/services/round_robin/manage_service_spec.rb +++ b/spec/services/round_robin/manage_service_spec.rb @@ -1,41 +1,43 @@ require 'rails_helper' describe RoundRobin::ManageService do + subject(:round_robin_manage_service) { ::RoundRobin::ManageService.new(inbox: inbox) } + let!(:account) { create(:account) } let!(:inbox) { create(:inbox, account: account) } let!(:inbox_members) { create_list(:inbox_member, 5, inbox: inbox) } - let(:subject) { ::RoundRobin::ManageService.new(inbox: inbox) } describe '#available_agent' do it 'gets the first available agent and move agent to end of the list' do expected_queue = [inbox_members[0].user_id, inbox_members[4].user_id, inbox_members[3].user_id, inbox_members[2].user_id, inbox_members[1].user_id].map(&:to_s) - subject.available_agent - expect(subject.send(:queue)).to eq(expected_queue) + round_robin_manage_service.available_agent + expect(round_robin_manage_service.send(:queue)).to eq(expected_queue) end it 'gets intersection of priority list and agent queue. get and move agent to the end of the list' do expected_queue = [inbox_members[2].user_id, inbox_members[4].user_id, inbox_members[3].user_id, inbox_members[1].user_id, inbox_members[0].user_id].map(&:to_s) - expect(subject.available_agent(priority_list: [inbox_members[3].user_id, inbox_members[2].user_id])).to eq inbox_members[2].user - expect(subject.send(:queue)).to eq(expected_queue) + expect(round_robin_manage_service.available_agent(priority_list: [inbox_members[3].user_id, + inbox_members[2].user_id])).to eq inbox_members[2].user + expect(round_robin_manage_service.send(:queue)).to eq(expected_queue) end it 'constructs round_robin_queue if queue is not present' do - subject.clear_queue - expect(subject.send(:queue)).to eq([]) - subject.available_agent + round_robin_manage_service.clear_queue + expect(round_robin_manage_service.send(:queue)).to eq([]) + round_robin_manage_service.available_agent # the service constructed the redis queue before performing - expect(subject.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort) + expect(round_robin_manage_service.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort) end it 'validates the queue and correct it before performing round robin' do # adding some invalid ids to queue - subject.add_agent_to_queue([2, 3, 5, 9]) - expect(subject.send(:queue).sort.map(&:to_i)).not_to eq(inbox_members.map(&:user_id).sort) - subject.available_agent + round_robin_manage_service.add_agent_to_queue([2, 3, 5, 9]) + expect(round_robin_manage_service.send(:queue).sort.map(&:to_i)).not_to eq(inbox_members.map(&:user_id).sort) + round_robin_manage_service.available_agent # the service have refreshed the redis queue before performing - expect(subject.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort) + expect(round_robin_manage_service.send(:queue).sort.map(&:to_i)).to eq(inbox_members.map(&:user_id).sort) end end end diff --git a/spec/services/telegram/incoming_message_service_spec.rb b/spec/services/telegram/incoming_message_service_spec.rb index 9a1433794..9ac85c047 100644 --- a/spec/services/telegram/incoming_message_service_spec.rb +++ b/spec/services/telegram/incoming_message_service_spec.rb @@ -41,5 +41,168 @@ describe Telegram::IncomingMessageService do expect(telegram_channel.inbox.conversations.count).to eq(0) end end + + context 'when valid audio messages params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.mp3') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'audio' => { + 'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX', + 'duration' => 243, + 'mime_type' => 'audio/mpeg', + 'file_size' => 3_897_500, + 'title' => 'Test music file' + } + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') + end + end + + context 'when valid image attachment params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.png') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'photo' => [{ + 'file_id' => 'AgACAgUAAxkBAAODYV3aGZlD6vhzKsE2WNmblsr6zKwAAi-tMRvCoeBWNQ1ENVBzJdwBAAMCAANzAAMhBA', + 'file_unique_id' => 'AQADL60xG8Kh4FZ4', 'file_size' => 1883, 'width' => 90, 'height' => 67 + }] + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') + end + end + + context 'when valid sticker attachment params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.png') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'sticker' => { + 'emoji' => '👍', 'width' => 512, 'height' => 512, 'set_name' => 'a834556273_by_HopSins_1_anim', 'is_animated' => 1, 'thumb' => { + 'file_id' => 'AAMCAQADGQEAA0dhXpKorj9CiRpNX3QOn7YPZ6XS4AAC4wADcVG-MexptyOf8SbfAQAHbQADIQQ', + 'file_unique_id' => 'AQAD4wADcVG-MXI', 'file_size' => 4690, 'width' => 128, 'height' => 128 + }, + 'file_id' => 'CAACAgEAAxkBAANHYV6SqK4_QokaTV90Dp-2D2el0uAAAuMAA3FRvjHsabcjn_Em3yEE', + 'file_unique_id' => 'AgAD4wADcVG-MQ', + 'file_size' => 7340 + } + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('image') + end + end + + context 'when valid video messages params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.mov') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'video' => { + 'duration' => 1, 'width' => 720, 'height' => 1280, 'file_name' => 'IMG_2170.MOV', 'mime_type' => 'video/mp4', 'thumb' => { + 'file_id' => 'AAMCBQADGQEAA4ZhXd78Xz6_c6gCzbdIkgGiXJcwwwACqwMAAp3x8Fbhf3EWamgCWAEAB20AAyEE', 'file_unique_id' => 'AQADqwMAAp3x8FZy', + 'file_size' => 11_462, 'width' => 180, 'height' => 320 + }, 'file_id' => 'BAACAgUAAxkBAAOGYV3e_F8-v3OoAs23SJIBolyXMMMAAqsDAAKd8fBW4X9xFmpoAlghBA', 'file_unique_id' => 'AgADqwMAAp3x8FY', + 'file_size' => 291_286 + } + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('video') + end + end + + context 'when valid voice attachment params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.oga') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'voice' => { + 'duration' => 2, 'mime_type' => 'audio/ogg', 'file_id' => 'AwACAgUAAxkBAANjYVwnWF_w8LYTchqVdK9dY7mbwYEAAskDAALCoeBWFvS2u4zS6HAhBA', + 'file_unique_id' => 'AgADyQMAAsKh4FY', 'file_size' => 11_833 + } + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('audio') + end + end + + context 'when valid document message params' do + it 'creates appropriate conversations, message and contacts' do + allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('https://chatwoot-public-assets.s3.amazonaws.com/test-files/rspec/sample.pdf') + params = { + 'update_id' => 2_342_342_343_242, + 'message' => { + 'message_id' => 1, + 'from' => { + 'id' => 23, 'is_bot' => false, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'language_code' => 'en' + }, + 'chat' => { 'id' => 23, 'first_name' => 'Sojan', 'last_name' => 'Jose', 'username' => 'sojan', 'type' => 'private' }, + 'date' => 1_631_132_077, + 'document' => { + 'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX', + 'file_name' => 'Screenshot 2021-09-27 at 2.01.14 PM.png', + 'mime_type' => 'application/png', + 'file_size' => 536_392 + } + } + }.with_indifferent_access + described_class.new(inbox: telegram_channel.inbox, params: params).perform + expect(telegram_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(telegram_channel.inbox.messages.first.attachments.first.file_type).to eq('file') + end + end end end diff --git a/spec/services/twilio/send_on_twilio_service_spec.rb b/spec/services/twilio/send_on_twilio_service_spec.rb index 9fc6e2d57..d4fc16048 100644 --- a/spec/services/twilio/send_on_twilio_service_spec.rb +++ b/spec/services/twilio/send_on_twilio_service_spec.rb @@ -28,6 +28,7 @@ describe Twilio::SendOnTwilioService do message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account) ::Twilio::SendOnTwilioService.new(message: message).perform expect(twilio_client).not_to have_received(:messages) + expect(message.reload.source_id).to be_nil end it 'if inbox channel is not twilio' do @@ -40,6 +41,7 @@ describe Twilio::SendOnTwilioService do message = create(:message, message_type: 'incoming', inbox: twilio_inbox, account: account) ::Twilio::SendOnTwilioService.new(message: message).perform expect(twilio_client).not_to have_received(:messages) + expect(message.reload.source_id).to be_nil end it 'if message has an source id' do diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb new file mode 100644 index 000000000..1c004a6d4 --- /dev/null +++ b/spec/services/whatsapp/incoming_message_service_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +describe Whatsapp::IncomingMessageService do + let!(:whatsapp_channel) { create(:channel_whatsapp) } + + describe '#perform' do + context 'when valid text message params' do + it 'creates appropriate conversations, message and contacts' do + params = { + 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], + 'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa', 'text' => { 'body' => 'Test' }, + 'timestamp' => '1633034394', 'type' => 'text' }] + }.with_indifferent_access + described_class.new(inbox: whatsapp_channel.inbox, params: params).perform + expect(whatsapp_channel.inbox.conversations.count).not_to eq(0) + expect(Contact.all.first.name).to eq('Sojan Jose') + expect(whatsapp_channel.inbox.messages.first.content).to eq('Test') + end + end + end +end diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb new file mode 100644 index 000000000..efb063aa1 --- /dev/null +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +describe Whatsapp::SendOnWhatsappService do + describe '#perform' do + context 'when a valid message' do + it 'calls channel.send_message' do + whatsapp_request = double + whatsapp_channel = create(:channel_whatsapp) + contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') + conversation = create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) + message = create(:message, message_type: :outgoing, content: 'test', + conversation: conversation) + allow(HTTParty).to receive(:post).and_return(whatsapp_request) + expect(HTTParty).to receive(:post).with( + 'https://waba.360dialog.io/v1/messages', + headers: { 'D360-API-KEY': 'test_key', 'Content-Type': 'application/json' }, + body: { to: '123456789', text: { body: 'test' }, type: 'text' }.to_json + ) + described_class.new(message: message).perform + end + end + end +end diff --git a/spec/support/slack_stubs.rb b/spec/support/slack_stubs.rb index d0e89221b..650a7cf2b 100644 --- a/spec/support/slack_stubs.rb +++ b/spec/support/slack_stubs.rb @@ -21,6 +21,20 @@ module SlackStubs } end + def slack_attachment_stub + { + token: '[FILTERED]', + team_id: 'TLST3048H', + api_app_id: 'A012S5UETV4', + event: message_event, + type: 'event_callback', + event_id: 'Ev013QUX3WV6', + event_time: 1_588_623_033, + authed_users: '[FILTERED]', + webhook: {} + } + end + def slack_message_stub_without_thread_ts { token: '[FILTERED]', @@ -52,6 +66,7 @@ module SlackStubs ts: '1588623033.006000', team: 'TLST3048H', blocks: message_blocks, + files: file_stub, thread_ts: '1588623023.005900', channel: 'G01354F6A6Q', event_ts: '1588623033.006000', @@ -59,6 +74,19 @@ module SlackStubs } end + def file_stub + [ + { + mimetype: 'image/png', + url_private: 'https://via.placeholder.com/250x250.png', + name: 'name_of_the_file', + title: 'title_of_the_file', + filetype: 'png', + url_private_download: 'https://via.placeholder.com/250x250.png' + } + ] + end + def message_blocks [ { diff --git a/spec/workers/conversation_reply_email_worker_spec.rb b/spec/workers/conversation_reply_email_worker_spec.rb index 469431c61..936242a06 100644 --- a/spec/workers/conversation_reply_email_worker_spec.rb +++ b/spec/workers/conversation_reply_email_worker_spec.rb @@ -2,8 +2,6 @@ require 'rails_helper' Sidekiq::Testing.fake! RSpec.describe ConversationReplyEmailWorker, type: :worker do - let(:perform_at) { (Time.zone.today + 6.hours).to_datetime } - let(:scheduled_job) { described_class.perform_at(perform_at, 1, Time.zone.now) } let(:conversation) { build(:conversation, display_id: nil) } let(:message) { build(:message, conversation: conversation, content_type: 'incoming_email', inbox: conversation.inbox) } let(:mailer) { double } @@ -29,18 +27,18 @@ RSpec.describe ConversationReplyEmailWorker, type: :worker do expect do described_class.perform_async end.to change(described_class.jobs, :size).by(1) - described_class.new.perform(1, Time.zone.now) + described_class.new.perform(1, message.id) end context 'with actions performed by the worker' do it 'calls ConversationSummaryMailer#reply_with_summary when last incoming message was not email' do - described_class.new.perform(1, Time.zone.now) + described_class.new.perform(1, message.id) expect(mailer).to have_received(:reply_with_summary) end it 'calls ConversationSummaryMailer#reply_without_summary when last incoming message was from email' do message.save - described_class.new.perform(1, Time.zone.now) + described_class.new.perform(1, message.id) expect(mailer).to have_received(:reply_without_summary) end end diff --git a/spec/workers/email_reply_worker_spec.rb b/spec/workers/email_reply_worker_spec.rb new file mode 100644 index 000000000..dc2d83e2b --- /dev/null +++ b/spec/workers/email_reply_worker_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe EmailReplyWorker, type: :worker do + let(:account) { create(:account) } + let(:channel) { create(:channel_email, account: account) } + let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) } + let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) } + let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) } + let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) } + let(:mailer) { double } + let(:mailer_action) { double } + + describe '#perform' do + before do + allow(ConversationReplyMailer).to receive(:with).and_return(mailer) + allow(mailer).to receive(:email_reply).and_return(mailer_action) + allow(mailer_action).to receive(:deliver_later).and_return(true) + end + + it 'calls mailer action with message' do + described_class.new.perform(message.id) + expect(mailer).to have_received(:email_reply).with(message) + expect(mailer_action).to have_received(:deliver_later) + end + + it 'does not call mailer action with a private message' do + described_class.new.perform(private_message.id) + expect(mailer).not_to have_received(:email_reply) + expect(mailer_action).not_to have_received(:deliver_later) + end + + it 'calls mailer action with a CSAT message' do + described_class.new.perform(template_message.id) + expect(mailer).to have_received(:email_reply).with(template_message) + expect(mailer_action).to have_received(:deliver_later) + end + + it 'does not call mailer action with an incoming message' do + described_class.new.perform(incoming_message.id) + expect(mailer).not_to have_received(:email_reply) + expect(mailer_action).not_to have_received(:deliver_later) + 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/parameters/report_type.yml b/swagger/parameters/report_type.yml index 6c78f964d..407478d9d 100644 --- a/swagger/parameters/report_type.yml +++ b/swagger/parameters/report_type.yml @@ -2,6 +2,6 @@ in: query name: report_type schema: type: string - enum: [account,agent,inbox,label] + enum: [account,agent,inbox,label,team] required: true description: Type of report 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 76% rename from swagger/paths/contact/crud.yml rename to swagger/paths/application/contacts/crud.yml index fafa89cb4..7f0089b4b 100644 --- a/swagger/paths/contact/crud.yml +++ b/swagger/paths/application/contacts/crud.yml @@ -48,3 +48,22 @@ put: description: Contact not found 403: description: Access denied + +delete: + tags: + - Contact + operationId: contactDelete + summary: Delete Contact + parameters: + - name: id + in: path + type: number + description: ID of the contact + required: true + responses: + 200: + description: Success + 401: + description: Unauthorized + 404: + description: Contact not found \ No newline at end of file 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 89% rename from swagger/paths/conversation/index.yml rename to swagger/paths/application/conversation/index.yml index 7c690fe56..022fe7911 100644 --- a/swagger/paths/conversation/index.yml +++ b/swagger/paths/application/conversation/index.yml @@ -15,7 +15,7 @@ get: - name: status in: query type: string - enum: ['open', 'resolved', 'pending'] + enum: ['open', 'resolved', 'pending', 'all'] - name: page in: query type: integer @@ -73,6 +73,12 @@ post: type: string enum: ['open', 'resolved', 'pending'] description: Specify the conversation whether it's pending, open, closed + assignee_id: + type: string + description: Agent Id for assigning a conversation to an agent + team_id: + type: string + description: Team Id for assigning a conversation to a team responses: 200: 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 2fb77e254..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,24 +310,24 @@ 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 # List -/api/v1/accounts/{id}/reports: +/api/v2/accounts/{id}/reports: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/report_metric' @@ -335,10 +348,10 @@ 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/v1/accounts/{id}/reports/summary: +/api/v2/accounts/{id}/reports/summary: parameters: - $ref: '#/parameters/account_id' - $ref: '#/parameters/report_type' @@ -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 d9254d8e5..996f2ad2f 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": [ @@ -1249,6 +1462,33 @@ "description": "Access denied" } } + }, + "delete": { + "tags": [ + "Contact" + ], + "operationId": "contactDelete", + "summary": "Delete Contact", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "number", + "description": "ID of the contact", + "required": true + } + ], + "responses": { + "200": { + "description": "Success" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Contact not found" + } + } } }, "/api/v1/accounts/{account_id}/contacts/{id}/conversations": { @@ -1461,7 +1701,8 @@ "enum": [ "open", "resolved", - "pending" + "pending", + "all" ] }, { @@ -1555,6 +1796,14 @@ "pending" ], "description": "Specify the conversation whether it's pending, open, closed" + }, + "assignee_id": { + "type": "string", + "description": "Agent Id for assigning a conversation to an agent" + }, + "team_id": { + "type": "string", + "description": "Team Id for assigning a conversation to a team" } } } @@ -3061,7 +3310,7 @@ } } }, - "/api/v1/accounts/{id}/reports": { + "/api/v2/accounts/{id}/reports": { "parameters": [ { "$ref": "#/parameters/account_id" @@ -3124,7 +3373,7 @@ } } }, - "/api/v1/accounts/{id}/reports/summary": { + "/api/v2/accounts/{id}/reports/summary": { "parameters": [ { "$ref": "#/parameters/account_id" @@ -3404,7 +3653,7 @@ "type": "object", "properties": { "id": { - "type": "number" + "type": "integer" }, "uid": { "type": "string" @@ -3422,7 +3671,7 @@ "type": "string" }, "account_id": { - "type": "number" + "type": "integer" }, "role": { "type": "string", @@ -3434,6 +3683,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." @@ -4471,7 +4733,8 @@ "account", "agent", "inbox", - "label" + "label", + "team" ] }, "required": true, @@ -4527,6 +4790,7 @@ "name": "Application", "tags": [ "Account AgentBots", + "Agent", "Contact", "Conversation", "Conversation Assignment", diff --git a/yarn.lock b/yarn.lock index 75ba1285b..e68f62084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14290,9 +14290,9 @@ tmp@~0.1.0: rimraf "^2.6.3" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-arraybuffer@^1.0.0: version "1.0.1" @@ -14749,9 +14749,9 @@ url-loader@^4.1.1: schema-utils "^3.0.0" url-parse@^1.4.3, url-parse@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" - integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== + version "1.5.3" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0"