diff --git a/.circleci/config.yml b/.circleci/config.yml index 235289a2f..2c55d21b2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,7 @@ defaults: &defaults # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ - image: circleci/postgres:9.4 + - image: circleci/redis:5.0.7-alpine environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f diff --git a/.codeclimate.yml b/.codeclimate.yml index f12f590a2..0bf089f4b 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -10,7 +10,7 @@ plugins: scss-lint: enabled: true brakeman: - enabled: true + enabled: false checks: similar-code: enabled: false diff --git a/.env.example b/.env.example index 2aafa0645..1b9cbb3e6 100644 --- a/.env.example +++ b/.env.example @@ -19,10 +19,16 @@ FB_VERIFY_TOKEN= FB_APP_SECRET= FB_APP_ID= +#twitter app +TWITTER_CONSUMER_KEY= +TWITTER_CONSUMER_SECRET= + #mail MAILER_SENDER_EMAIL=accounts@chatwoot.com SMTP_PORT=1025 SMTP_DOMAIN=chatwoot.com +# if you are running docker-compose, set SMTP_ADDRESS value as "mailhog", +# else set the value as "localhost" SMTP_ADDRESS=mailhog SMTP_USERNAME= SMTP_PASSWORD= @@ -42,6 +48,10 @@ AWS_REGION= #sentry SENTRY_DSN= +# Credentials to access sidekiq dashboard in production +SIDEKIQ_AUTH_USERNAME= +SIDEKIQ_AUTH_PASSWORD= + #### This environment variables are only required in hosted version which has billing ENABLE_BILLING= diff --git a/.rubocop.yml b/.rubocop.yml index 8fa1dd4a5..a21f6eada 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -4,16 +4,25 @@ require: - rubocop-rspec inherit_from: .rubocop_todo.yml -Metrics/LineLength: +Layout/LineLength: Max: 150 +Metrics/ClassLength: + Max: 125 RSpec/ExampleLength: Max: 15 -Documentation: +Style/Documentation: Enabled: false Style/FrozenStringLiteralComment: Enabled: false Style/SymbolArray: Enabled: false +Style/GlobalVars: + Exclude: + - 'config/initializers/redis.rb' + - 'lib/redis/alfred.rb' + - 'app/controllers/api/v1/webhooks_controller.rb' + - 'app/services/twitter/send_reply_service.rb' + - 'spec/services/twitter/send_reply_service_spec.rb' Metrics/BlockLength: Exclude: - spec/**/* diff --git a/Gemfile b/Gemfile index d0e53f197..284e9bff7 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,9 @@ gem 'valid_email2' gem 'uglifier' ##-- for active storage --## +gem 'aws-sdk-s3', require: false +gem 'azure-storage', require: false +gem 'google-cloud-storage', require: false gem 'mini_magick' ##-- gems for database --# @@ -58,6 +61,9 @@ gem 'chargebee' gem 'facebook-messenger' gem 'telegram-bot-ruby' gem 'twitter' +# twitty will handle subscription of twitter account events +gem 'twitty', git: 'https://github.com/chatwoot/twitty' + # facebook client gem 'koala' # Random name generator @@ -68,9 +74,7 @@ gem 'haikunator' gem 'brakeman' gem 'sentry-raven' -##-- TODO: move these gems to appropriate groups --## -# remove this gem in favor of active storage - github #158 -gem 'carrierwave-aws' +##-- background job processing --## gem 'sidekiq' group :development do diff --git a/Gemfile.lock b/Gemfile.lock index a7bb9d402..3bf10b7ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,10 @@ +GIT + remote: https://github.com/chatwoot/twitty + revision: a005f8f6740fc8d2d3500701e1ab4ab0f1416c26 + specs: + twitty (0.1.0) + oauth + GEM remote: https://rubygems.org/ specs: @@ -66,15 +73,15 @@ GEM activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 14.0) ast (2.4.0) - attr_extras (6.2.1) + attr_extras (6.2.2) aws-eventstream (1.0.3) - aws-partitions (1.259.0) - aws-sdk-core (3.86.0) + aws-partitions (1.268.0) + aws-sdk-core (3.89.1) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.27.0) + aws-sdk-kms (1.28.0) aws-sdk-core (~> 3, >= 3.71.0) aws-sigv4 (~> 1.1) aws-sdk-s3 (1.60.1) @@ -87,15 +94,24 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + azure-core (0.1.15) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6) + azure-storage (0.15.0.preview) + azure-core (~> 0.1) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6, >= 1.6.8) bcrypt (3.1.13) bindex (0.8.1) bootsnap (1.4.5) msgpack (~> 1.0) brakeman (4.7.2) - browser (2.7.1) + browser (3.0.3) buftok (0.2.0) builder (3.2.4) - bullet (6.0.2) + bullet (6.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) @@ -103,18 +119,8 @@ GEM bundler-audit (0.6.1) bundler (>= 1.2.0, < 3) thor (~> 0.18) - byebug (11.0.1) - carrierwave (2.0.2) - activemodel (>= 5.0.0) - activesupport (>= 5.0.0) - addressable (~> 2.6) - image_processing (~> 1.1) - mimemagic (>= 0.3.0) - mini_mime (>= 0.1.3) - carrierwave-aws (1.4.0) - aws-sdk-s3 (~> 1.0) - carrierwave (>= 0.7, < 2.1) - chargebee (2.7.1) + byebug (11.1.1) + chargebee (2.7.3) json_pure (~> 2.1) rest-client (>= 1.8, < 3.0) coderay (1.1.2) @@ -122,7 +128,9 @@ GEM descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.5) connection_pool (2.2.2) - crass (1.0.5) + crass (1.0.6) + declarative (0.0.10) + declarative-option (0.1.0) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) devise (4.7.1) @@ -136,6 +144,7 @@ GEM devise (> 3.5.2, < 5) rails (>= 4.2.0, < 6.1) diff-lcs (1.3) + digest-crc (0.4.1) docile (1.3.2) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) @@ -154,14 +163,44 @@ GEM factory_bot_rails (5.1.1) factory_bot (~> 5.1.0) railties (>= 4.2.0) - faker (2.9.0) - i18n (>= 1.6, < 1.8) - faraday (0.17.1) + faker (2.10.1) + i18n (>= 1.6, < 2) + faraday (0.17.3) multipart-post (>= 1.2, < 3) - ffi (1.11.3) - foreman (0.86.0) + faraday_middleware (0.14.0) + faraday (>= 0.7.4, < 1.0) + ffi (1.12.1) + foreman (0.87.0) globalid (0.4.2) activesupport (>= 4.2.0) + google-api-client (0.36.4) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.9) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + signet (~> 0.12) + google-cloud-core (1.5.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.3.0) + faraday (~> 0.11) + google-cloud-errors (1.0.0) + google-cloud-storage (1.25.1) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-api-client (~> 0.33) + google-cloud-core (~> 1.2) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.10.0) + faraday (~> 0.12) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (~> 0.12) haikunator (1.1.0) hashie (4.0.0) http (3.3.0) @@ -172,17 +211,15 @@ GEM http-accept (1.7.0) http-cookie (1.0.3) domain_name (~> 0.5) - http-form_data (2.1.1) + http-form_data (2.2.0) http_parser.rb (0.6.0) httparty (0.17.3) mime-types (~> 3.0) multi_xml (>= 0.5.2) - i18n (1.7.0) + httpclient (2.8.3) + i18n (1.8.2) concurrent-ruby (~> 1.0) ice_nine (0.11.2) - image_processing (1.10.0) - mini_magick (>= 4.9.5, < 5) - ruby-vips (>= 2.0.13, < 3) inflecto (0.0.2) jaro_winkler (1.5.4) jbuilder (2.9.1) @@ -221,19 +258,21 @@ GEM mini_mime (>= 0.1.1) marcel (0.3.3) mimemagic (~> 0.3.2) + memoist (0.16.2) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) method_source (0.9.2) - mime-types (3.3) + mime-types (3.3.1) mime-types-data (~> 3.2015) mime-types-data (3.2019.1009) mimemagic (0.3.3) - mini_magick (4.9.5) + mini_magick (4.10.1) mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.13.0) + minitest (5.14.0) mock_redis (0.22.0) msgpack (1.3.1) + multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) naught (1.1.0) @@ -242,27 +281,29 @@ GEM nio4r (2.5.2) nokogiri (1.10.7) mini_portile2 (~> 2.4.0) + oauth (0.5.4) orm_adapter (0.5.0) + os (1.0.1) parallel (1.19.1) - parser (2.6.5.0) + parser (2.7.0.2) ast (~> 2.4.0) - pg (1.1.4) + pg (1.2.2) pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.1) + public_suffix (4.0.3) puma (4.3.1) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - rack (2.0.8) - rack-cache (1.10.0) + rack (2.1.1) + rack-cache (1.11.0) rack (>= 0.4) - rack-cors (1.1.0) + rack-cors (1.1.1) rack (>= 2.0.0) - rack-protection (2.0.7) + rack-protection (2.0.8.1) rack rack-proxy (0.6.5) rack @@ -297,7 +338,7 @@ GEM rainbow (3.0.0) rake (13.0.1) rb-fsevent (0.10.3) - rb-inotify (0.10.0) + rb-inotify (0.10.1) ffi (~> 1.0) redis (4.1.3) redis-namespace (1.7.0) @@ -307,6 +348,10 @@ GEM redis-store (>= 1.6, < 2) redis-store (1.8.1) redis (>= 4, < 5) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) @@ -315,52 +360,56 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rspec-core (3.9.0) - rspec-support (~> 3.9.0) + retriable (3.1.2) + rspec-core (3.9.1) + rspec-support (~> 3.9.1) rspec-expectations (3.9.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) - rspec-mocks (3.9.0) + rspec-mocks (3.9.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) - rspec-rails (4.0.0.beta3) + rspec-rails (4.0.0.beta4) actionpack (>= 4.2) activesupport (>= 4.2) railties (>= 4.2) - rspec-core (~> 3.8) - rspec-expectations (~> 3.8) - rspec-mocks (~> 3.8) - rspec-support (~> 3.8) - rspec-support (3.9.0) - rubocop (0.78.0) + rspec-core (~> 3.9) + rspec-expectations (~> 3.9) + rspec-mocks (~> 3.9) + rspec-support (~> 3.9) + rspec-support (3.9.2) + rubocop (0.79.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.6) + parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-performance (1.5.1) + rubocop-performance (1.5.2) rubocop (>= 0.71.0) - rubocop-rails (2.4.0) + rubocop-rails (2.4.1) rack (>= 1.1) rubocop (>= 0.72.0) rubocop-rspec (1.37.1) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) - ruby-vips (2.0.16) - ffi (~> 1.9) seed_dump (3.3.1) activerecord (>= 4) activesupport (>= 4) sentry-raven (2.13.0) faraday (>= 0.7.6, < 1.0) - shoulda-matchers (4.1.2) + shoulda-matchers (4.2.0) activesupport (>= 4.2.0) sidekiq (6.0.4) connection_pool (>= 2.2.2) rack (>= 2.0.0) rack-protection (>= 2.0.0) redis (>= 4.1.0) + signet (0.12.0) + addressable (~> 2.3) + faraday (~> 0.9) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) simple_oauth (0.3.1) simplecov (0.17.1) docile (~> 1.1) @@ -398,16 +447,17 @@ GEM multipart-post (~> 2.0) naught (~> 1.0) simple_oauth (~> 0.3.0) - tzinfo (1.2.5) + tzinfo (1.2.6) thread_safe (~> 0.1) tzinfo-data (1.2019.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.6) - unicode-display_width (1.6.0) + unicode-display_width (1.6.1) uniform_notifier (1.13.0) valid_email2 (3.1.3) activemodel (>= 3.2) @@ -442,13 +492,14 @@ DEPENDENCIES acts-as-taggable-on annotate attr_extras + aws-sdk-s3 + azure-storage bootsnap brakeman browser bullet bundle-audit byebug - carrierwave-aws chargebee devise devise_token_auth @@ -457,6 +508,7 @@ DEPENDENCIES factory_bot_rails faker foreman + google-cloud-storage haikunator hashie jbuilder @@ -493,6 +545,7 @@ DEPENDENCIES telegram-bot-ruby time_diff twitter + twitty! tzinfo-data uglifier valid_email2 diff --git a/Procfile.dev b/Procfile.dev index 90f69c8b3..f7c4e6a67 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,3 +1,3 @@ backend: bin/rails s -p 3000 frontend: bin/webpack-dev-server -worker: bundle exec sidekiq \ No newline at end of file +worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/README.md b/README.md index b31a194e1..117a1fc9e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ Detailed documentation is available at [www.chatwoot.com/docs](https://www.chatw You can find the quick setup docs [here](https://www.chatwoot.com/docs/quick-setup). +## Branching model + +We use [git-flow](https://nvie.com/posts/a-successful-git-branching-model/) branching model. The base branch is `develop`. +If you are looking for a stable version, please use the `master` or tags labelled as `v1.x.x`. + ## Heroku one-click deploy Deploying chatwoot to heroku, it's a breeze. It's as simple as clicking this button. diff --git a/app/bot/bot_configurator.rb b/app/bot/bot_configurator.rb deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 67d3272ba..8cad3f87b 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -6,125 +6,137 @@ require 'open-uri' # 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. -module Messages - class MessageBuilder - attr_reader :response +class Messages::MessageBuilder + attr_reader :response - def initialize(response, inbox, outgoing_echo = false) - @response = response - @inbox = inbox - @sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) - @message_type = (outgoing_echo ? :outgoing : :incoming) + def initialize(response, inbox, outgoing_echo = false) + @response = response + @inbox = inbox + @sender_id = (outgoing_echo ? @response.recipient_id : @response.sender_id) + @message_type = (outgoing_echo ? :outgoing : :incoming) + end + + def perform + ActiveRecord::Base.transaction do + build_contact + build_message end + rescue StandardError => e + Raven.capture_exception(e) + true + end - def perform - ActiveRecord::Base.transaction do - build_contact - build_message - end - rescue StandardError => e - Raven.capture_exception(e) - true - end + private - private + def contact + @contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact + end - def contact - @contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact - end + def build_contact + return if contact.present? - def build_contact - return if contact.present? + @contact = Contact.create!(contact_params.except(:remote_avatar_url)) + avatar_resource = LocalResource.new(contact_params[:remote_avatar_url]) + @contact.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) - @contact = Contact.create!(contact_params) - ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) - end + @contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id) + end - def build_message - @message = conversation.messages.new(message_params) - (response.attachments || []).each do |attachment| - @message.build_attachment(attachment_params(attachment)) - end - @message.save! - end - - def build_attachment; end - - def conversation - @conversation ||= Conversation.find_by(conversation_params) || Conversation.create!(conversation_params) - 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'] - { - external_url: attachment['url'], - coordinates_lat: lat, - coordinates_long: long, - fallback_title: attachment['title'] - } - end - - def fallback_params(attachment) - { - fallback_title: attachment['title'], - external_url: attachment['url'] - } - end - - def conversation_params - { - account_id: @inbox.account_id, - inbox_id: @inbox.id, - contact_id: contact.id - } - end - - def message_params - { - account_id: conversation.account_id, - inbox_id: conversation.inbox_id, - message_type: @message_type, - content: response.content, - fb_id: response.identifier - } - end - - def contact_params - begin - k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? - result = k.get_object(@sender_id) || {} - rescue Exception => e - result = {} - Raven.capture_exception(e) - end - { - name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", - account_id: @inbox.account_id, - remote_avatar_url: result['profile_pic'] || nil - } + def build_message + @message = conversation.messages.create!(message_params) + (response.attachments || []).each do |attachment| + attachment_obj = @message.build_attachment(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 end + + def attach_file(attachment, file_url) + file_resource = LocalResource.new(file_url) + attachment.file.attach(io: file_resource.file, filename: file_resource.tmp_filename, content_type: file_resource.encoding) + end + + def conversation + @conversation ||= Conversation.find_by(conversation_params) || build_conversation + end + + def build_conversation + @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id) + Conversation.create!(conversation_params.merge( + contact_inbox_id: @contact_inbox.id + )) + 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'] + { + external_url: attachment['url'], + coordinates_lat: lat, + coordinates_long: long, + fallback_title: attachment['title'] + } + end + + def fallback_params(attachment) + { + fallback_title: attachment['title'], + external_url: attachment['url'] + } + end + + def conversation_params + { + account_id: @inbox.account_id, + inbox_id: @inbox.id, + contact_id: contact.id + } + end + + def message_params + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: @message_type, + content: response.content, + fb_id: response.identifier + } + end + + def contact_params + begin + k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook? + result = k.get_object(@sender_id) || {} + rescue Exception => e + result = {} + Raven.capture_exception(e) + end + { + name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}", + account_id: @inbox.account_id, + remote_avatar_url: result['profile_pic'] || '' + } + end end diff --git a/app/channels/room_channel.rb b/app/channels/room_channel.rb index c04b72877..df1a05d6b 100644 --- a/app/channels/room_channel.rb +++ b/app/channels/room_channel.rb @@ -1,5 +1,10 @@ class RoomChannel < ApplicationCable::Channel def subscribed stream_from params[:pubsub_token] + ::OnlineStatusTracker.add_subscription(params[:pubsub_token]) + end + + def unsubscribed + ::OnlineStatusTracker.remove_subscription(params[:pubsub_token]) end end diff --git a/app/controllers/api/v1/agents_controller.rb b/app/controllers/api/v1/agents_controller.rb index 681422515..a1758b40b 100644 --- a/app/controllers/api/v1/agents_controller.rb +++ b/app/controllers/api/v1/agents_controller.rb @@ -4,7 +4,7 @@ class Api::V1::AgentsController < Api::BaseController before_action :build_agent, only: [:create] def index - render json: agents + @agents = agents end def destroy diff --git a/app/controllers/api/v1/callbacks_controller.rb b/app/controllers/api/v1/callbacks_controller.rb index 6731c7a20..cc6b00958 100644 --- a/app/controllers/api/v1/callbacks_controller.rb +++ b/app/controllers/api/v1/callbacks_controller.rb @@ -12,8 +12,9 @@ class Api::V1::CallbacksController < ApplicationController inbox_name = params[:inbox_name] facebook_channel = current_account.facebook_pages.create!( name: page_name, page_id: page_id, user_access_token: user_access_token, - page_access_token: page_access_token, remote_avatar_url: set_avatar(page_id) + page_access_token: page_access_token ) + set_avatar(facebook_channel, page_id) inbox = current_account.inboxes.create!(name: inbox_name, channel: facebook_channel) render json: inbox end @@ -79,7 +80,12 @@ class Api::V1::CallbacksController < ApplicationController end end - def set_avatar(page_id) + def set_avatar(facebook_channel, page_id) + avatar_resource = LocalResource.new(get_avatar_url(page_id)) + facebook_channel.avatar.attach(io: avatar_resource.file, filename: avatar_resource.tmp_filename, content_type: avatar_resource.encoding) + end + + def get_avatar_url(page_id) begin url = 'http://graph.facebook.com/' << page_id << '/picture?type=large' uri = URI.parse(url) diff --git a/app/controllers/api/v1/contacts/conversations_controller.rb b/app/controllers/api/v1/contacts/conversations_controller.rb new file mode 100644 index 000000000..bce503ad6 --- /dev/null +++ b/app/controllers/api/v1/contacts/conversations_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Contacts::ConversationsController < Api::BaseController + def index + @conversations = current_account.conversations.includes( + :assignee, :contact, :inbox + ).where(inbox_id: inbox_ids, contact_id: permitted_params[:contact_id]) + end + + private + + def inbox_ids + if current_user.administrator? + current_account.inboxes.pluck(:id) + elsif current_user.agent? + current_user.assigned_inboxes.pluck(:id) + else + [] + end + end + + def permitted_params + params.permit(:contact_id) + end +end diff --git a/app/controllers/api/v1/inbox_members_controller.rb b/app/controllers/api/v1/inbox_members_controller.rb index f5e8a6a10..ed47f409a 100644 --- a/app/controllers/api/v1/inbox_members_controller.rb +++ b/app/controllers/api/v1/inbox_members_controller.rb @@ -1,55 +1,51 @@ -module Api - module V1 - class InboxMembersController < Api::BaseController - before_action :fetch_inbox, only: [:create, :show] - before_action :current_agents_ids, only: [:create] +class Api::V1::InboxMembersController < Api::BaseController + before_action :fetch_inbox, only: [:create, :show] + before_action :current_agents_ids, only: [:create] - def create - # update also done via same action - if @inbox - begin - update_agents_list - head :ok - rescue StandardError => e - Rails.logger.debug "Rescued: #{e.inspect}" - render_could_not_create_error('Could not add agents to inbox') - end - else - render_not_found_error('Agents or inbox not found') - end - end - - def show - @agents = current_account.users.where(id: @inbox.members.pluck(:user_id)) - end - - private - - def update_agents_list - # get all the user_ids which the inbox currently has as members. - # get the list of user_ids from params - # the missing ones are the agents which are to be deleted from the inbox - # the new ones are the agents which are to be added to the inbox - - agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } - agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } - end - - def agents_to_be_added_ids - params[:user_ids] - @current_agents_ids - end - - def agents_to_be_removed_ids - @current_agents_ids - params[:user_ids] - end - - def current_agents_ids - @current_agents_ids = @inbox.members.pluck(:id) - end - - def fetch_inbox - @inbox = current_account.inboxes.find(params[:inbox_id]) + def create + # update also done via same action + if @inbox + begin + update_agents_list + head :ok + rescue StandardError => e + Rails.logger.debug "Rescued: #{e.inspect}" + render_could_not_create_error('Could not add agents to inbox') end + else + render_not_found_error('Agents or inbox not found') end end + + def show + @agents = current_account.users.where(id: @inbox.members.pluck(:user_id)) + end + + private + + def update_agents_list + # get all the user_ids which the inbox currently has as members. + # get the list of user_ids from params + # the missing ones are the agents which are to be deleted from the inbox + # the new ones are the agents which are to be added to the inbox + + agents_to_be_added_ids.each { |user_id| @inbox.add_member(user_id) } + agents_to_be_removed_ids.each { |user_id| @inbox.remove_member(user_id) } + end + + def agents_to_be_added_ids + params[:user_ids] - @current_agents_ids + end + + def agents_to_be_removed_ids + @current_agents_ids - params[:user_ids] + end + + def current_agents_ids + @current_agents_ids = @inbox.members.pluck(:id) + end + + def fetch_inbox + @inbox = current_account.inboxes.find(params[:inbox_id]) + end end diff --git a/app/controllers/api/v1/labels_controller.rb b/app/controllers/api/v1/labels_controller.rb index 5a06ae314..4426b7018 100644 --- a/app/controllers/api/v1/labels_controller.rb +++ b/app/controllers/api/v1/labels_controller.rb @@ -2,4 +2,8 @@ class Api::V1::LabelsController < Api::BaseController def index # list all labels in account @labels = current_account.all_conversation_tags end + + def most_used + @labels = ActsAsTaggableOn::Tag.most_used(params[:count] || 10) + end end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb index 6ea63cf53..10bcfc25a 100644 --- a/app/controllers/api/v1/webhooks_controller.rb +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -3,7 +3,7 @@ class Api::V1::WebhooksController < ApplicationController skip_before_action :set_current_user skip_before_action :check_subscription - before_action :login_from_basic_auth + before_action :login_from_basic_auth, only: [:chargebee] def chargebee chargebee_consumer.consume head :ok @@ -12,6 +12,18 @@ class Api::V1::WebhooksController < ApplicationController head :ok end + def twitter_crc + render json: { response_token: "sha256=#{$twitter.generate_crc(params[:crc_token])}" } + end + + def twitter_events + twitter_consumer.consume + head :ok + rescue StandardError => e + Raven.capture_exception(e) + head :ok + end + private def login_from_basic_auth @@ -21,6 +33,10 @@ class Api::V1::WebhooksController < ApplicationController end def chargebee_consumer - @consumer ||= ::Webhooks::Chargebee.new(params) + @chargebee_consumer ||= ::Webhooks::Chargebee.new(params) + end + + def twitter_consumer + @twitter_consumer ||= ::Webhooks::Twitter.new(params) end end diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb new file mode 100644 index 000000000..eb19e2bdd --- /dev/null +++ b/app/controllers/api/v1/widget/base_controller.rb @@ -0,0 +1,29 @@ +class Api::V1::Widget::BaseController < ApplicationController + private + + def conversation + @conversation ||= @contact_inbox.conversations.find_by( + inbox_id: auth_token_params[:inbox_id] + ) + end + + def auth_token_params + @auth_token_params ||= ::Widget::TokenService.new(token: request.headers[header_name]).decode_token + end + + def header_name + 'X-Auth-Token' + end + + def set_web_widget + @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) + @account = @web_widget.account + end + + def set_contact + @contact_inbox = @web_widget.inbox.contact_inboxes.find_by( + source_id: auth_token_params[:source_id] + ) + @contact = @contact_inbox.contact + end +end diff --git a/app/controllers/api/v1/widget/inbox_members_controller.rb b/app/controllers/api/v1/widget/inbox_members_controller.rb new file mode 100644 index 000000000..d978a9b6e --- /dev/null +++ b/app/controllers/api/v1/widget/inbox_members_controller.rb @@ -0,0 +1,13 @@ +class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController + before_action :set_web_widget + + def index + @inbox_members = @web_widget.inbox.inbox_members.includes(:user) + end + + private + + def permitted_params + params.permit(:website_token) + end +end diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a3e83663e..a6087bb8c 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -1,6 +1,8 @@ -class Api::V1::Widget::MessagesController < ActionController::Base - skip_before_action :verify_authenticity_token +class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController + before_action :set_web_widget + before_action :set_contact before_action :set_conversation, only: [:create] + before_action :set_message, only: [:update] def index @messages = conversation.nil? ? [] : message_finder.perform @@ -9,17 +11,19 @@ class Api::V1::Widget::MessagesController < ActionController::Base def create @message = conversation.messages.new(message_params) @message.save! + render json: @message + end + + def update + @message.update!(input_submitted_email: contact_email) + update_contact(contact_email) + head :no_content + rescue StandardError => e + render json: { error: @contact.errors, message: e.message }.to_json, status: 500 end private - def conversation - @conversation ||= ::Conversation.find_by( - contact_id: cookie_params[:contact_id], - inbox_id: cookie_params[:inbox_id] - ) - end - def set_conversation @conversation = ::Conversation.create!(conversation_params) if conversation.nil? end @@ -37,7 +41,8 @@ class Api::V1::Widget::MessagesController < ActionController::Base { account_id: inbox.account_id, inbox_id: inbox.id, - contact_id: cookie_params[:contact_id], + contact_id: @contact.id, + contact_inbox_id: @contact_inbox.id, additional_attributes: { browser: browser_params, referer: permitted_params[:message][:referer_url], @@ -63,13 +68,7 @@ class Api::V1::Widget::MessagesController < ActionController::Base end def inbox - @inbox ||= ::Inbox.find_by(id: cookie_params[:inbox_id]) - end - - def cookie_params - @cookie_params ||= JWT.decode( - request.headers[header_name], secret_key, true, algorithm: 'HS256' - ).first.symbolize_keys + @inbox ||= ::Inbox.find_by(id: auth_token_params[:inbox_id]) end def message_finder_params @@ -83,15 +82,31 @@ class Api::V1::Widget::MessagesController < ActionController::Base @message_finder ||= MessageFinder.new(conversation, message_finder_params) end - def header_name - 'X-Auth-Token' + def update_contact(email) + contact_with_email = @account.contacts.find_by(email: email) + if contact_with_email + ::ContactMergeAction.new(account: @account, base_contact: contact_with_email, mergee_contact: @contact).perform + else + @contact.update!( + email: email, + name: contact_name + ) + end + end + + def contact_email + permitted_params[:contact][:email].downcase + end + + def contact_name + contact_email.split('@')[0] end def permitted_params - params.permit(:before, message: [:content, :referer_url, :timestamp]) + params.permit(:id, :before, :website_token, contact: [:email], message: [:content, :referer_url, :timestamp]) end - def secret_key - Rails.application.secrets.secret_key_base + def set_message + @message = @web_widget.inbox.messages.find(permitted_params[:id]) end end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/devise_overrides/confirmations_controller.rb similarity index 93% rename from app/controllers/confirmations_controller.rb rename to app/controllers/devise_overrides/confirmations_controller.rb index 863d4cdcd..96d16eafe 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/devise_overrides/confirmations_controller.rb @@ -1,4 +1,4 @@ -class ConfirmationsController < Devise::ConfirmationsController +class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController skip_before_action :require_no_authentication, raise: false skip_before_action :authenticate_user!, raise: false diff --git a/app/controllers/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb similarity index 95% rename from app/controllers/passwords_controller.rb rename to app/controllers/devise_overrides/passwords_controller.rb index 873da9d3f..adea8687d 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -1,4 +1,4 @@ -class PasswordsController < Devise::PasswordsController +class DeviseOverrides::PasswordsController < Devise::PasswordsController include AuthHelper skip_before_action :require_no_authentication, raise: false diff --git a/app/controllers/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb similarity index 58% rename from app/controllers/sessions_controller.rb rename to app/controllers/devise_overrides/sessions_controller.rb index 582406b4c..8ef7f2d11 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -1,4 +1,4 @@ -class SessionsController < ::DeviseTokenAuth::SessionsController +class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController # Prevent session parameter from being passed # Unpermitted parameter: session wrap_parameters format: [] diff --git a/app/controllers/devise_overrides/token_validations_controller.rb b/app/controllers/devise_overrides/token_validations_controller.rb new file mode 100644 index 000000000..b9830d79d --- /dev/null +++ b/app/controllers/devise_overrides/token_validations_controller.rb @@ -0,0 +1,10 @@ +class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController + def validate_token + # @resource will have been set by set_user_by_token concern + if @resource + render 'devise/token.json' + else + render_validate_token_error + end + end +end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 711a5cca4..60b522831 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -4,66 +4,47 @@ class WidgetsController < ActionController::Base before_action :set_contact before_action :build_contact + def index + render + end + private - def set_contact - return if cookie_params[:source_id].nil? - - contact_inbox = ::ContactInbox.find_by( - inbox_id: @web_widget.inbox.id, - source_id: cookie_params[:source_id] - ) - - @contact = contact_inbox ? contact_inbox.contact : nil - end - - def set_token - @token = conversation_token - end - def set_web_widget @web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token]) end + def set_token + @token = permitted_params[:cw_conversation] + @auth_token_params = if @token.present? + ::Widget::TokenService.new(token: @token).decode_token + else + {} + end + end + + def set_contact + return if @auth_token_params[:source_id].nil? + + contact_inbox = ::ContactInbox.find_by( + inbox_id: @web_widget.inbox.id, + source_id: @auth_token_params[:source_id] + ) + + @contact = contact_inbox ? contact_inbox.contact : nil + end + def build_contact return if @contact.present? contact_inbox = @web_widget.create_contact_inbox @contact = contact_inbox.contact - payload = { - source_id: contact_inbox.source_id, - contact_id: @contact.id, - inbox_id: @web_widget.inbox.id - } - @token = JWT.encode payload, secret_key, 'HS256' - end - - def cookie_params - return @cookie_params if @cookie_params.present? - - if conversation_token.present? - begin - @cookie_params = JWT.decode( - conversation_token, secret_key, true, algorithm: 'HS256' - ).first.symbolize_keys - rescue StandardError - @cookie_params = {} - end - return @cookie_params - end - {} - end - - def conversation_token - permitted_params[:cw_conversation] + payload = { source_id: contact_inbox.source_id, inbox_id: @web_widget.inbox.id } + @token = ::Widget::TokenService.new(payload: payload).generate_token end def permitted_params params.permit(:website_token, :cw_conversation) end - - def secret_key - Rails.application.secrets.secret_key_base - end end diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index b6c6f966c..f4e114bf4 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -31,6 +31,7 @@ class ConversationFinder find_all_conversations filter_by_status + filter_by_labels if params[:labels] mine_count, unassigned_count, all_count = set_count_for_all_conversations @@ -62,7 +63,6 @@ class ConversationFinder def set_assignee_type @assignee_type_id = ASSIGNEE_TYPES[ASSIGNEE_TYPES_BY_ID[params[:assignee_type_id].to_i]] - # ente budhiparamaya neekam kandit enthu tonunu? ;) end def find_all_conversations @@ -86,6 +86,10 @@ class ConversationFinder @conversations = @conversations.where(status: params[:status] || DEFAULT_STATUS) end + def filter_by_labels + @conversations = @conversations.tagged_with(params[:labels], any: true) + end + def set_count_for_all_conversations [ @conversations.assigned_to(current_user).count, diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2addd9ce1..bad89b182 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -1,9 +1,14 @@ +/* global axios */ import ApiClient from './ApiClient'; class ContactAPI extends ApiClient { constructor() { super('contacts'); } + + getConversations(contactId) { + return axios.get(`${this.url}/${contactId}/conversations`); + } } export default new ContactAPI(); diff --git a/app/javascript/dashboard/api/conversations.js b/app/javascript/dashboard/api/conversations.js new file mode 100644 index 000000000..02d312388 --- /dev/null +++ b/app/javascript/dashboard/api/conversations.js @@ -0,0 +1,18 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class ConversationApi extends ApiClient { + constructor() { + super('conversations'); + } + + getLabels(conversationID) { + return axios.get(`${this.url}/${conversationID}/labels`); + } + + createLabels(conversationID) { + return axios.get(`${this.url}/${conversationID}/labels`); + } +} + +export default new ConversationApi(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js new file mode 100644 index 000000000..1021c0409 --- /dev/null +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -0,0 +1,14 @@ +import agents from '../contacts'; +import ApiClient from '../ApiClient'; + +describe('#ContactsAPI', () => { + it('creates correct instance', () => { + expect(agents).toBeInstanceOf(ApiClient); + expect(agents).toHaveProperty('get'); + expect(agents).toHaveProperty('show'); + expect(agents).toHaveProperty('create'); + expect(agents).toHaveProperty('update'); + expect(agents).toHaveProperty('delete'); + expect(agents).toHaveProperty('getConversations'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js new file mode 100644 index 000000000..71cebae3a --- /dev/null +++ b/app/javascript/dashboard/api/specs/conversations.spec.js @@ -0,0 +1,15 @@ +import conversations from '../conversations'; +import ApiClient from '../ApiClient'; + +describe('#ConversationApi', () => { + it('creates correct instance', () => { + expect(conversations).toBeInstanceOf(ApiClient); + expect(conversations).toHaveProperty('get'); + expect(conversations).toHaveProperty('show'); + expect(conversations).toHaveProperty('create'); + expect(conversations).toHaveProperty('update'); + expect(conversations).toHaveProperty('delete'); + expect(conversations).toHaveProperty('getLabels'); + expect(conversations).toHaveProperty('createLabels'); + }); +}); diff --git a/app/javascript/dashboard/assets/images/Mask.png b/app/javascript/dashboard/assets/images/Mask.png new file mode 100644 index 000000000..1894f1f76 Binary files /dev/null and b/app/javascript/dashboard/assets/images/Mask.png differ diff --git a/app/javascript/dashboard/assets/images/chatwoot_bot.png b/app/javascript/dashboard/assets/images/chatwoot_bot.png new file mode 100644 index 000000000..4b5a2d686 Binary files /dev/null and b/app/javascript/dashboard/assets/images/chatwoot_bot.png differ diff --git a/app/javascript/dashboard/assets/images/twitter-badge.png b/app/javascript/dashboard/assets/images/twitter-badge.png new file mode 100644 index 000000000..9dd805942 Binary files /dev/null and b/app/javascript/dashboard/assets/images/twitter-badge.png differ diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss index 83476518d..a4f632085 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-card.scss @@ -41,6 +41,7 @@ color: $color-body; width: 27rem; white-space: nowrap; + max-width: 96%; } .conversation--meta { @@ -91,4 +92,12 @@ font-weight: $font-weight-medium; } } + + &.compact { + padding-left: 0; + + .conversation--details { + margin-left: 0; + } + } } diff --git a/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss b/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss index a51d38e83..d229c0738 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_woot-tables.scss @@ -21,7 +21,7 @@ table { tr { .show-if-hover { opacity: 0; - transition: all .2s $ease-in-out-cubic; + transition: all 0.2s $ease-in-out-cubic; } &:hover { diff --git a/app/javascript/dashboard/components/Spinner.vue b/app/javascript/dashboard/components/Spinner.vue deleted file mode 100644 index b90ad471d..000000000 --- a/app/javascript/dashboard/components/Spinner.vue +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue index b0cf8cdad..c6ec19321 100644 --- a/app/javascript/dashboard/components/buttons/FormSubmitButton.vue +++ b/app/javascript/dashboard/components/buttons/FormSubmitButton.vue @@ -12,7 +12,7 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue index 1f69a97ca..2211d79de 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactDetailsItem.vue @@ -4,7 +4,7 @@ {{ title }} -