diff --git a/Gemfile.lock b/Gemfile.lock index 6cc4cf008..0eb225711 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/chatwoot/twitty - revision: 58b4958d7f4a58eec8fe9543caedb232308253f6 + revision: c1edd557401d1e8a197b19e738f82e39507a8e2d specs: twitty (0.1.0) oauth diff --git a/app/controllers/twitter/authorizations_controller.rb b/app/controllers/twitter/authorizations_controller.rb new file mode 100644 index 000000000..69145ff84 --- /dev/null +++ b/app/controllers/twitter/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Twitter::AuthorizationsController < Twitter::BaseController + def create + @response = twitter_client.request_oauth_token(url: twitter_callback_url) + + if @response.status == '200' + ::Redis::Alfred.setex(oauth_token, account.id) + redirect_to oauth_authorize_endpoint(oauth_token) + else + redirect_to app_new_twitter_inbox_url + end + end + + private + + def oauth_token + parsed_body['oauth_token'] + end + + def user + @user ||= User.find_by(id: params[:user_id]) + end + + def account + @account ||= user.account + end + + def oauth_authorize_endpoint(oauth_token) + "#{twitter_api_base_url}/oauth/authorize?oauth_token=#{oauth_token}" + end +end diff --git a/app/controllers/twitter/base_controller.rb b/app/controllers/twitter/base_controller.rb new file mode 100644 index 000000000..353c1159c --- /dev/null +++ b/app/controllers/twitter/base_controller.rb @@ -0,0 +1,24 @@ +class Twitter::BaseController < ApplicationController + private + + def parsed_body + @parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body) + end + + def host + ENV.fetch('FRONTEND_URL', '') + end + + def twitter_client + Twitty::Facade.new do |config| + config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil) + config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil) + config.base_url = twitter_api_base_url + config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '') + end + end + + def twitter_api_base_url + 'https://api.twitter.com' + end +end diff --git a/app/controllers/twitter/callbacks_controller.rb b/app/controllers/twitter/callbacks_controller.rb new file mode 100644 index 000000000..876720f26 --- /dev/null +++ b/app/controllers/twitter/callbacks_controller.rb @@ -0,0 +1,51 @@ +class Twitter::CallbacksController < Twitter::BaseController + def show + @response = twitter_client.access_token( + oauth_token: permitted_params[:oauth_token], + oauth_verifier: permitted_params[:oauth_verifier] + ) + if @response.status == '200' + inbox = build_inbox + ::Redis::Alfred.delete(permitted_params[:oauth_token]) + ::Twitter::WebhookSubscribeService.new(inbox_id: inbox.id).perform + redirect_to app_twitter_inbox_agents_url(inbox_id: inbox.id) + else + redirect_to app_new_twitter_inbox_url + end + end + + private + + def parsed_body + @parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body) + end + + def account_id + ::Redis::Alfred.get(permitted_params[:oauth_token]) + end + + def account + @account ||= Account.find_by!(id: account_id) + end + + def build_inbox + ActiveRecord::Base.transaction do + twitter_profile = account.twitter_profiles.create( + twitter_access_token: parsed_body['oauth_token'], + twitter_access_token_secret: parsed_body['oauth_token_secret'], + profile_id: parsed_body['user_id'], + name: parsed_body['screen_name'] + ) + account.inboxes.create( + name: parsed_body['screen_name'], + channel: twitter_profile + ) + rescue StandardError => e + Rails.logger e + end + end + + def permitted_params + params.permit(:oauth_token, :oauth_verifier) + end +end diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 939a18537..da22f71bd 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -39,7 +39,7 @@ export default { }, methods: { isActive(channel) { - return ['facebook', 'website'].includes(channel); + return ['facebook', 'website', 'twitter'].includes(channel); }, onItemClick() { if (this.isActive(this.channel)) { diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index dcaaec1fd..bc496305f 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -15,6 +15,9 @@ "FB": { "HELP": "PS: By signing in, we only get access to your Page's messages. Your private messages can never be accessed by Chatwoot." }, + "TWITTER": { + "HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' " + }, "WEBSITE_CHANNEL": { "TITLE": "Website channel", "DESC": "Create a channel for your website and start supporting your customers via our website widget.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js index 139bdc6d4..6e2dc3403 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channel-factory.js @@ -1,9 +1,11 @@ import Facebook from './channels/Facebook'; import Website from './channels/Website'; +import Twitter from './channels/Twitter'; const channelViewList = { facebook: Facebook, website: Website, + twitter: Twitter, }; export default { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue new file mode 100644 index 000000000..31ee3f984 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue @@ -0,0 +1,27 @@ + + + diff --git a/app/models/account.rb b/app/models/account.rb index 4eafc7c91..f0a2d5735 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -18,6 +18,7 @@ class Account < ApplicationRecord has_many :conversations, dependent: :destroy has_many :contacts, dependent: :destroy has_many :facebook_pages, dependent: :destroy, class_name: '::Channel::FacebookPage' + has_many :twitter_profiles, dependent: :destroy, class_name: '::Channel::TwitterProfile' has_many :web_widgets, dependent: :destroy, class_name: '::Channel::WebWidget' has_many :telegram_bots, dependent: :destroy has_many :canned_responses, dependent: :destroy diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb index 39e544288..a8427c55e 100644 --- a/app/models/channel/twitter_profile.rb +++ b/app/models/channel/twitter_profile.rb @@ -41,9 +41,21 @@ class Channel::TwitterProfile < ApplicationRecord end end + def twitter_client + Twitty::Facade.new do |config| + config.consumer_key = ENV.fetch('TWITTER_CONSUMER_KEY', nil) + config.consumer_secret = ENV.fetch('TWITTER_CONSUMER_SECRET', nil) + config.access_token = twitter_access_token + config.access_token_secret = twitter_access_token_secret + config.base_url = 'https://api.twitter.com' + config.environment = ENV.fetch('TWITTER_ENVIRONMENT', '') + end + end + private def unsubscribe - # to implement + webhooks_list = twitter_client.fetch_webhooks.body + twitter_client.unsubscribe_webhook(id: webhooks_list.first['id']) if webhooks_list.present? end end diff --git a/app/services/twitter/webhook_subscribe_service.rb b/app/services/twitter/webhook_subscribe_service.rb new file mode 100644 index 000000000..11e7af86f --- /dev/null +++ b/app/services/twitter/webhook_subscribe_service.rb @@ -0,0 +1,20 @@ +class Twitter::WebhookSubscribeService + include Rails.application.routes.url_helpers + + pattr_initialize [:inbox_id] + + def perform + register_response = twitter_client.register_webhook(url: webhooks_twitter_url) + twitter_client.subscribe_webhook if register_response.status == '200' + Rails.logger.info 'TWITTER_REGISTER_WEBHOOK_FAILURE: ' + register_response.body.to_s + end + + private + + delegate :channel, to: :inbox + delegate :twitter_client, to: :channel + + def inbox + Inbox.find_by!(id: inbox_id) + end +end diff --git a/config/routes.rb b/config/routes.rb index 447e96e0c..c1c935fc8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,8 @@ Rails.application.routes.draw do get '/app', to: 'dashboard#index' get '/app/*params', to: 'dashboard#index' + get '/app/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox' + get '/app/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents' match '/status', to: 'home#status', via: [:get] @@ -104,6 +106,11 @@ Rails.application.routes.draw do end end + namespace :twitter do + resource :authorization, only: [:create] + resource :callback, only: [:show] + end + # Used in mailer templates resource :app, only: [:index] do resources :conversations, only: [:show] diff --git a/spec/controllers/twitter/callbacks_controller_spec.rb b/spec/controllers/twitter/callbacks_controller_spec.rb new file mode 100644 index 000000000..425caa2de --- /dev/null +++ b/spec/controllers/twitter/callbacks_controller_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +RSpec.describe 'Twitter::CallbacksController', type: :request do + subject(:webhook_subscribe_service) { described_class.new(inbox_id: twitter_inbox.id) } + + let(:twitter_client) { instance_double(::Twitty::Facade) } + let(:twitter_response) { instance_double(::Twitty::Response, status: '200', body: { message: 'Valid' }) } + let(:raw_response) do + object_double('raw_response', body: 'oauth_token=1&oauth_token_secret=1&user_id=100&screen_name=chatwoot') + end + let(:account) { create(:account) } + + before do + allow(::Twitty::Facade).to receive(:new).and_return(twitter_client) + allow(::Redis::Alfred).to receive(:get).and_return(account.id) + allow(::Redis::Alfred).to receive(:delete).and_return('OK') + allow(twitter_client).to receive(:access_token).and_return(twitter_response) + allow(twitter_client).to receive(:register_webhook).and_return(twitter_response) + allow(twitter_client).to receive(:subscribe_webhook).and_return(true) + allow(twitter_response).to receive(:raw_response).and_return(raw_response) + end + + describe 'GET /twitter/callback' do + it 'renders the page correctly when called with website_token' do + get twitter_callback_url + account.reload + expect(response).to redirect_to app_twitter_inbox_agents_url(inbox_id: account.inboxes.last.id) + expect(account.inboxes.count).to be 1 + expect(account.twitter_profiles.last.inbox.name).to eq 'chatwoot' + expect(account.twitter_profiles.last.profile_id).to eq '100' + end + end +end diff --git a/spec/services/twitter/webhook_subscribe_service_spec.rb b/spec/services/twitter/webhook_subscribe_service_spec.rb new file mode 100644 index 000000000..8af7042de --- /dev/null +++ b/spec/services/twitter/webhook_subscribe_service_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +describe ::Twitter::WebhookSubscribeService do + subject(:webhook_subscribe_service) { described_class.new(inbox_id: twitter_inbox.id) } + + let(:twitter_client) { instance_double(::Twitty::Facade) } + let(:twitter_success_response) { instance_double(::Twitty::Response, status: '200', body: { message: 'Valid' }) } + let(:twitter_error_response) { instance_double(::Twitty::Response, status: '422', body: { message: 'Invalid request' }) } + let(:account) { create(:account) } + let(:twitter_channel) { create(:channel_twitter_profile, account: account) } + let(:twitter_inbox) { create(:inbox, channel: twitter_channel, account: account) } + + before do + allow(::Twitty::Facade).to receive(:new).and_return(twitter_client) + allow(twitter_client).to receive(:register_webhook) + allow(twitter_client).to receive(:subscribe_webhook) + end + + describe '#perform' do + context 'with successful registration' do + it 'calls subscribe webhook' do + allow(twitter_client).to receive(:register_webhook).and_return(twitter_success_response) + webhook_subscribe_service.perform + expect(twitter_client).to have_received(:subscribe_webhook) + end + end + + context 'with unsuccessful registration' do + it 'does not call subscribe webhook' do + allow(twitter_client).to receive(:register_webhook).and_return(twitter_error_response) + webhook_subscribe_service.perform + expect(twitter_client).not_to have_received(:subscribe_webhook) + end + end + end +end