From ed1c871633f4d90f79e7664c58565c3aba1963be Mon Sep 17 00:00:00 2001 From: Subin T P Date: Fri, 12 Jun 2020 23:12:47 +0530 Subject: [PATCH] Feature: Slack integration (#783) - Integrations architecture - Slack integration --- .env.example | 4 + Gemfile | 3 + Gemfile.lock | 18 +++++ .../accounts/integrations/apps_controller.rb | 18 +++++ .../accounts/integrations/slack_controller.rb | 36 +++++++++ .../v1/integrations/webhooks_controller.rb | 7 ++ app/dispatchers/async_dispatcher.rb | 2 +- app/jobs/hook_job.rb | 11 +++ app/listeners/hook_listener.rb | 10 +++ app/models/account.rb | 1 + app/models/conversation.rb | 1 + app/models/inbox.rb | 1 + app/models/integrations.rb | 5 ++ app/models/integrations/app.rb | 51 ++++++++++++ app/models/integrations/hook.rb | 36 +++++++++ .../integrations/apps/index.json.jbuilder | 7 ++ .../integrations/apps/show.json.jbuilder | 7 ++ config/initializers/00_init.rb | 1 + config/integration/apps.yml | 6 ++ config/routes.rb | 12 ++- config/sidekiq.yml | 1 + ...0200430163438_create_integrations_hooks.rb | 15 ++++ ...154151_add_reference_id_to_conversation.rb | 5 ++ .../20200610143132_rename_reference_id.rb | 5 ++ db/schema.rb | 30 ++++++- lib/integrations/slack/channel_builder.rb | 33 ++++++++ lib/integrations/slack/hook_builder.rb | 42 ++++++++++ .../slack/incoming_message_builder.rb | 78 +++++++++++++++++++ .../slack/outgoing_message_builder.rb | 50 ++++++++++++ .../integrations/apps_controller_spec.rb | 53 +++++++++++++ spec/factories/conversations.rb | 1 + spec/factories/integrations/hooks.rb | 12 +++ spec/jobs/hook_job_spec.rb | 15 ++++ .../integrations/slack/hook_builder_spec.rb | 22 ++++++ .../slack/incoming_message_builder_spec.rb | 45 +++++++++++ .../slack/outgoing_message_builder_spec.rb | 30 +++++++ spec/listeners/hook_listener_spec.rb | 32 ++++++++ spec/models/inbox_spec.rb | 2 + spec/models/integrations/hook_spec.rb | 12 +++ spec/rails_helper.rb | 6 +- .../integrations/slack_request_spec.rb | 77 ++++++++++++++++++ .../v1/integrations/webhooks_request_spec.rb | 15 ++++ spec/spec_helper.rb | 3 + spec/support/slack_stubs.rb | 53 +++++++++++++ 44 files changed, 867 insertions(+), 7 deletions(-) create mode 100644 app/controllers/api/v1/accounts/integrations/apps_controller.rb create mode 100644 app/controllers/api/v1/accounts/integrations/slack_controller.rb create mode 100644 app/controllers/api/v1/integrations/webhooks_controller.rb create mode 100644 app/jobs/hook_job.rb create mode 100644 app/listeners/hook_listener.rb create mode 100644 app/models/integrations.rb create mode 100644 app/models/integrations/app.rb create mode 100644 app/models/integrations/hook.rb create mode 100644 app/views/api/v1/accounts/integrations/apps/index.json.jbuilder create mode 100644 app/views/api/v1/accounts/integrations/apps/show.json.jbuilder create mode 100644 config/initializers/00_init.rb create mode 100644 config/integration/apps.yml create mode 100644 db/migrate/20200430163438_create_integrations_hooks.rb create mode 100644 db/migrate/20200510154151_add_reference_id_to_conversation.rb create mode 100644 db/migrate/20200610143132_rename_reference_id.rb create mode 100644 lib/integrations/slack/channel_builder.rb create mode 100644 lib/integrations/slack/hook_builder.rb create mode 100644 lib/integrations/slack/incoming_message_builder.rb create mode 100644 lib/integrations/slack/outgoing_message_builder.rb create mode 100644 spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb create mode 100644 spec/factories/integrations/hooks.rb create mode 100644 spec/jobs/hook_job_spec.rb create mode 100644 spec/lib/integrations/slack/hook_builder_spec.rb create mode 100644 spec/lib/integrations/slack/incoming_message_builder_spec.rb create mode 100644 spec/lib/integrations/slack/outgoing_message_builder_spec.rb create mode 100644 spec/listeners/hook_listener_spec.rb create mode 100644 spec/models/integrations/hook_spec.rb create mode 100644 spec/requests/api/v1/accounts/integrations/slack_request_spec.rb create mode 100644 spec/requests/api/v1/integrations/webhooks_request_spec.rb create mode 100644 spec/support/slack_stubs.rb diff --git a/.env.example b/.env.example index 7dcbe5e62..06a39d17f 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,10 @@ TWITTER_CONSUMER_KEY= TWITTER_CONSUMER_SECRET= TWITTER_ENVIRONMENT= +#slack +SLACK_CLIENT_ID= +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 diff --git a/Gemfile b/Gemfile index 5397d5130..a769187de 100644 --- a/Gemfile +++ b/Gemfile @@ -65,6 +65,8 @@ gem 'twilio-ruby', '~> 5.32.0' gem 'twitty' # facebook client gem 'koala' +# slack client +gem 'slack-ruby-client' # Random name generator gem 'haikunator' @@ -115,4 +117,5 @@ group :development, :test do gem 'simplecov', '0.17.1', require: false gem 'spring' gem 'spring-watcher-listen' + gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 3f8ff49e7..aadeaf5be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -143,6 +143,8 @@ GEM descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.6) connection_pool (2.2.2) + crack (0.4.3) + safe_yaml (~> 1.0.0) crass (1.0.6) datetime_picker_rails (0.0.7) momentjs-rails (>= 2.8.1) @@ -191,6 +193,7 @@ GEM ffi (1.12.2) flag_shih_tzu (0.3.23) foreman (0.87.1) + gli (2.19.0) globalid (0.4.2) activesupport (>= 4.2.0) google-api-client (0.39.4) @@ -225,6 +228,7 @@ GEM activesupport (>= 5) haikunator (1.1.0) hana (1.3.6) + hashdiff (1.0.1) hashie (4.1.0) hkdf (0.3.0) http-accept (1.7.0) @@ -411,6 +415,7 @@ GEM rubocop-rspec (1.39.0) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) + safe_yaml (1.0.5) sass (3.7.4) sass-listen (~> 4.0.0) sass-listen (4.0.0) @@ -452,6 +457,13 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + slack-ruby-client (0.14.6) + activesupport + faraday (>= 0.9) + faraday_middleware + gli + hashie + websocket-driver spring (2.1.0) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) @@ -507,6 +519,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.8.3) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webpacker (5.1.1) activesupport (>= 5.2) rack-proxy (>= 0.6.1) @@ -583,6 +599,7 @@ DEPENDENCIES shoulda-matchers sidekiq simplecov (= 0.17.1) + slack-ruby-client spring spring-watcher-listen telegram-bot-ruby @@ -594,6 +611,7 @@ DEPENDENCIES uglifier valid_email2 web-console + webmock webpacker webpush wisper (= 2.0.0) diff --git a/app/controllers/api/v1/accounts/integrations/apps_controller.rb b/app/controllers/api/v1/accounts/integrations/apps_controller.rb new file mode 100644 index 000000000..35b6f7c4d --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/apps_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController + before_action :fetch_apps, only: [:index] + before_action :fetch_app, only: [:show] + + def index; end + + def show; end + + private + + def fetch_apps + @apps = Integrations::App.all + end + + def fetch_app + @app = Integrations::App.find(id: params[:id]) + end +end diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb new file mode 100644 index 000000000..023deaa97 --- /dev/null +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController + before_action :fetch_hook, only: [:update, :destroy] + + def create + builder = Integrations::Slack::HookBuilder.new( + account: current_account, + code: params[:code], + inbox_id: params[:inbox_id] + ) + + @hook = builder.perform + + render json: @hook + end + + def update + builder = Integrations::Slack::ChannelBuilder.new( + hook: @hook, channel: params[:channel] + ) + builder.perform + + render json: @hook + end + + def destroy + @hook.destroy + + head :ok + end + + private + + def fetch_hook + @hook = Integrations::Hook.find(params[:id]) + end +end diff --git a/app/controllers/api/v1/integrations/webhooks_controller.rb b/app/controllers/api/v1/integrations/webhooks_controller.rb new file mode 100644 index 000000000..94373c92e --- /dev/null +++ b/app/controllers/api/v1/integrations/webhooks_controller.rb @@ -0,0 +1,7 @@ +class Api::V1::Integrations::WebhooksController < ApplicationController + def create + builder = Integrations::Slack::IncomingMessageBuilder.new(params) + response = builder.perform + render json: response + end +end diff --git a/app/dispatchers/async_dispatcher.rb b/app/dispatchers/async_dispatcher.rb index 8ddc963b5..c24b633aa 100644 --- a/app/dispatchers/async_dispatcher.rb +++ b/app/dispatchers/async_dispatcher.rb @@ -9,7 +9,7 @@ class AsyncDispatcher < BaseDispatcher end def listeners - listeners = [EventListener.instance, WebhookListener.instance] + listeners = [EventListener.instance, WebhookListener.instance, HookListener.instance] listeners end end diff --git a/app/jobs/hook_job.rb b/app/jobs/hook_job.rb new file mode 100644 index 000000000..f867248d8 --- /dev/null +++ b/app/jobs/hook_job.rb @@ -0,0 +1,11 @@ +class HookJob < ApplicationJob + queue_as :integrations + + def perform(hook, message) + return unless hook.slack? + + Integrations::Slack::OutgoingMessageBuilder.perform(hook, message) + rescue StandardError => e + Raven.capture_exception(e) + end +end diff --git a/app/listeners/hook_listener.rb b/app/listeners/hook_listener.rb new file mode 100644 index 000000000..efe1c74e8 --- /dev/null +++ b/app/listeners/hook_listener.rb @@ -0,0 +1,10 @@ +class HookListener < BaseListener + def message_created(event) + message = extract_message_and_account(event)[0] + return unless message.reportable? + + message.account.hooks.each do |hook| + HookJob.perform_later(hook, message) + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 3e5b48f00..7e97365e5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -47,6 +47,7 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy has_many :labels, dependent: :destroy has_many :notification_settings, dependent: :destroy + has_many :hooks, dependent: :destroy, class_name: 'Integrations::Hook' has_flags ACCOUNT_SETTINGS_FLAGS.merge(column: 'settings_flags').merge(DEFAULT_QUERY_SETTING) enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h diff --git a/app/models/conversation.rb b/app/models/conversation.rb index db25c27da..98c3ebcc6 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -5,6 +5,7 @@ # id :integer not null, primary key # additional_attributes :jsonb # agent_last_seen_at :datetime +# identifier :string # locked :boolean default(FALSE) # status :integer default("open"), not null # user_last_seen_at :datetime diff --git a/app/models/inbox.rb b/app/models/inbox.rb index b5d276c56..b48b6b354 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -42,6 +42,7 @@ class Inbox < ApplicationRecord has_one :agent_bot_inbox, dependent: :destroy has_one :agent_bot, through: :agent_bot_inbox has_many :webhooks, dependent: :destroy + has_many :hooks, dependent: :destroy, class_name: 'Integrations::Hook' after_destroy :delete_round_robin_agents diff --git a/app/models/integrations.rb b/app/models/integrations.rb new file mode 100644 index 000000000..a6b681795 --- /dev/null +++ b/app/models/integrations.rb @@ -0,0 +1,5 @@ +module Integrations + def self.table_name_prefix + 'integrations_' + end +end diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb new file mode 100644 index 000000000..324b34b98 --- /dev/null +++ b/app/models/integrations/app.rb @@ -0,0 +1,51 @@ +class Integrations::App + attr_accessor :params + + def initialize(params) + @params = params + end + + def id + params[:id] + end + + def name + params[:name] + end + + def description + params[:description] + end + + def logo + params[:logo] + end + + def fields + params[:fields] + end + + def button + params[:button] + end + + def enabled?(account) + account.hooks.where(app_id: id).exists? + end + + class << self + def apps + Hashie::Mash.new(APPS_CONFIG) + end + + def all + apps.values.each_with_object([]) do |app, result| + result << new(app) + end + end + + def find(params) + all.detect { |app| app.id == params[:id] } + end + end +end diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb new file mode 100644 index 000000000..bd9768624 --- /dev/null +++ b/app/models/integrations/hook.rb @@ -0,0 +1,36 @@ +# == Schema Information +# +# Table name: integrations_hooks +# +# id :bigint not null, primary key +# access_token :string +# hook_type :integer default("account") +# settings :text +# status :integer default("disabled") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# app_id :string +# inbox_id :integer +# reference_id :string +# +class Integrations::Hook < ApplicationRecord + validates :account_id, presence: true + validates :app_id, presence: true + + enum status: { disabled: 0, enabled: 1 } + + belongs_to :account + belongs_to :inbox, optional: true + has_secure_token :access_token + + enum hook_type: { account: 0, inbox: 1 } + + def app + @app ||= Integrations::App.find(id: app_id) + end + + def slack? + app_id == 'cw_slack' + end +end diff --git a/app/views/api/v1/accounts/integrations/apps/index.json.jbuilder b/app/views/api/v1/accounts/integrations/apps/index.json.jbuilder new file mode 100644 index 000000000..dc314f008 --- /dev/null +++ b/app/views/api/v1/accounts/integrations/apps/index.json.jbuilder @@ -0,0 +1,7 @@ +json.array! @apps do |app| + json.id app.id + json.name app.name + json.logo app.logo + json.enabled app.enabled?(@current_account) + json.button app.button +end diff --git a/app/views/api/v1/accounts/integrations/apps/show.json.jbuilder b/app/views/api/v1/accounts/integrations/apps/show.json.jbuilder new file mode 100644 index 000000000..9970e9b0d --- /dev/null +++ b/app/views/api/v1/accounts/integrations/apps/show.json.jbuilder @@ -0,0 +1,7 @@ +json.id @app.id +json.name @app.name +json.logo @app.logo +json.description @app.description +json.fields @app.fields +json.enabled @app.enabled?(@current_account) +json.button @app.button diff --git a/config/initializers/00_init.rb b/config/initializers/00_init.rb new file mode 100644 index 000000000..b72fc4be1 --- /dev/null +++ b/config/initializers/00_init.rb @@ -0,0 +1 @@ +APPS_CONFIG = YAML.load_file(File.join(Rails.root, 'config/integration', 'apps.yml')) \ No newline at end of file diff --git a/config/integration/apps.yml b/config/integration/apps.yml new file mode 100644 index 000000000..b95d5e16b --- /dev/null +++ b/config/integration/apps.yml @@ -0,0 +1,6 @@ +slack: + id: cw_slack + name: Slack + logo: https://a.slack-edge.com/80588/marketing/img/media-kit/img-logos@2x.png + description: "'Be less busy' - Slack is the chat tool that brings all your communication together in one place. By integrating Slack with SupportBee, you can get notified in your Slack channels for important events in your support desk" + button: \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4f7d36d07..9602020e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,18 +87,26 @@ Rails.application.routes.draw do end resource :notification_settings, only: [:show, :update] - resources :webhooks, except: [:show] + resources :webhooks, except: [:show] + namespace :integrations do + resources :apps, only: [:index, :show] + resources :slack, only: [:create, :update, :destroy] + end end end - # end of account scoped api routes # ---------------------------------- + namespace :integrations do + resources :webhooks, only: [:create] + end + resource :profile, only: [:show, :update] resource :notification_subscriptions, only: [:create] resources :agent_bots, only: [:index] + namespace :widget do resources :events, only: [:create] resources :messages, only: [:index, :create, :update] diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 402758056..3e20bd0df 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -13,6 +13,7 @@ :queues: - [low, 1] - [webhooks, 1] + - [integrations, 2] - [bots, 1] - [active_storage_analysis, 1] - [action_mailbox_incineration, 1] diff --git a/db/migrate/20200430163438_create_integrations_hooks.rb b/db/migrate/20200430163438_create_integrations_hooks.rb new file mode 100644 index 000000000..487297bf6 --- /dev/null +++ b/db/migrate/20200430163438_create_integrations_hooks.rb @@ -0,0 +1,15 @@ +class CreateIntegrationsHooks < ActiveRecord::Migration[6.0] + def change + create_table :integrations_hooks do |t| + t.integer :status, default: 0 + t.integer :inbox_id + t.integer :account_id + t.string :app_id + t.text :settings + t.integer :hook_type, default: 0 + t.string :reference_id + t.string :access_token + t.timestamps + end + end +end diff --git a/db/migrate/20200510154151_add_reference_id_to_conversation.rb b/db/migrate/20200510154151_add_reference_id_to_conversation.rb new file mode 100644 index 000000000..5bc0af68a --- /dev/null +++ b/db/migrate/20200510154151_add_reference_id_to_conversation.rb @@ -0,0 +1,5 @@ +class AddReferenceIdToConversation < ActiveRecord::Migration[6.0] + def change + add_column :conversations, :reference_id, :string + end +end diff --git a/db/migrate/20200610143132_rename_reference_id.rb b/db/migrate/20200610143132_rename_reference_id.rb new file mode 100644 index 000000000..e15e642a2 --- /dev/null +++ b/db/migrate/20200610143132_rename_reference_id.rb @@ -0,0 +1,5 @@ +class RenameReferenceId < ActiveRecord::Migration[6.0] + def change + rename_column :conversations, :reference_id, :identifier + end +end diff --git a/db/schema.rb b/db/schema.rb index f237be8f0..5771ffe28 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_06_07_140737) do +ActiveRecord::Schema.define(version: 2020_06_10_143132) do # These are extensions that must be enabled in order to support this database - enable_extension "pg_stat_statements" enable_extension "pgcrypto" enable_extension "plpgsql" @@ -206,6 +205,7 @@ ActiveRecord::Schema.define(version: 2020_06_07_140737) do t.boolean "locked", default: false t.jsonb "additional_attributes" t.bigint "contact_inbox_id" + t.string "identifier" t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false 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" @@ -228,6 +228,17 @@ ActiveRecord::Schema.define(version: 2020_06_07_140737) do t.index ["user_id"], name: "index_events_on_user_id" end + create_table "hooks_inbox_apps", force: :cascade do |t| + t.integer "inbox_id" + t.integer "agent_id" + t.integer "account_id" + t.string "app_slug" + t.string "status" + t.text "settings" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "inbox_members", id: :serial, force: :cascade do |t| t.integer "user_id", null: false t.integer "inbox_id", null: false @@ -257,6 +268,19 @@ ActiveRecord::Schema.define(version: 2020_06_07_140737) do t.index ["name", "created_at"], name: "index_installation_configs_on_name_and_created_at", unique: true end + create_table "integrations_hooks", force: :cascade do |t| + t.integer "status", default: 0 + t.integer "inbox_id" + t.integer "account_id" + t.string "app_id" + t.text "settings" + t.integer "hook_type", default: 0 + t.string "reference_id" + t.string "access_token" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "labels", force: :cascade do |t| t.string "title" t.text "description" @@ -357,9 +381,11 @@ ActiveRecord::Schema.define(version: 2020_06_07_140737) do t.index ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context" t.index ["taggable_id", "taggable_type", "tagger_id", "context"], name: "taggings_idy" t.index ["taggable_id"], name: "index_taggings_on_taggable_id" + t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable_type_and_taggable_id" t.index ["taggable_type"], name: "index_taggings_on_taggable_type" t.index ["tagger_id", "tagger_type"], name: "index_taggings_on_tagger_id_and_tagger_type" t.index ["tagger_id"], name: "index_taggings_on_tagger_id" + t.index ["tagger_type", "tagger_id"], name: "index_taggings_on_tagger_type_and_tagger_id" end create_table "tags", id: :serial, force: :cascade do |t| diff --git a/lib/integrations/slack/channel_builder.rb b/lib/integrations/slack/channel_builder.rb new file mode 100644 index 000000000..e5d7e6679 --- /dev/null +++ b/lib/integrations/slack/channel_builder.rb @@ -0,0 +1,33 @@ +class Integrations::Slack::ChannelBuilder + attr_reader :params, :channel + + def initialize(params) + @params = params + end + + def perform + create_channel + update_reference_id + end + + private + + def hook + @hook ||= params[:hook] + end + + def slack_client + Slack.configure do |config| + config.token = hook.access_token + end + Slack::Web::Client.new + end + + def create_channel + @channel = slack_client.conversations_create(name: params[:channel]) + end + + def update_reference_id + @hook.reference_id = channel['channel']['id'] + end +end diff --git a/lib/integrations/slack/hook_builder.rb b/lib/integrations/slack/hook_builder.rb new file mode 100644 index 000000000..8900b2a5a --- /dev/null +++ b/lib/integrations/slack/hook_builder.rb @@ -0,0 +1,42 @@ +class Integrations::Slack::HookBuilder + attr_reader :params + + def initialize(params) + @params = params + end + + def perform + token = fetch_access_token + + hook = account.hooks.new( + access_token: token, + status: 'enabled', + inbox_id: params[:inbox_id], + hook_type: hook_type, + app_id: 'cw_slack' + ) + + hook.save! + hook + end + + private + + def account + params[:account] + end + + def hook_type + params[:inbox_id] ? 'inbox' : 'account' + end + + def fetch_access_token + client = Slack::Web::Client.new + + client.oauth_access( + client_id: ENV.fetch('SLACK_CLIENT_ID', 'TEST_CLIENT_ID'), + client_secret: ENV.fetch('SLACK_CLIENT_SECRET', 'TEST_CLIENT_SECRET'), + code: params[:code] + )['bot']['bot_access_token'] + end +end diff --git a/lib/integrations/slack/incoming_message_builder.rb b/lib/integrations/slack/incoming_message_builder.rb new file mode 100644 index 000000000..3236739ba --- /dev/null +++ b/lib/integrations/slack/incoming_message_builder.rb @@ -0,0 +1,78 @@ +class Integrations::Slack::IncomingMessageBuilder + attr_reader :params + + SUPPORTED_EVENT_TYPES = %w[event_callback url_verification].freeze + SUPPORTED_EVENTS = %w[message].freeze + SUPPORTED_MESSAGE_TYPES = %w[rich_text].freeze + + def initialize(params) + @params = params + end + + def perform + return unless valid_event? + + if hook_verification? + verify_hook + elsif create_message? + create_message + end + end + + private + + def valid_event? + supported_event_type? && supported_event? + end + + def supported_event_type? + SUPPORTED_EVENT_TYPES.include?(params[:type]) + end + + def supported_event? + hook_verification? || SUPPORTED_EVENTS.include?(params[:event][:type]) + end + + def supported_message? + SUPPORTED_MESSAGE_TYPES.include?(message[:type]) + end + + def hook_verification? + params[:type] == 'url_verification' + end + + def create_message? + supported_message? && integration_hook + end + + def message + params[:event][:blocks].first + end + + def verify_hook + { + challenge: params[:challenge] + } + end + + def integration_hook + @integration_hook ||= Integrations::Hook.where(reference_id: params[:event][:channel]) + end + + def conversation + @conversation ||= Conversation.where(identifier: params[:event][:thread_ts]).first + end + + def create_message + return unless conversation + + conversation.messages.create( + message_type: 0, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + content: message[:elements].first[:elements].first[:text] + ) + + { status: 'success' } + end +end diff --git a/lib/integrations/slack/outgoing_message_builder.rb b/lib/integrations/slack/outgoing_message_builder.rb new file mode 100644 index 000000000..61754d816 --- /dev/null +++ b/lib/integrations/slack/outgoing_message_builder.rb @@ -0,0 +1,50 @@ +class Integrations::Slack::OutgoingMessageBuilder + attr_reader :hook, :message + + def self.perform(hook, message) + new(hook, message).perform + end + + def initialize(hook, message) + @hook = hook + @message = message + end + + def perform + send_message + update_reference_id + end + + private + + def conversation + @conversation ||= message.conversation + end + + def contact + @contact ||= conversation.contact + end + + def send_message + @slack_message = slack_client.chat_postMessage( + channel: hook.reference_id, + text: message.content, + username: contact.try(:name), + thread_ts: conversation.identifier + ) + end + + def update_reference_id + return if conversation.identifier + + conversation.identifier = @slack_message['ts'] + conversation.save! + end + + def slack_client + Slack.configure do |config| + config.token = hook.access_token + end + Slack::Web::Client.new + end +end diff --git a/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb new file mode 100644 index 000000000..78c72db7e --- /dev/null +++ b/spec/controllers/api/v1/accounts/integrations/apps_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe 'Integration Apps API', type: :request do + let(:account) { create(:account) } + + describe 'GET /api/v1/integrations/apps' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get api_v1_account_integrations_apps_url(account) + 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) } + + it 'returns all the apps' do + get api_v1_account_integrations_apps_url(account), + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + app = JSON.parse(response.body).first + expect(app['id']).to eql('cw_slack') + expect(app['name']).to eql('Slack') + end + end + end + + describe 'GET /api/v1/integrations/apps/:id' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get api_v1_account_integrations_app_url(account_id: account.id, id: 'cw_slack') + 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) } + + it 'returns details of the app' do + get api_v1_account_integrations_app_url(account_id: account.id, id: 'cw_slack'), + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + app = JSON.parse(response.body) + expect(app['id']).to eql('cw_slack') + expect(app['name']).to eql('Slack') + end + end + end +end diff --git a/spec/factories/conversations.rb b/spec/factories/conversations.rb index a14845e7f..ca5960a51 100644 --- a/spec/factories/conversations.rb +++ b/spec/factories/conversations.rb @@ -7,6 +7,7 @@ FactoryBot.define do user_last_seen_at { Time.current } agent_last_seen_at { Time.current } locked { false } + identifier { SecureRandom.hex } after(:build) do |conversation| conversation.account ||= create(:account) diff --git a/spec/factories/integrations/hooks.rb b/spec/factories/integrations/hooks.rb new file mode 100644 index 000000000..6d6ed4a1b --- /dev/null +++ b/spec/factories/integrations/hooks.rb @@ -0,0 +1,12 @@ +FactoryBot.define do + factory :integrations_hook, class: 'Integrations::Hook' do + status { 1 } + inbox_id { 1 } + account_id { 1 } + app_id { 'cw_slack' } + settings { 'MyText' } + hook_type { 1 } + access_token { SecureRandom.hex } + reference_id { SecureRandom.hex } + end +end diff --git a/spec/jobs/hook_job_spec.rb b/spec/jobs/hook_job_spec.rb new file mode 100644 index 000000000..b852db7ad --- /dev/null +++ b/spec/jobs/hook_job_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe HookJob, type: :job do + subject(:job) { described_class.perform_later(hook, message) } + + let(:account) { create(:account) } + let(:hook) { create(:integrations_hook, account: account) } + let(:message) { create(:message) } + + it 'queues the job' do + expect { job }.to have_enqueued_job(described_class) + .with(hook, message) + .on_queue('integrations') + end +end diff --git a/spec/lib/integrations/slack/hook_builder_spec.rb b/spec/lib/integrations/slack/hook_builder_spec.rb new file mode 100644 index 000000000..948b14fae --- /dev/null +++ b/spec/lib/integrations/slack/hook_builder_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +describe Integrations::Slack::HookBuilder do + let(:account) { create(:account) } + let(:code) { SecureRandom.hex } + let(:token) { SecureRandom.hex } + + describe '#perform' do + it 'creates hook' do + hooks_count = account.hooks.count + + builder = described_class.new(account: account, code: code) + builder.stub(:fetch_access_token) { token } + + builder.perform + expect(account.hooks.count).to eql(hooks_count + 1) + + hook = account.hooks.last + expect(hook.access_token).to eql(token) + 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 new file mode 100644 index 000000000..7d38a9ac7 --- /dev/null +++ b/spec/lib/integrations/slack/incoming_message_builder_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +describe Integrations::Slack::IncomingMessageBuilder do + let(:account) { create(:account) } + let(:message_params) { slack_message_stub } + let(:verification_params) { slack_url_verification_stub } + + let(:hook) { create(:integrations_hook, account: account, reference_id: message_params[:event][:channel]) } + let!(:conversation) { create(:conversation, identifier: message_params[:event][:thread_ts]) } + + describe '#perform' do + context 'when url verification' do + it 'return challenge code as response' do + builder = described_class.new(verification_params) + response = builder.perform + expect(response[:challenge]).to eql(verification_params[:challenge]) + end + end + + context 'when message creation' do + it 'creates message' do + messages_count = conversation.messages.count + builder = described_class.new(message_params) + builder.perform + expect(conversation.messages.count).to eql(messages_count + 1) + end + + it 'does not create message for invalid event type' do + messages_count = conversation.messages.count + message_params[:type] = 'invalid_event_type' + builder = described_class.new(message_params) + builder.perform + expect(conversation.messages.count).to eql(messages_count) + end + + it 'does not create message for invalid event name' do + messages_count = conversation.messages.count + message_params[:event][:type] = 'invalid_event_name' + builder = described_class.new(message_params) + builder.perform + expect(conversation.messages.count).to eql(messages_count) + end + end + end +end diff --git a/spec/lib/integrations/slack/outgoing_message_builder_spec.rb b/spec/lib/integrations/slack/outgoing_message_builder_spec.rb new file mode 100644 index 000000000..d52ed33aa --- /dev/null +++ b/spec/lib/integrations/slack/outgoing_message_builder_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +describe Integrations::Slack::OutgoingMessageBuilder do + let(:account) { create(:account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:contact) { create(:contact) } + + let!(:hook) { create(:integrations_hook, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) } + + describe '#perform' do + it 'sent message to slack' do + builder = described_class.new(hook, message) + stub_request(:post, 'https://slack.com/api/chat.postMessage') + .to_return(status: 200, body: '', headers: {}) + + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Slack::Web::Client).to receive(:chat_postMessage).with( + channel: hook.reference_id, + text: message.content, + username: contact.name, + thread_ts: conversation.identifier + ) + # rubocop:enable RSpec/AnyInstance + + builder.perform + end + end +end diff --git a/spec/listeners/hook_listener_spec.rb b/spec/listeners/hook_listener_spec.rb new file mode 100644 index 000000000..f22a074ab --- /dev/null +++ b/spec/listeners/hook_listener_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' +describe HookListener do + let(:listener) { described_class.instance } + let!(:account) { create(:account) } + let!(:user) { create(:user, account: account) } + let!(:inbox) { create(:inbox, account: account) } + let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) } + let!(:message) do + create(:message, message_type: 'outgoing', + account: account, inbox: inbox, conversation: conversation) + end + let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) } + + describe '#message_created' do + let(:event_name) { :'conversation.created' } + + context 'when hook is not configured' do + it 'does not trigger hook job' do + expect(HookJob).to receive(:perform_later).exactly(0).times + listener.message_created(event) + end + end + + context 'when hook is configured' do + it 'triggers hook job' do + hook = create(:integrations_hook, account: account) + expect(HookJob).to receive(:perform_later).with(hook, message).once + listener.message_created(event) + end + end + end +end diff --git a/spec/models/inbox_spec.rb b/spec/models/inbox_spec.rb index 65dd9ee9e..e92e66c31 100644 --- a/spec/models/inbox_spec.rb +++ b/spec/models/inbox_spec.rb @@ -29,6 +29,8 @@ RSpec.describe Inbox do it { is_expected.to have_many(:webhooks).dependent(:destroy) } it { is_expected.to have_many(:events) } + + it { is_expected.to have_many(:hooks) } end describe '#add_member' do diff --git a/spec/models/integrations/hook_spec.rb b/spec/models/integrations/hook_spec.rb new file mode 100644 index 000000000..da5f1ff20 --- /dev/null +++ b/spec/models/integrations/hook_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Integrations::Hook, type: :model do + context 'with validations' do + it { is_expected.to validate_presence_of(:app_id) } + it { is_expected.to validate_presence_of(:account_id) } + end + + describe 'associations' do + it { is_expected.to belong_to(:account) } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index ef39ac0ef..bb4475cca 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -22,7 +22,9 @@ require 'sidekiq/testing' # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -# Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } +# rubocop:disable Rails/FilePath +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } +# rubocop:enable Rails/FilePath # Checks for pending migrations and applies them before tests are run. # If you are not using ActiveRecord, you can remove these lines. @@ -61,7 +63,7 @@ RSpec.configure do |config| config.filter_rails_from_backtrace! # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") - + config.include SlackStubs config.include Devise::Test::IntegrationHelpers, type: :request end diff --git a/spec/requests/api/v1/accounts/integrations/slack_request_spec.rb b/spec/requests/api/v1/accounts/integrations/slack_request_spec.rb new file mode 100644 index 000000000..71f8c1d18 --- /dev/null +++ b/spec/requests/api/v1/accounts/integrations/slack_request_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Integrations::Slacks', type: :request do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:hook) { create(:integrations_hook, account: account) } + + describe 'POST /api/v1/accounts/{account.id}/integrations/slack' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/integrations/slack", params: {} + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'creates hook' do + hook_builder = Integrations::Slack::HookBuilder.new(account: account, code: SecureRandom.hex) + hook_builder.stub(:fetch_access_token) { SecureRandom.hex } + + expect(Integrations::Slack::HookBuilder).to receive(:new).and_return(hook_builder) + + post "/api/v1/accounts/#{account.id}/integrations/slack", + params: { code: SecureRandom.hex }, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['app_id']).to eql('cw_slack') + end + end + + describe 'PUT /api/v1/accounts/{account.id}/integrations/slack/{id}' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + put "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", params: {} + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'updates hook' do + channel_builder = Integrations::Slack::ChannelBuilder.new(hook: hook, channel: 'channel') + channel_builder.stub(:perform) + + expect(Integrations::Slack::ChannelBuilder).to receive(:new).and_return(channel_builder) + + put "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", + params: { channel: SecureRandom.hex }, + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:success) + json_response = JSON.parse(response.body) + expect(json_response['app_id']).to eql('cw_slack') + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/integrations/slack/{id}' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + delete "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", params: {} + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + it 'deletes hook' do + delete "/api/v1/accounts/#{account.id}/integrations/slack/#{hook.id}", + headers: agent.create_new_auth_token + expect(response).to have_http_status(:success) + expect(Integrations::Hook.find_by(id: hook.id)).to be nil + end + end + end + end +end diff --git a/spec/requests/api/v1/integrations/webhooks_request_spec.rb b/spec/requests/api/v1/integrations/webhooks_request_spec.rb new file mode 100644 index 000000000..1a29f2695 --- /dev/null +++ b/spec/requests/api/v1/integrations/webhooks_request_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Integrations::Webhooks', type: :request do + describe 'POST /api/v1/integrations/webhooks' do + it 'consumes webhook' do + builder = Integrations::Slack::IncomingMessageBuilder.new({}) + builder.stub(:perform) { true } + + expect(Integrations::Slack::IncomingMessageBuilder).to receive(:new).and_return(builder) + + post '/api/v1/integrations/webhooks', params: {} + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ee6aa71b9..207f3bea6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,8 @@ require 'simplecov' +require 'webmock/rspec' + SimpleCov.start 'rails' +WebMock.allow_net_connect! RSpec.configure do |config| config.expect_with :rspec do |expectations| diff --git a/spec/support/slack_stubs.rb b/spec/support/slack_stubs.rb new file mode 100644 index 000000000..971803f4e --- /dev/null +++ b/spec/support/slack_stubs.rb @@ -0,0 +1,53 @@ +module SlackStubs + def slack_url_verification_stub + { + "token": 'Jhj5dZrVaK7ZwHHjRyZWjbDl', + "challenge": '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + "type": 'url_verification' + } + end + + # rubocop:disable Metrics/MethodLength + def slack_message_stub + { + "token": '[FILTERED]', + "team_id": 'TLST3048H', + "api_app_id": 'A012S5UETV4', + "event": { + "client_msg_id": 'ffc6e64e-6f0c-4a3d-b594-faa6b44e48ab', + "type": 'message', + "text": 'this is test', + "user": 'ULYPAKE5S', + "ts": '1588623033.006000', + "team": 'TLST3048H', + "blocks": [ + { + "type": 'rich_text', + "block_id": 'jaIv3', + "elements": [ + { + "type": 'rich_text_section', + "elements": [ + { + "type": 'text', + "text": 'this is test' + } + ] + } + ] + } + ], + "thread_ts": '1588623023.005900', + "channel": 'G01354F6A6Q', + "event_ts": '1588623033.006000', + "channel_type": 'group' + }, + "type": 'event_callback', + "event_id": 'Ev013QUX3WV6', + "event_time": 1_588_623_033, + "authed_users": '[FILTERED]', + "webhook": {} + } + end + # rubocop:enable Metrics/MethodLength +end