mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
feat: Added the ability to create Instagram channel (#11182)
This PR is part of https://github.com/chatwoot/chatwoot/pull/11054 to make the review cycle easier.
This commit is contained in:
@@ -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
|
||||
75
app/controllers/concerns/instagram_concern.rb
Normal file
75
app/controllers/concerns/instagram_concern.rb
Normal file
@@ -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
|
||||
123
app/controllers/instagram/callbacks_controller.rb
Normal file
123
app/controllers/instagram/callbacks_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
49
app/helpers/instagram/integration_helper.rb
Normal file
49
app/helpers/instagram/integration_helper.rb
Normal file
@@ -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
|
||||
14
app/javascript/dashboard/api/channel/instagramClient.js
Normal file
14
app/javascript/dashboard/api/channel/instagramClient.js
Normal file
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||
|
||||
export default {
|
||||
mixins: [globalConfigMixin],
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
return {
|
||||
accountId,
|
||||
v$: useVuelidate(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isCreating: false,
|
||||
hasError: false,
|
||||
errorStateMessage: '',
|
||||
errorStateDescription: '',
|
||||
isRequestingAuthorization: false,
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
// TODO: Handle error type
|
||||
// const errorType = urlParams.get('error_type');
|
||||
const errorCode = urlParams.get('code');
|
||||
const errorMessage = urlParams.get('error_message');
|
||||
|
||||
if (errorMessage) {
|
||||
this.hasError = true;
|
||||
if (errorCode === '400') {
|
||||
this.errorStateMessage = errorMessage;
|
||||
this.errorStateDescription = this.$t(
|
||||
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'
|
||||
);
|
||||
} else {
|
||||
this.errorStateMessage = this.$t(
|
||||
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE'
|
||||
);
|
||||
this.errorStateDescription = errorMessage;
|
||||
}
|
||||
}
|
||||
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
|
||||
const cleanURL = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, cleanURL);
|
||||
},
|
||||
|
||||
methods: {
|
||||
async requestAuthorization() {
|
||||
this.isRequestingAuthorization = true;
|
||||
const response = await instagramClient.generateAuthorization();
|
||||
const {
|
||||
data: { url },
|
||||
} = response;
|
||||
|
||||
window.location.href = url;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center h-full text-center">
|
||||
<div v-if="hasError" class="max-w-lg mx-auto text-center">
|
||||
<h5>{{ errorStateMessage }}</h5>
|
||||
<p
|
||||
v-if="errorStateDescription"
|
||||
v-dompurify-html="errorStateDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center h-full text-center"
|
||||
>
|
||||
<button
|
||||
class="flex items-center justify-center px-8 py-3.5 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
|
||||
:disabled="isRequestingAuthorization"
|
||||
@click="requestAuthorization()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-base font-medium">
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
|
||||
</span>
|
||||
<span v-if="isRequestingAuthorization" class="ml-2">
|
||||
<svg
|
||||
class="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<p class="py-6">
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
|
||||
84
app/services/instagram/refresh_oauth_token_service.rb
Normal file
84
app/services/instagram/refresh_oauth_token_service.rb
Normal file
@@ -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
|
||||
@@ -152,6 +152,10 @@
|
||||
<path d="M0.294 14.765c-0.053-0.228 0.218-0.371 0.383-0.206l8.762 8.762c0.165 0.165 0.022 0.436-0.206 0.383C4.812 22.668 1.332 19.188 0.294 14.765zM0 11.253c-0.004 0.068 0.021 0.134 0.07 0.183l12.494 12.494c0.048 0.048 0.115 0.074 0.183 0.07c0.568-0.035 1.127-0.11 1.671-0.222c0.183-0.038 0.247-0.263 0.115-0.396l-13.847-13.847c-0.132-0.132-0.358-0.068-0.396 0.115c-0.112 0.544-0.187 1.102-0.222 1.671zM1.011 7.129c-0.04 0.09-0.02 0.195 0.05 0.264l15.546 15.546c0.069 0.069 0.174 0.09 0.264 0.05c0.429-0.191 0.844-0.406 1.244-0.644c0.133-0.079 0.153-0.261 0.044-0.37l-16.134-16.134c-0.109-0.109-0.291-0.089-0.37 0.044c-0.238 0.4-0.453 0.816-0.644 1.244zM3.038 4.338c-0.089-0.089-0.094-0.231-0.011-0.325c2.2-2.46 5.4-4.013 8.973-4.013 6.627 0 12 5.373 12 12c0 3.562-1.55 6.76-4.013 8.961c-0.094 0.084-0.236 0.078-0.325-0.011l-16.624-16.612z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-instagram" viewBox="0 0 24 24">
|
||||
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
@@ -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
|
||||
|
||||
@@ -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 ------- #
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
138
spec/controllers/concerns/instagram_concern_spec.rb
Normal file
138
spec/controllers/concerns/instagram_concern_spec.rb
Normal file
@@ -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
|
||||
113
spec/controllers/instagram/callbacks_controller_spec.rb
Normal file
113
spec/controllers/instagram/callbacks_controller_spec.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
98
spec/helpers/instagram/integration_helper_spec.rb
Normal file
98
spec/helpers/instagram/integration_helper_spec.rb
Normal file
@@ -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
|
||||
127
spec/services/instagram/refresh_oauth_token_service_spec.rb
Normal file
127
spec/services/instagram/refresh_oauth_token_service_spec.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user