mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
Feature: Slack integration (#783)
- Integrations architecture - Slack integration
This commit is contained in:
@@ -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
|
||||
|
||||
3
Gemfile
3
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
|
||||
|
||||
18
Gemfile.lock
18
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
11
app/jobs/hook_job.rb
Normal file
11
app/jobs/hook_job.rb
Normal file
@@ -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
|
||||
10
app/listeners/hook_listener.rb
Normal file
10
app/listeners/hook_listener.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
app/models/integrations.rb
Normal file
5
app/models/integrations.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module Integrations
|
||||
def self.table_name_prefix
|
||||
'integrations_'
|
||||
end
|
||||
end
|
||||
51
app/models/integrations/app.rb
Normal file
51
app/models/integrations/app.rb
Normal file
@@ -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
|
||||
36
app/models/integrations/hook.rb
Normal file
36
app/models/integrations/hook.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
1
config/initializers/00_init.rb
Normal file
1
config/initializers/00_init.rb
Normal file
@@ -0,0 +1 @@
|
||||
APPS_CONFIG = YAML.load_file(File.join(Rails.root, 'config/integration', 'apps.yml'))
|
||||
6
config/integration/apps.yml
Normal file
6
config/integration/apps.yml
Normal file
@@ -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: <a href="https://slack.com/oauth/v2/authorize?scope=incoming-webhook,commands,chat:write&client_id=706921004289.1094198503990"><img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
|
||||
@@ -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]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:queues:
|
||||
- [low, 1]
|
||||
- [webhooks, 1]
|
||||
- [integrations, 2]
|
||||
- [bots, 1]
|
||||
- [active_storage_analysis, 1]
|
||||
- [action_mailbox_incineration, 1]
|
||||
|
||||
15
db/migrate/20200430163438_create_integrations_hooks.rb
Normal file
15
db/migrate/20200430163438_create_integrations_hooks.rb
Normal file
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddReferenceIdToConversation < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
add_column :conversations, :reference_id, :string
|
||||
end
|
||||
end
|
||||
5
db/migrate/20200610143132_rename_reference_id.rb
Normal file
5
db/migrate/20200610143132_rename_reference_id.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class RenameReferenceId < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
rename_column :conversations, :reference_id, :identifier
|
||||
end
|
||||
end
|
||||
30
db/schema.rb
30
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|
|
||||
|
||||
33
lib/integrations/slack/channel_builder.rb
Normal file
33
lib/integrations/slack/channel_builder.rb
Normal file
@@ -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
|
||||
42
lib/integrations/slack/hook_builder.rb
Normal file
42
lib/integrations/slack/hook_builder.rb
Normal file
@@ -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
|
||||
78
lib/integrations/slack/incoming_message_builder.rb
Normal file
78
lib/integrations/slack/incoming_message_builder.rb
Normal file
@@ -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
|
||||
50
lib/integrations/slack/outgoing_message_builder.rb
Normal file
50
lib/integrations/slack/outgoing_message_builder.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
12
spec/factories/integrations/hooks.rb
Normal file
12
spec/factories/integrations/hooks.rb
Normal file
@@ -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
|
||||
15
spec/jobs/hook_job_spec.rb
Normal file
15
spec/jobs/hook_job_spec.rb
Normal file
@@ -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
|
||||
22
spec/lib/integrations/slack/hook_builder_spec.rb
Normal file
22
spec/lib/integrations/slack/hook_builder_spec.rb
Normal file
@@ -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
|
||||
45
spec/lib/integrations/slack/incoming_message_builder_spec.rb
Normal file
45
spec/lib/integrations/slack/incoming_message_builder_spec.rb
Normal file
@@ -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
|
||||
30
spec/lib/integrations/slack/outgoing_message_builder_spec.rb
Normal file
30
spec/lib/integrations/slack/outgoing_message_builder_spec.rb
Normal file
@@ -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
|
||||
32
spec/listeners/hook_listener_spec.rb
Normal file
32
spec/listeners/hook_listener_spec.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
12
spec/models/integrations/hook_spec.rb
Normal file
12
spec/models/integrations/hook_spec.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
15
spec/requests/api/v1/integrations/webhooks_request_spec.rb
Normal file
15
spec/requests/api/v1/integrations/webhooks_request_spec.rb
Normal file
@@ -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
|
||||
@@ -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|
|
||||
|
||||
53
spec/support/slack_stubs.rb
Normal file
53
spec/support/slack_stubs.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user