feat: Instagram reauthorization (#11221)

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 14:30:48 +05:30
committed by GitHub
parent 7a24672b66
commit 246deab684
17 changed files with 170 additions and 20 deletions

View File

@@ -26,8 +26,13 @@ class Instagram::CallbacksController < ApplicationController
)
@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)
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
# Handle all errors that might occur during authorization
@@ -82,12 +87,45 @@ class Instagram::CallbacksController < ApplicationController
)
end
def create_channel_with_inbox
def find_or_create_inbox
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
channel_exists = channel_instagram.present?
if channel_instagram
update_channel(channel_instagram, user_details)
else
channel_instagram = create_channel_with_inbox(user_details)
end
# reauthorize channel, this code path only triggers when instagram auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_instagram.reauthorized!
[channel_instagram.inbox, channel_exists]
end
def find_channel_by_instagram_id(instagram_id)
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
end
def update_channel(channel_instagram, user_details)
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
channel_instagram.update!(
access_token: @long_lived_token_response['access_token'],
expires_at: expires_at
)
# Update inbox name if username changed
channel_instagram.inbox.update!(name: user_details['username'])
channel_instagram
end
def create_channel_with_inbox(user_details)
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,
@@ -100,6 +138,8 @@ class Instagram::CallbacksController < ApplicationController
channel: channel_instagram,
name: user_details['username']
)
channel_instagram
end
end

View File

@@ -9,6 +9,7 @@ export const INBOX_TYPES = {
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
};
const INBOX_ICON_MAP_FILL = {
@@ -20,6 +21,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -33,6 +35,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -118,6 +121,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'brand-line';
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
default:
return 'chat';
}

View File

@@ -49,7 +49,7 @@
"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"
"ERROR_AUTH": "There was an error connecting to Instagram, 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' ",

View File

@@ -7,6 +7,7 @@ import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.
import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
@@ -36,6 +37,7 @@ export default {
MicrosoftReauthorize,
GoogleReauthorize,
NextButton,
InstagramReauthorize,
},
mixins: [inboxMixin],
setup() {
@@ -202,6 +204,9 @@ export default {
return true;
return false;
},
instagramUnauthorized() {
return this.isAInstagramChannel && this.inbox.reauthorization_required;
},
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
},
@@ -383,10 +388,11 @@ export default {
/>
</woot-tabs>
</SettingIntroBanner>
<section class="max-w-6xl mx-auto w-full">
<section class="w-full max-w-6xl mx-auto">
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"

View File

@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import instagramClient from 'dashboard/api/channel/instagramClient';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
</template>

View File

@@ -28,6 +28,7 @@ const i18nMap = {
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
};
const twilioChannelName = () => {

View File

@@ -121,6 +121,9 @@ export default {
this.isATwilioWhatsAppChannel
);
},
isAInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
},
methods: {
inboxHasFeature(feature) {

View File

@@ -4,6 +4,11 @@ class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNoti
send_notification(subject, action_url: inbox_url(inbox))
end
def instagram_disconnect(inbox)
subject = 'Your Instagram connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def whatsapp_disconnect(inbox)
subject = 'Your Whatsapp connection has expired'
send_notification(subject, action_url: inbox_url(inbox))

View File

@@ -19,6 +19,8 @@ class Channel::Instagram < ApplicationRecord
include Reauthorizable
self.table_name = 'channel_instagram'
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :access_token, presence: true
validates :instagram_id, uniqueness: true, presence: true

View File

@@ -39,18 +39,7 @@ module Reauthorizable
def prompt_reauthorization!
::Redis::Alfred.set(reauthorization_required_key, true)
case self.class.name
when 'Integrations::Hook'
process_integration_hook_reauthorization_emails
when 'Channel::FacebookPage'
send_channel_reauthorization_email(:facebook_disconnect)
when 'Channel::Whatsapp'
send_channel_reauthorization_email(:whatsapp_disconnect)
when 'Channel::Email'
send_channel_reauthorization_email(:email_disconnect)
when 'AutomationRule'
handle_automation_rule_reauthorization
end
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end
@@ -82,6 +71,17 @@ module Reauthorizable
private
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }
}
end
def invalidate_inbox_cache
inbox.update_account_cache if inbox.present?
end

View File

@@ -107,7 +107,11 @@ class Inbox < ApplicationRecord
end
def instagram?
facebook? && channel.instagram_id.present?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?

View File

@@ -54,6 +54,9 @@ if resource.facebook?
json.reauthorization_required resource.channel.try(:reauthorization_required?)
end
## Instagram Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
json.phone_number resource.channel.try(:phone_number)

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>Your Instagram Inbox Access has expired. </p>
<p>Please reconnect Instagram to continue receiving messages.</p>
<p>
Click <a href="{{action_url}}">here</a> to re-connect.
</p>

View File

@@ -22,6 +22,7 @@ Rails.application.routes.draw do
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_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
resource :widget, only: [:show]

View File

@@ -44,4 +44,18 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
expect(mail.to).to eq([administrator.email])
end
end
describe 'instagram_disconnect' do
let!(:instagram_channel) { create(:channel_instagram, account: account) }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account) }
let(:mail) { described_class.with(account: account).instagram_disconnect(instagram_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Instagram connection has expired')
end
it 'renders the receiver email' do
expect(mail.to).to eq([administrator.email])
end
end
end

View File

@@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Instagram do
let(:channel) { create(:channel_instagram) }
@@ -14,4 +15,21 @@ RSpec.describe Channel::Instagram do
it 'has a valid name' do
expect(channel.name).to eq('Instagram')
end
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for instagram' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:instagram_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
end

View File

@@ -48,10 +48,12 @@ shared_examples_for 'reauthorizable' do
facebook_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
whatsapp_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
email_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
instagram_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(channel_mailer)
allow(channel_mailer).to receive(:facebook_disconnect).and_return(facebook_mailer_response)
allow(channel_mailer).to receive(:whatsapp_disconnect).and_return(whatsapp_mailer_response)
allow(channel_mailer).to receive(:email_disconnect).and_return(email_mailer_response)
allow(channel_mailer).to receive(:instagram_disconnect).and_return(instagram_mailer_response)
end
describe 'prompt_reauthorization!' do