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:
Muhsin Keloth
2025-04-03 13:57:14 +05:30
committed by GitHub
parent 0dc2af3c78
commit 7a24672b66
23 changed files with 1150 additions and 6 deletions

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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();

View File

@@ -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"
}
}
}

View File

@@ -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({

View File

@@ -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>

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ------- #

View File

@@ -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'

View File

@@ -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'

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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