diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb new file mode 100644 index 000000000..eace4411a --- /dev/null +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -0,0 +1,30 @@ +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController + include InstagramConcern + include Instagram::IntegrationHelper + before_action :check_authorization + + def create + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization + redirect_url = instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{base_url}/instagram/callback", + scope: REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: generate_instagram_token(Current.account.id) + } + ) + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/concerns/instagram_concern.rb b/app/controllers/concerns/instagram_concern.rb new file mode 100644 index 000000000..f4dcfa010 --- /dev/null +++ b/app/controllers/concerns/instagram_concern.rb @@ -0,0 +1,75 @@ +module InstagramConcern + extend ActiveSupport::Concern + include HTTParty + + def instagram_client + ::OAuth2::Client.new( + client_id, + client_secret, + { + site: 'https://api.instagram.com', + authorize_url: 'https://api.instagram.com/oauth/authorize', + token_url: 'https://api.instagram.com/oauth/access_token', + auth_scheme: :request_body, + token_method: :post + } + ) + end + + private + + def client_id + GlobalConfigService.load('INSTAGRAM_APP_ID', nil) + end + + def client_secret + GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def exchange_for_long_lived_token(short_lived_token) + endpoint = 'https://graph.instagram.com/access_token' + params = { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + } + + make_api_request(endpoint, params, 'Failed to exchange token') + end + + def fetch_instagram_user_details(access_token) + endpoint = 'https://graph.instagram.com/v22.0/me' + params = { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + } + + make_api_request(endpoint, params, 'Failed to fetch Instagram user details') + end + + def make_api_request(endpoint, params, error_prefix) + response = HTTParty.get( + endpoint, + query: params, + headers: { 'Accept' => 'application/json' } + ) + + unless response.success? + Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" + raise "#{error_prefix}: #{response.body}" + end + + begin + JSON.parse(response.body) + rescue JSON::ParserError => e + ChatwootExceptionTracker.new(e).capture_exception + Rails.logger.error "Invalid JSON response: #{response.body}" + raise e + end + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end +end diff --git a/app/controllers/instagram/callbacks_controller.rb b/app/controllers/instagram/callbacks_controller.rb new file mode 100644 index 000000000..02add933f --- /dev/null +++ b/app/controllers/instagram/callbacks_controller.rb @@ -0,0 +1,123 @@ +class Instagram::CallbacksController < ApplicationController + include InstagramConcern + include Instagram::IntegrationHelper + + def show + # Check if Instagram redirected with an error (user canceled authorization) + # See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + if params[:error].present? + handle_authorization_error + return + end + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + # Process the authorization code and create inbox + def process_successful_authorization + @response = instagram_client.auth_code.get_token( + oauth_code, + redirect_uri: "#{base_url}/#{provider_name}/callback", + grant_type: 'authorization_code' + ) + + @long_lived_token_response = exchange_for_long_lived_token(@response.token) + inbox, = create_channel_with_inbox + redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + + # Handle all errors that might occur during authorization + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response + def handle_error(error) + Rails.logger.error("Instagram Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + error_info = extract_error_info(error) + redirect_to_error_page(error_info) + end + + # Extract error details from the exception + def extract_error_info(error) + if error.is_a?(OAuth2::Error) + begin + # Instagram returns JSON error response which we parse to extract error details + JSON.parse(error.message) + rescue JSON::ParseError + # Fall back to a generic OAuth error if JSON parsing fails + { 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message } + end + else + # For other unexpected errors + { 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message } + end + end + + # Handles the case when a user denies permissions or cancels the authorization flow + # Error parameters are documented at: + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization + def handle_authorization_error + error_info = { + 'error_type' => params[:error] || 'authorization_error', + 'code' => 400, + 'error_message' => params[:error_description] || 'Authorization was denied' + } + + Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}") + redirect_to_error_page(error_info) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_info) + redirect_to app_new_instagram_inbox_url( + account_id: account_id, + error_type: error_info['error_type'], + code: error_info['code'], + error_message: error_info['error_message'] + ) + end + + def create_channel_with_inbox + ActiveRecord::Base.transaction do + expires_at = Time.current + @long_lived_token_response['expires_in'].seconds + + user_details = fetch_instagram_user_details(@long_lived_token_response['access_token']) + + channel_instagram = Channel::Instagram.create!( + access_token: @long_lived_token_response['access_token'], + instagram_id: user_details['user_id'].to_s, + account: account, + expires_at: expires_at + ) + + account.inboxes.create!( + account: account, + channel: channel_instagram, + name: user_details['username'] + ) + end + end + + def account_id + return unless params[:state] + + verify_instagram_token(params[:state]) + end + + def oauth_code + params[:code] + end + + def account + @account ||= Account.find(account_id) + end + + def provider_name + 'instagram' + end +end diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 3e17a7369..550b6c893 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController ['MAILER_INBOUND_EMAIL_DOMAIN'] when 'linear' %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET] + when 'instagram' + %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT] else %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS] end diff --git a/app/controllers/webhooks/instagram_controller.rb b/app/controllers/webhooks/instagram_controller.rb index b658915ed..3d46334ca 100644 --- a/app/controllers/webhooks/instagram_controller.rb +++ b/app/controllers/webhooks/instagram_controller.rb @@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API private def valid_token?(token) - token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') + # Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and + # INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login) + token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || + token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') end end diff --git a/app/helpers/instagram/integration_helper.rb b/app/helpers/instagram/integration_helper.rb new file mode 100644 index 000000000..8ba57bf95 --- /dev/null +++ b/app/helpers/instagram/integration_helper.rb @@ -0,0 +1,49 @@ +module Instagram::IntegrationHelper + REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze + + # Generates a signed JWT token for Instagram integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_instagram_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate Instagram token: #{e.message}") + nil + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + # Verifies and decodes a Instagram JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_instagram_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil) + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/instagramClient.js b/app/javascript/dashboard/api/channel/instagramClient.js new file mode 100644 index 000000000..51ae26448 --- /dev/null +++ b/app/javascript/dashboard/api/channel/instagramClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class InstagramChannel extends ApiClient { + constructor() { + super('instagram', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new InstagramChannel(); diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 20f5ebed3..3834b0a3d 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -45,6 +45,12 @@ "PICK_NAME": "Pick a Name for your Inbox", "PICK_A_VALUE": "Pick a value" }, + "INSTAGRAM": { + "CONTINUE_WITH_INSTAGRAM": "Continue with Instagram", + "HELP": "To add your Instagram profile as a channel, you need to authenticate your Instagram Profile by clicking on 'Continue with Instagram' ", + "ERROR_MESSAGE": "There was an error connecting to Instagram, please try again", + "ERROR_AUTH": "Something went wrong with your Instagram authentication, please try again" + }, "TWITTER": { "HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ", "ERROR_MESSAGE": "There was an error connecting to Twitter, please try again", @@ -753,7 +759,8 @@ "EMAIL": "Email", "TELEGRAM": "Telegram", "LINE": "Line", - "API": "API Channel" + "API": "API Channel", + "INSTAGRAM": "Instagram" } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index b34c50c7f..7ea58e3e5 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -9,6 +9,7 @@ import Sms from './channels/Sms.vue'; import Whatsapp from './channels/Whatsapp.vue'; import Line from './channels/Line.vue'; import Telegram from './channels/Telegram.vue'; +import Instagram from './channels/Instagram.vue'; const channelViewList = { facebook: Facebook, @@ -20,6 +21,7 @@ const channelViewList = { whatsapp: Whatsapp, line: Line, telegram: Telegram, + instagram: Instagram, }; export default defineComponent({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue new file mode 100644 index 000000000..ff0933e11 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue @@ -0,0 +1,129 @@ + + + + + + + {{ errorStateMessage }} + + + + + + + + + {{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }} + + + + + + + + + + {{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }} + + + + + diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index fcfcb852e..b5ce02ce7 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -16,13 +16,47 @@ # class Channel::Instagram < ApplicationRecord include Channelable - + include Reauthorizable self.table_name = 'channel_instagram' validates :access_token, presence: true validates :instagram_id, uniqueness: true, presence: true + after_create_commit :subscribe + before_destroy :unsubscribe + def name 'Instagram' end + + def subscribe + # ref https://developers.facebook.com/docs/instagram-platform/webhooks#enable-subscriptions + HTTParty.post( + "https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps", + query: { + subscribed_fields: %w[messages message_reactions messaging_seen], + access_token: access_token + } + ) + rescue StandardError => e + Rails.logger.debug { "Rescued: #{e.inspect}" } + true + end + + def unsubscribe + HTTParty.delete( + "https://graph.instagram.com/v22.0/#{instagram_id}/subscribed_apps", + query: { + access_token: access_token + } + ) + true + rescue StandardError => e + Rails.logger.debug { "Rescued: #{e.inspect}" } + true + end + + def access_token + Instagram::RefreshOauthTokenService.new(channel: self).access_token + end end diff --git a/app/services/instagram/refresh_oauth_token_service.rb b/app/services/instagram/refresh_oauth_token_service.rb new file mode 100644 index 000000000..087fbcfa2 --- /dev/null +++ b/app/services/instagram/refresh_oauth_token_service.rb @@ -0,0 +1,84 @@ +# Service to handle Instagram access token refresh logic +# Instagram tokens are valid for 60 days and can be refreshed to extend validity +# This service implements the refresh logic per official Instagram API guidelines +class Instagram::RefreshOauthTokenService + attr_reader :channel + + def initialize(channel:) + @channel = channel + end + + # Returns a valid access token, refreshing it if necessary and eligible + def access_token + return unless token_valid? + + # If token is valid and eligible for refresh, attempt to refresh it + return channel[:access_token] unless token_eligible_for_refresh? + + attempt_token_refresh + end + + private + + # Checks if the current token is still valid (not expired) + def token_valid? + return false if channel.expires_at.blank? + + # Check if token is still valid + Time.current < channel.expires_at + end + + # Determines if a token is eligible for refresh based on Instagram's requirements + # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#refresh-a-long-lived-token + + def token_eligible_for_refresh? + # Three conditions must be met: + # 1. Token is still valid + token_is_valid = Time.current < channel.expires_at + + # 2. Token is at least 24 hours old (based on updated_at) + token_is_old_enough = channel.updated_at.present? && channel.updated_at < 24.hours.ago + + # 3. Token is approaching expiry (within 10 days) + approaching_expiry = channel.expires_at < 10.days.from_now + + token_is_valid && token_is_old_enough && approaching_expiry + end + + # Makes an API request to refresh the long-lived token + # @return [Hash] Response data containing new access_token and expires_in values + # @raise [RuntimeError] If API request fails + def refresh_long_lived_token + endpoint = 'https://graph.instagram.com/refresh_access_token' + params = { + grant_type: 'ig_refresh_token', + access_token: channel[:access_token] + } + + response = HTTParty.get(endpoint, query: params, headers: { 'Accept' => 'application/json' }) + + unless response.success? + Rails.logger.error "Failed to refresh Instagram token: #{response.body}" + raise "Failed to refresh Instagram token: #{response.body}" + end + + JSON.parse(response.body) + end + + def update_channel_tokens(token_data) + channel.update!( + access_token: token_data['access_token'], + expires_at: Time.current + token_data['expires_in'].seconds + ) + end + + # Attempts to refresh the token, returning either the new or existing token + def attempt_token_refresh + refreshed_token_data = refresh_long_lived_token + update_channel_tokens(refreshed_token_data) + channel.reload[:access_token] + rescue StandardError => e + Rails.logger.error("Token refresh failed: #{e.message}") + channel[:access_token] + end +end diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb index 37f6a77ff..fb10c1035 100644 --- a/app/views/super_admin/application/_icons.html.erb +++ b/app/views/super_admin/application/_icons.html.erb @@ -151,8 +151,12 @@ - + + + + + - + \ No newline at end of file diff --git a/config/features.yml b/config/features.yml index 70d6c9fcf..59b9aa2ad 100644 --- a/config/features.yml +++ b/config/features.yml @@ -161,3 +161,7 @@ - name: search_with_gin display_name: Search messages with GIN enabled: false +- name: channel_instagram + display_name: Instagram Channel + enabled: false + chatwoot_internal: true diff --git a/config/installation_config.yml b/config/installation_config.yml index 15b815496..1a891c420 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -292,3 +292,30 @@ locked: false type: secret # ------- End of Shopify Related Config ------- # + +# ------- Instagram Channel Related Config ------- # +- name: INSTAGRAM_APP_ID + display_title: 'Instagram App ID' + locked: false +- name: INSTAGRAM_APP_SECRET + display_title: 'Instagram App Secret' + description: 'The App Secret used for Instagram authentication' + locked: false + type: secret +- name: INSTAGRAM_VERIFY_TOKEN + display_title: 'Instagram Verify Token' + description: 'The verify token used for Instagram Webhook' + locked: false + type: secret +- name: ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT + display_title: 'Enable human agent for instagram channel' + value: false + locked: false + description: 'Enable human agent for instagram channel for longer message back period. Needs additional app approval: https://developers.facebook.com/docs/features-reference/human-agent/' + type: boolean +- name: INSTAGRAM_API_VERSION + display_title: 'Instagram API Version' + description: 'Configure this if you want to use a different Instagram API version. Make sure its prefixed with `v`' + value: 'v22.0' + locked: true +# ------- End of Instagram Channel Related Config ------- # diff --git a/config/routes.rb b/config/routes.rb index 87344924d..375d60b0c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,8 +18,10 @@ Rails.application.routes.draw do get '/app/*params', to: 'dashboard#index' get '/app/accounts/:account_id/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox' get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox' + get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents' + get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' resource :widget, only: [:show] @@ -214,6 +216,10 @@ Rails.application.routes.draw do resource :authorization, only: [:create] end + namespace :instagram do + resource :authorization, only: [:create] + end + resources :webhooks, only: [:index, :create, :update, :destroy] namespace :integrations do resources :apps, only: [:index, :show] @@ -475,7 +481,7 @@ Rails.application.routes.draw do get 'microsoft/callback', to: 'microsoft/callbacks#show' get 'google/callback', to: 'google/callbacks#show' - + get 'instagram/callback', to: 'instagram/callbacks#show' # ---------------------------------------------------------------------- # Routes for external service verifications get '.well-known/assetlinks.json' => 'android_app#assetlinks' diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml index a54aaf6eb..26a47f0e9 100644 --- a/enterprise/app/helpers/super_admin/features.yml +++ b/enterprise/app/helpers/super_admin/features.yml @@ -91,3 +91,9 @@ shopify: enabled: true icon: 'icon-shopify' config_key: 'shopify' +instagram: + name: 'Instagram' + description: 'Configuration for setting up Instagram' + enabled: true + icon: 'icon-instagram' + config_key: 'instagram' diff --git a/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb new file mode 100644 index 000000000..003ecc023 --- /dev/null +++ b/spec/controllers/api/v1/accounts/instagram/authorizations_controller_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe 'Instagram Authorization API', type: :request do + let(:account) { create(:account) } + + describe 'POST /api/v1/accounts/{account.id}/instagram/authorization' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/instagram/authorization" + + 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) } + let(:administrator) { create(:user, account: account, role: :administrator) } + + it 'returns unauthorized for agent' do + post "/api/v1/accounts/#{account.id}/instagram/authorization", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'creates a new authorization and returns the redirect url' do + post "/api/v1/accounts/#{account.id}/instagram/authorization", + headers: administrator.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body['success']).to be true + + instagram_service = Class.new do + extend InstagramConcern + extend Instagram::IntegrationHelper + end + frontend_url = ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + response_url = instagram_service.instagram_client.auth_code.authorize_url( + { + redirect_uri: "#{frontend_url}/instagram/callback", + scope: Instagram::IntegrationHelper::REQUIRED_SCOPES.join(','), + enable_fb_login: '0', + force_authentication: '1', + response_type: 'code', + state: instagram_service.generate_instagram_token(account.id) + } + ) + expect(response.parsed_body['url']).to eq response_url + end + end + end +end diff --git a/spec/controllers/concerns/instagram_concern_spec.rb b/spec/controllers/concerns/instagram_concern_spec.rb new file mode 100644 index 000000000..6ab03a272 --- /dev/null +++ b/spec/controllers/concerns/instagram_concern_spec.rb @@ -0,0 +1,138 @@ +require 'rails_helper' + +RSpec.describe InstagramConcern do + let(:dummy_class) { Class.new { include InstagramConcern } } + let(:dummy_instance) { dummy_class.new } + let(:client_id) { 'test_client_id' } + let(:client_secret) { 'test_client_secret' } + let(:short_lived_token) { 'short_lived_token' } + let(:long_lived_token) { 'long_lived_token' } + let(:access_token) { 'access_token' } + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_ID', nil).and_return(client_id) + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + allow(Rails.logger).to receive(:error) + end + + describe '#instagram_client' do + it 'creates an OAuth2 client with correct configuration', :aggregate_failures do + client = dummy_instance.instagram_client + + expect(client).to be_a(OAuth2::Client) + expect(client.id).to eq(client_id) + expect(client.secret).to eq(client_secret) + expect(client.site).to eq('https://api.instagram.com') + expect(client.options[:authorize_url]).to eq('https://api.instagram.com/oauth/authorize') + expect(client.options[:token_url]).to eq('https://api.instagram.com/oauth/access_token') + expect(client.options[:auth_scheme]).to eq(:request_body) + expect(client.options[:token_method]).to eq(:post) + end + end + + describe '#exchange_for_long_lived_token' do + let(:response_body) { { 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + allow(mock_response).to receive(:inspect).and_return(response_body) + end + + it 'exchanges short lived token for long lived token' do + result = dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) + + expect(HTTParty).to have_received(:get).with( + 'https://graph.instagram.com/access_token', + { + query: { + grant_type: 'ig_exchange_token', + client_secret: client_secret, + access_token: short_lived_token, + client_id: client_id + }, + headers: { 'Accept' => 'application/json' } + } + ) + + expect(result).to eq({ 'access_token' => long_lived_token, 'expires_in' => 5_184_000 }) + end + + context 'when the request fails' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) } + + it 'raises an error' do + expect do + dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) + end.to raise_error(RuntimeError, 'Failed to exchange token: Error') + end + end + + context 'when the response is not valid JSON' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) } + + it 'raises a JSON parse error' do + allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON')) + + expect { dummy_instance.send(:exchange_for_long_lived_token, short_lived_token) }.to raise_error(JSON::ParserError) + end + end + end + + describe '#fetch_instagram_user_details' do + let(:user_details) do + { + 'id' => '12345', + 'username' => 'test_user', + 'user_id' => '12345', + 'name' => 'Test User', + 'profile_picture_url' => 'https://example.com/profile.jpg', + 'account_type' => 'BUSINESS' + } + end + let(:response_body) { user_details.to_json } + let(:mock_response) { instance_double(HTTParty::Response, body: response_body, success?: true) } + + before do + allow(HTTParty).to receive(:get).and_return(mock_response) + allow(mock_response).to receive(:inspect).and_return(response_body) + end + + it 'fetches Instagram user details' do + result = dummy_instance.send(:fetch_instagram_user_details, access_token) + + expect(HTTParty).to have_received(:get).with( + 'https://graph.instagram.com/v22.0/me', + { + query: { + fields: 'id,username,user_id,name,profile_picture_url,account_type', + access_token: access_token + }, + headers: { 'Accept' => 'application/json' } + } + ) + + expect(result).to eq(user_details) + end + + context 'when the request fails' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Error', success?: false, code: 400) } + + it 'raises an error' do + expect do + dummy_instance.send(:fetch_instagram_user_details, access_token) + end.to raise_error(RuntimeError, 'Failed to fetch Instagram user details: Error') + end + end + + context 'when the response is not valid JSON' do + let(:mock_response) { instance_double(HTTParty::Response, body: 'Not JSON', success?: true) } + + it 'raises a JSON parse error' do + allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('Invalid JSON')) + + expect { dummy_instance.send(:fetch_instagram_user_details, access_token) }.to raise_error(JSON::ParserError) + end + end + end +end diff --git a/spec/controllers/instagram/callbacks_controller_spec.rb b/spec/controllers/instagram/callbacks_controller_spec.rb new file mode 100644 index 000000000..64b90491a --- /dev/null +++ b/spec/controllers/instagram/callbacks_controller_spec.rb @@ -0,0 +1,113 @@ +require 'rails_helper' + +RSpec.describe Instagram::CallbacksController do + let(:account) { create(:account) } + let(:valid_params) { { code: 'valid_code', state: "#{account.id}|valid_token" } } + let(:error_params) { { error: 'access_denied', error_description: 'User denied access', state: "#{account.id}|valid_token" } } + let(:oauth_client) { instance_double(OAuth2::Client) } + let(:auth_code_object) { instance_double(OAuth2::Strategy::AuthCode) } + let(:access_token) { instance_double(OAuth2::AccessToken, token: 'test_token') } + let(:long_lived_token_response) { { 'access_token' => 'long_lived_test_token', 'expires_in' => 5_184_000 } } + let(:user_details) { { 'username' => 'test_user', 'user_id' => '12345' } } + let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } + + before do + allow(controller).to receive(:verify_instagram_token).and_return(account.id) + allow(controller).to receive(:instagram_client).and_return(oauth_client) + allow(controller).to receive(:base_url).and_return('https://app.chatwoot.com') + allow(controller).to receive(:account).and_return(account) + allow(oauth_client).to receive(:auth_code).and_return(auth_code_object) + allow(controller).to receive(:exchange_for_long_lived_token).and_return(long_lived_token_response) + allow(controller).to receive(:fetch_instagram_user_details).and_return(user_details) + allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + + # Stub the exact request format that's being made + stub_request(:post, 'https://graph.instagram.com/v22.0/12345/subscribed_apps?access_token=long_lived_test_token&subscribed_fields%5B%5D=messages&subscribed_fields%5B%5D=message_reactions&subscribed_fields%5B%5D=messaging_seen') + .with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return(status: 200, body: '', headers: {}) + end + + describe '#show' do + context 'when authorization is successful' do + before do + allow(auth_code_object).to receive(:get_token).and_return(access_token) + end + + it 'creates instagram channel and inbox' do + expect do + get :show, params: valid_params + end.to change(Channel::Instagram, :count).by(1).and change(Inbox, :count).by(1) + + expect(Channel::Instagram.last.access_token).to eq('long_lived_test_token') + expect(Channel::Instagram.last.instagram_id).to eq('12345') + expect(Inbox.last.name).to eq('test_user') + + expect(response).to redirect_to(app_instagram_inbox_agents_url(account_id: account.id, inbox_id: Inbox.last.id)) + end + end + + context 'when user denies authorization' do + it 'redirects to error page with authorization error details' do + get :show, params: error_params + + expect(response).to redirect_to( + app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'access_denied', + code: 400, + error_message: 'User denied access' + ) + ) + end + end + + context 'when an OAuth error occurs' do + before do + oauth_error = OAuth2::Error.new( + OpenStruct.new( + body: { error_type: 'OAuthException', code: 400, error_message: 'Invalid OAuth code' }.to_json, + status: 400 + ) + ) + allow(auth_code_object).to receive(:get_token).and_raise(oauth_error) + end + + it 'handles OAuth errors and redirects to error page' do + get :show, params: valid_params + + expected_url = app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'OAuthException', + code: 400, + error_message: 'Invalid OAuth code' + ) + expect(response).to redirect_to(expected_url) + end + end + + context 'when a standard error occurs' do + before do + allow(auth_code_object).to receive(:get_token).and_raise(StandardError.new('Unknown error')) + end + + it 'handles standard errors and redirects to error page' do + get :show, params: valid_params + + expected_url = app_new_instagram_inbox_url( + account_id: account.id, + error_type: 'StandardError', + code: 500, + error_message: 'Unknown error' + ) + expect(response).to redirect_to(expected_url) + end + end + end +end diff --git a/spec/factories/channel/channel_instagram.rb b/spec/factories/channel/channel_instagram.rb index 9a0d33bb5..6665e4558 100644 --- a/spec/factories/channel/channel_instagram.rb +++ b/spec/factories/channel/channel_instagram.rb @@ -6,6 +6,21 @@ FactoryBot.define do expires_at { 60.days.from_now } updated_at { 25.hours.ago } + before :create do |channel| + WebMock::API.stub_request(:post, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps") + .with(query: { + access_token: channel.access_token, + subscribed_fields: %w[messages message_reactions messaging_seen] + }) + .to_return(status: 200, body: '', headers: {}) + + WebMock::API.stub_request(:delete, "https://graph.instagram.com/v22.0/#{channel.instagram_id}/subscribed_apps") + .with(query: { + access_token: channel.access_token + }) + .to_return(status: 200, body: '', headers: {}) + end + after(:create) do |channel| create(:inbox, channel: channel, account: channel.account) end diff --git a/spec/helpers/instagram/integration_helper_spec.rb b/spec/helpers/instagram/integration_helper_spec.rb new file mode 100644 index 000000000..7a8bb30a4 --- /dev/null +++ b/spec/helpers/instagram/integration_helper_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +RSpec.describe Instagram::IntegrationHelper do + include described_class + + describe '#generate_instagram_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:current_time) { Time.current } + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + allow(Time).to receive(:current).and_return(current_time) + end + + it 'generates a valid JWT token with correct payload' do + token = generate_instagram_token(account_id) + decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first + + expect(decoded_token['sub']).to eq(account_id) + expect(decoded_token['iat']).to eq(current_time.to_i) + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(generate_instagram_token(account_id)).to be_nil + end + end + + context 'when an error occurs' do + before do + allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error')) + end + + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with('Failed to generate Instagram token: Test error') + expect(generate_instagram_token(account_id)).to be_nil + end + end + end + + describe '#token_payload' do + let(:account_id) { 1 } + let(:current_time) { Time.current } + + before do + allow(Time).to receive(:current).and_return(current_time) + end + + it 'returns a hash with the correct structure' do + payload = token_payload(account_id) + + expect(payload).to be_a(Hash) + expect(payload[:sub]).to eq(account_id) + expect(payload[:iat]).to eq(current_time.to_i) + end + end + + describe '#verify_instagram_token' do + let(:account_id) { 1 } + let(:client_secret) { 'test_secret' } + let(:valid_token) do + JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256') + end + + before do + allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret) + end + + it 'successfully verifies and returns account_id from valid token' do + expect(verify_instagram_token(valid_token)).to eq(account_id) + end + + context 'when token is blank' do + it 'returns nil' do + expect(verify_instagram_token('')).to be_nil + expect(verify_instagram_token(nil)).to be_nil + end + end + + context 'when client secret is not configured' do + let(:client_secret) { nil } + + it 'returns nil' do + expect(verify_instagram_token(valid_token)).to be_nil + end + end + + context 'when token is invalid' do + it 'logs the error and returns nil' do + expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Instagram token:/) + expect(verify_instagram_token('invalid_token')).to be_nil + end + end + end +end diff --git a/spec/services/instagram/refresh_oauth_token_service_spec.rb b/spec/services/instagram/refresh_oauth_token_service_spec.rb new file mode 100644 index 000000000..007159a88 --- /dev/null +++ b/spec/services/instagram/refresh_oauth_token_service_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +RSpec.describe Instagram::RefreshOauthTokenService do + let(:account) { create(:account) } + let(:refresh_response) do + { + 'access_token' => 'new_refreshed_token', + 'expires_in' => 5_184_000 # 60 days in seconds + } + end + let(:fixed_token) { 'c061d0c51973a8fcab2ecec86f6aa41718414a10070967a5e9a58f49bf8a798e' } + let(:instagram_channel) do + create(:channel_instagram, + account: account, + access_token: fixed_token, + expires_at: 20.days.from_now) # Set default expiry + end + let(:service) { described_class.new(channel: instagram_channel) } + + before do + stub_request(:get, 'https://graph.instagram.com/refresh_access_token') + .with( + query: { + 'access_token' => fixed_token, + 'grant_type' => 'ig_refresh_token' + }, + headers: { + 'Accept' => 'application/json', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return(status: 200, body: refresh_response.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + describe '#access_token' do + context 'when token is valid and not eligible for refresh' do + before do + instagram_channel.update!( + updated_at: 12.hours.ago # Less than 24 hours old + ) + end + + it 'returns existing token without refresh' do + expect(service).not_to receive(:refresh_long_lived_token) + expect(service.access_token).to eq(fixed_token) + end + end + + context 'when token is eligible for refresh' do + before do + instagram_channel.update!( + expires_at: 5.days.from_now, # Within 10 days window + updated_at: 25.hours.ago # More than 24 hours old + ) + end + + it 'refreshes the token and updates channel' do + expect(service.access_token).to eq('new_refreshed_token') + instagram_channel.reload + expect(instagram_channel.access_token).to eq('new_refreshed_token') + expect(instagram_channel.expires_at).to be_within(1.second).of(5_184_000.seconds.from_now) + end + end + end + + describe 'private methods' do + describe '#token_valid?' do + # For the expires_at null test, we need to modify the validation or use a different approach + context 'when expires_at is blank' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(nil) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is expired' do + it 'returns false' do + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + expect(service.send(:token_valid?)).to be false + end + end + + context 'when token is valid' do + it 'returns true' do + allow(instagram_channel).to receive(:expires_at).and_return(1.day.from_now) + expect(service.send(:token_valid?)).to be true + end + end + end + + describe '#token_eligible_for_refresh?' do + context 'when token is too new' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(12.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(5.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is not approaching expiry' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(20.days.from_now) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + + context 'when token is expired' do + before do + allow(instagram_channel).to receive(:updated_at).and_return(25.hours.ago) + allow(instagram_channel).to receive(:expires_at).and_return(1.hour.ago) + end + + it 'returns false' do + expect(service.send(:token_eligible_for_refresh?)).to be false + end + end + end + end +end
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }} +