Chore: Routine Bugfixes and enhancements (#979)

- Fix slack scopes
- Docs for authentication
Fixes: #704 , #973
This commit is contained in:
Sojan Jose 2020-06-25 23:35:16 +05:30 committed by GitHub
parent 0aab717bb3
commit 4f83d5451e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 254 additions and 147 deletions

View file

@ -98,7 +98,7 @@ jobs:
- run:
name: Run backend tests
command: |
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
- persist_to_workspace:
root: ~/tmp

View file

@ -93,10 +93,10 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.1)
attr_extras (6.2.4)
autoprefixer-rails (9.7.6)
autoprefixer-rails (9.8.2)
execjs
aws-eventstream (1.1.0)
aws-partitions (1.329.0)
aws-partitions (1.332.0)
aws-sdk-core (3.100.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
@ -105,12 +105,12 @@ GEM
aws-sdk-kms (1.34.1)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.68.1)
aws-sdk-s3 (1.69.1)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.4)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-sigv4 (1.2.0)
aws-eventstream (~> 1, >= 1.0.2)
axiom-types (0.1.1)
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
@ -163,7 +163,7 @@ GEM
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 6.1)
sprockets (= 3.7.2)
diff-lcs (1.3)
diff-lcs (1.4)
digest-crc (0.5.1)
docile (1.3.2)
domain_name (0.5.20190701)
@ -178,11 +178,11 @@ GEM
facebook-messenger (1.5.0)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (5.2.0)
activesupport (>= 4.2.0)
factory_bot_rails (5.2.0)
factory_bot (~> 5.2.0)
railties (>= 4.2.0)
factory_bot (6.0.2)
activesupport (>= 5.0.0)
factory_bot_rails (6.0.0)
factory_bot (~> 6.0.0)
railties (>= 5.0.0)
faker (2.12.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
@ -197,7 +197,7 @@ GEM
gli (2.19.1)
globalid (0.4.2)
activesupport (>= 4.2.0)
google-api-client (0.40.2)
google-api-client (0.41.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 0.9)
httpclient (>= 2.8.1, < 3.0)
@ -218,7 +218,7 @@ GEM
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
mini_mime (~> 1.0)
googleauth (0.12.0)
googleauth (0.13.0)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@ -306,8 +306,8 @@ GEM
orm_adapter (0.5.0)
os (1.1.0)
parallel (1.19.2)
parser (2.7.1.3)
ast (~> 2.4.0)
parser (2.7.1.4)
ast (~> 2.4.1)
pg (1.2.3)
pry (0.13.1)
coderay (~> 1.1)
@ -367,7 +367,7 @@ GEM
redis-rack-cache (2.2.1)
rack-cache (>= 1.10, < 2)
redis-store (>= 1.6, < 2)
redis-store (1.8.2)
redis-store (1.9.0)
redis (>= 4, < 5)
regexp_parser (1.7.1)
representable (3.0.4)
@ -401,13 +401,13 @@ GEM
rspec-mocks (~> 3.9)
rspec-support (~> 3.9)
rspec-support (3.9.3)
rubocop (0.85.1)
rubocop (0.86.0)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
rexml
rubocop-ast (>= 0.0.3)
rubocop-ast (>= 0.0.3, < 1.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.0.3)

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def update
@label.update(permitted_params)
@label.update!(permitted_params)
end
def destroy

View file

@ -59,11 +59,19 @@ class ApplicationController < ActionController::Base
render json: exception.to_hash, status: exception.http_status
end
def locale_from_params
I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
end
def locale_from_account(account)
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
end
def switch_locale(account)
# priority is for locale set in query string (mostly for widget/from js sdk)
locale ||= (I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil)
locale ||= locale_from_params
# if local is not set in param, lets try account
locale ||= (I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil)
locale ||= locale_from_account(account)
I18n.locale = locale || I18n.default_locale
end

View file

@ -4,6 +4,7 @@ class WidgetsController < ActionController::Base
before_action :set_token
before_action :set_contact
before_action :build_contact
after_action :allow_iframe_requests
def index; end
@ -50,4 +51,8 @@ class WidgetsController < ActionController::Base
def permitted_params
params.permit(:website_token, :cw_conversation)
end
def allow_iframe_requests
response.headers.delete('X-Frame-Options')
end
end

View file

@ -62,7 +62,7 @@ class ConversationFinder
def find_all_conversations
@conversations = current_account.conversations.includes(
:assignee, :contact, :inbox
:assignee, :inbox, contact: [:avatar_attachment]
).where(inbox_id: @inbox_ids)
end

View file

@ -1,6 +1,6 @@
import { required, minLength } from 'vuelidate/lib/validators';
export const validLabelCharacters = (str = '') => /^[\w-_]+$/g.test(str);
export const validLabelCharacters = (str = '') => !!str && !str.includes(' ');
export default {
title: {

View file

@ -4,7 +4,7 @@ class HookJob < ApplicationJob
def perform(hook, message)
return unless hook.slack?
Integrations::Slack::OutgoingMessageBuilder.perform(hook, message)
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
rescue StandardError => e
Raven.capture_exception(e)
end

View file

@ -138,11 +138,11 @@ class Message < ApplicationRecord
def send_reply
channel_name = conversation.inbox.channel.class.to_s
if channel_name == 'Channel::FacebookPage'
::Facebook::SendReplyService.new(message: self).perform
::Facebook::SendOnFacebookService.new(message: self).perform
elsif channel_name == 'Channel::TwitterProfile'
::Twitter::SendReplyService.new(message: self).perform
::Twitter::SendOnTwitterService.new(message: self).perform
elsif channel_name == 'Channel::TwilioSms'
::Twilio::OutgoingMessageService.new(message: self).perform
::Twilio::SendOnTwilioService.new(message: self).perform
end
end

View file

@ -0,0 +1,55 @@
#######################################
# To create an external channel reply service
# - Inherit this as the base class.
# - Implement `channel_class` method in your child class.
# - Implement `perform_reply` method in your child class.
# - Implement additional custom logic for your `perform_reply` method.
# - When required override the validation_methods.
# - Use Childclass.new.perform.
######################################
class Base::SendOnChannelService
pattr_initialize [:message!]
def perform
validate_target_channel
return unless outgoing_message?
return if invalid_message?
perform_reply
end
private
delegate :conversation, to: :message
delegate :contact, :contact_inbox, :inbox, to: :conversation
delegate :channel, to: :inbox
def channel_class
raise 'Overwrite this method in child class'
end
def perform_reply
raise 'Overwrite this method in child class'
end
def outgoing_message_originated_from_channel?
# TODO: we need to refactor this logic as more integrations comes by
# chatwoot messages won't have source id at the moment
# outgoing messages may be created in slack which should be send to the channel
message.source_id.present? && !message.source_id.starts_with?('slack_')
end
def outgoing_message?
message.outgoing? || message.template?
end
def invalid_message?
# private notes aren't send to the channels
# we should also avoid the case of message loops, when outgoing messages are created from channel
message.private? || outgoing_message_originated_from_channel?
end
def validate_target_channel
raise 'Invalid channel service was called' if inbox.channel.class != channel_class
end
end

View file

@ -1,37 +1,14 @@
class Facebook::SendReplyService
pattr_initialize [:message!]
def perform
return if message.private
return if inbox.channel.class.to_s != 'Channel::FacebookPage'
return unless outgoing_message_from_chatwoot?
FacebookBot::Bot.deliver(delivery_params, access_token: message.channel_token)
end
class Facebook::SendOnFacebookService < Base::SendOnChannelService
private
delegate :contact, to: :conversation
def inbox
@inbox ||= message.inbox
def channel_class
Channel::FacebookPage
end
def conversation
@conversation ||= message.conversation
def perform_reply
FacebookBot::Bot.deliver(delivery_params, access_token: message.channel_token)
end
def outgoing_message_from_chatwoot?
# messages sent directly from chatwoot won't have source_id.
(message.outgoing? || message.template?) && !message.source_id
end
# def reopen_lock
# if message.incoming? && conversation.locked?
# conversation.unlock!
# end
# end
def fb_text_message_params
{
recipient: { id: contact.get_source_id(inbox.id) },

View file

@ -1,22 +1,15 @@
class Twilio::OutgoingMessageService
pattr_initialize [:message!]
class Twilio::SendOnTwilioService < Base::SendOnChannelService
private
def perform
return if message.private
return if message.source_id
return if inbox.channel.class.to_s != 'Channel::TwilioSms'
return unless outgoing_message?
def channel_class
Channel::TwilioSms
end
def perform_reply
twilio_message = client.messages.create(message_params)
message.update!(source_id: twilio_message.sid)
end
private
delegate :conversation, to: :message
delegate :contact, to: :conversation
delegate :contact_inbox, to: :conversation
def message_params
params = {
body: message.content,

View file

@ -1,16 +1,17 @@
class Twitter::SendReplyService
class Twitter::SendOnTwitterService < Base::SendOnChannelService
pattr_initialize [:message!]
def perform
return if message.private
return if message.source_id
return if inbox.channel.class.to_s != 'Channel::TwitterProfile'
return unless outgoing_message_from_chatwoot?
private
send_reply
delegate :additional_attributes, to: :contact
def channel_class
Channel::TwitterProfile
end
private
def perform_reply
conversation_type == 'tweet' ? send_tweet_reply : send_direct_message
end
def twitter_client
Twitty::Facade.new do |config|
@ -50,19 +51,4 @@ class Twitter::SendReplyService
Rails.logger.info 'TWITTER_TWEET_REPLY_ERROR' + response.body
end
end
def send_reply
conversation_type == 'tweet' ? send_tweet_reply : send_direct_message
end
def outgoing_message_from_chatwoot?
(message.outgoing? || message.template?)
end
delegate :additional_attributes, to: :contact
delegate :contact, to: :conversation
delegate :contact_inbox, to: :conversation
delegate :conversation, to: :message
delegate :inbox, to: :conversation
delegate :channel, to: :inbox
end

View file

@ -12,7 +12,7 @@ json.id conversation.display_id
if conversation.unread_incoming_messages.count.zero?
json.messages [conversation.messages.last.try(:push_event_data)]
else
json.messages conversation.unread_messages.map(&:push_event_data)
json.messages conversation.unread_messages.includes([:user, :attachments]).map(&:push_event_data)
end
json.inbox_id conversation.inbox_id

View file

@ -27,10 +27,6 @@ module Chatwoot
config.generators.javascripts = false
config.generators.stylesheets = false
config.action_dispatch.default_headers = {
'X-Frame-Options' => 'ALLOWALL'
}
# Custom chatwoot configurations
config.x = config_for(:app).with_indifferent_access
end

View file

@ -7,3 +7,7 @@ redis = Rails.env.test? ? MockRedis.new : Redis.new(app_redis_config)
# Alfred - Used currently for round robin and conversation emails.
# Add here as you use it for more features
$alfred = Redis::Namespace.new('alfred', redis: redis, warning: true)
# https://github.com/mperham/sidekiq/issues/4591
# TODO once sidekiq remove we can remove this
Redis.exists_returns_integer = false

View file

@ -3,7 +3,7 @@ slack:
name: Slack
logo: slack.png
description: "Slack is a chat tool that brings all your communication together in one place. By integrating Slack, you can get notified of all the new conversations in your account right inside your Slack."
action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize
action: https://slack.com/oauth/v2/authorize?scope=commands,chat:write,channels:read,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history
webhooks:
id: webhook
name: Webhooks

View file

@ -16,7 +16,7 @@ Once you register your Slack App, you will have to obtain the `Client Id` and `C
3) Head over to the `OAuth & permissions` section under `features` tab.
4) In the redirect URLs, Add your Chatwoot installation base url.
5) In the scopes section configure the given scopes for bot token scopes.
`commands,chat:write,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize`
`commands,chat:write,channels:read,channels:manage,channels:join,groups:write,im:write,mpim:write,users:read,users:read.email,chat:write.customize,channels:history,groups:history,mpim:history,im:history`
6) Head over to the `events subscriptions` section under `features` tab.
7) Enable events and configure the the given request url `{chatwoot installation url}/api/v1/integrations/webhooks`
8) Subscribe to the following bot events `message.channels` , `message.groups`, `message.im`, `message.mpim`

View file

@ -17,10 +17,7 @@ class Integrations::Slack::ChannelBuilder
end
def slack_client
Slack.configure do |config|
config.token = hook.access_token
end
Slack::Web::Client.new
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
end
def find_or_create_channel
@ -29,6 +26,7 @@ class Integrations::Slack::ChannelBuilder
end
def update_reference_id
@hook.update(reference_id: channel['id'])
slack_client.conversations_join(channel: channel[:id])
@hook.update(reference_id: channel[:id])
end
end

View file

@ -89,9 +89,6 @@ class Integrations::Slack::IncomingMessageBuilder
end
def slack_client
Slack.configure do |config|
config.token = integration_hook.access_token
end
Slack::Web::Client.new
@slack_client ||= Slack::Web::Client.new(token: @integration_hook.access_token)
end
end

View file

@ -1,30 +1,23 @@
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
class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
pattr_initialize [:message!, :hook!]
def perform
return if message.source_id.present?
# overriding the base class logic since the validations are different in this case.
# FIXME: for now we will only send messages from widget to slack
return unless channel.is_a?(Channel::WebWidget)
# we don't want message loop in slack
return if message.source_id.try(:starts_with?, 'slack_')
# we don't want to start slack thread from agent conversation as of now
return if message.outgoing? && conversation.identifier.blank?
send_message
update_reference_id
perform_reply
end
private
def conversation
@conversation ||= message.conversation
end
def contact
@contact ||= conversation.contact
def perform_reply
send_message
update_reference_id
end
def agent
@ -32,8 +25,9 @@ class Integrations::Slack::OutgoingMessageBuilder
end
def message_content
private_indicator = message.private? ? 'private: ' : ''
if conversation.identifier.present?
message.content
"#{private_indicator}#{message.content}"
else
"*Inbox: #{message.inbox.name}* \n\n #{message.content}"
end
@ -59,14 +53,10 @@ class Integrations::Slack::OutgoingMessageBuilder
def update_reference_id
return if conversation.identifier
conversation.identifier = @slack_message['ts']
conversation.save!
conversation.update!(identifier: @slack_message['ts'])
end
def slack_client
Slack.configure do |config|
config.token = hook.access_token
end
Slack::Web::Client.new
@slack_client ||= Slack::Web::Client.new(token: hook.access_token)
end
end

View file

@ -14,15 +14,16 @@ RSpec.describe 'Integration Apps API', type: :request do
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns all the apps' do
it 'returns all active apps' do
first_app = Integrations::App.all.find(&:active?)
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)['payload'].first
expect(app['id']).to eql('webhook')
expect(app['name']).to eql('Webhooks')
apps = JSON.parse(response.body)['payload'].first
expect(apps['id']).to eql(first_app.id)
expect(apps['name']).to eql(first_app.name)
end
end
end

View file

@ -1,6 +1,6 @@
require 'rails_helper'
describe Integrations::Slack::OutgoingMessageBuilder do
describe Integrations::Slack::SendOnSlackService do
let(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:contact) { create(:contact) }
@ -11,7 +11,7 @@ describe Integrations::Slack::OutgoingMessageBuilder do
describe '#perform' do
it 'sent message to slack' do
builder = described_class.new(hook, message)
builder = described_class.new(message: message, hook: hook)
stub_request(:post, 'https://slack.com/api/chat.postMessage')
.to_return(status: 200, body: '', headers: {})
slack_client = double

View file

@ -1,6 +1,6 @@
require 'rails_helper'
describe Facebook::SendReplyService do
describe Facebook::SendOnFacebookService do
subject(:send_reply_service) { described_class.new(message: message) }
before do

View file

@ -1,6 +1,6 @@
require 'rails_helper'
describe Twilio::OutgoingMessageService do
describe Twilio::SendOnTwilioService do
subject(:outgoing_message_service) { described_class.new(message: message) }
let(:twilio_client) { instance_double(::Twilio::REST::Client) }

View file

@ -1,6 +1,6 @@
require 'rails_helper'
describe Twitter::SendReplyService do
describe Twitter::SendOnTwitterService do
subject(:send_reply_service) { described_class.new(message: message) }
let(:twitter_client) { instance_double(::Twitty::Facade) }

View file

@ -14,6 +14,4 @@ RSpec.configure do |config|
end
config.shared_context_metadata_behavior = :apply_to_host_groups
# config.include Rails.application.routes.url_helpers
end

View file

@ -17,6 +17,24 @@ produces:
- application/json; charset=utf-8
consumes:
- application/json; charset=utf-8
securityDefinitions:
userApiKey:
type: apiKey
in: header
name: api_access_token
description: This token can be obtained by visiting the profile page or via rails console. Provides access to endpoints based on the user permissions levels. This token can be saved by an external system when user is created via API, to perform activities on behalf of the user.
agentBotApiKey:
type: apiKey
in: header
name: api_access_token
description: This token should be provided by system admin or obtained via rails console. This token can be used to build bot integrations and can only access limited apis.
superAdminApiKey:
type: apiKey
in: header
name: api_access_token
description: This token is only for the system admin or obtained via rails console. This token is to be used rarely for cases like creating a pre verified user through api from external system.
security:
- userApiKey: []
paths:
$ref: ./paths/index.yml

View file

@ -45,6 +45,9 @@ post:
operationId: newConversation
summary: Create New Conversation
description: Create conversation
security:
- userApiKey: []
- agentBotApiKey: []
parameters:
- name: data
in: body

View file

@ -30,6 +30,9 @@ post:
operationId: conversationNewMessage
summary: Create New Message
description: All the agent replies are created as new messages through this endpoint
security:
- userApiKey: []
- agentBotApiKey: []
parameters:
- name: id
in: path

View file

@ -4,6 +4,9 @@ post:
operationId: conversationToggleStatus
summary: Toggle Status
description: Toggles the status of the conversation between open and resolved
security:
- userApiKey: []
- agentBotApiKey: []
parameters:
- name: id
in: path

View file

@ -24,6 +24,33 @@
"consumes": [
"application/json; charset=utf-8"
],
"securityDefinitions": {
"userApiKey": {
"type": "apiKey",
"in": "header",
"name": "api_access_token",
"description": "This token can be obtained by visiting the profile page or via rails console. Provides access to endpoints based on the user permissions levels. This token can be saved by an external system when user is created via API, to perform activities on behalf of the user."
},
"agentBotApiKey": {
"type": "apiKey",
"in": "header",
"name": "api_access_token",
"description": "This token should be provided by system admin or obtained via rails console. This token can be used to build bot integrations and can only access limited apis."
},
"superAdminApiKey": {
"type": "apiKey",
"in": "header",
"name": "api_access_token",
"description": "This token is only for the system admin or obtained via rails console. This token is to be used rarely for cases like creating a pre verified user through api from external system."
}
},
"security": [
{
"userApiKey": [
]
}
],
"paths": {
"/accounts/{account_id}/inboxes": {
"post": {
@ -325,6 +352,18 @@
"operationId": "newConversation",
"summary": "Create New Conversation",
"description": "Create conversation",
"security": [
{
"userApiKey": [
]
},
{
"agentBotApiKey": [
]
}
],
"parameters": [
{
"name": "data",
@ -409,6 +448,18 @@
"operationId": "conversationToggleStatus",
"summary": "Toggle Status",
"description": "Toggles the status of the conversation between open and resolved",
"security": [
{
"userApiKey": [
]
},
{
"agentBotApiKey": [
]
}
],
"parameters": [
{
"name": "id",
@ -428,7 +479,8 @@
"type": "string",
"enum": [
"open",
"resolved"
"resolved",
"bot"
],
"required": true,
"description": "The status of the conversation"
@ -500,6 +552,18 @@
"operationId": "conversationNewMessage",
"summary": "Create New Message",
"description": "All the agent replies are created as new messages through this endpoint",
"security": [
{
"userApiKey": [
]
},
{
"agentBotApiKey": [
]
}
],
"parameters": [
{
"name": "id",
@ -1035,6 +1099,14 @@
"type": "number",
"description": "ID of the inbox"
},
"name": {
"type": "string",
"description": "The name of the inbox"
},
"website_url": {
"type": "string",
"description": "Website URL"
},
"channel_type": {
"type": "string",
"description": "The type of the inbox"