fix(whatsapp): trigger recovery for invalid tokens
This commit is contained in:
@@ -390,11 +390,6 @@ export default {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
(!this.isOnChatwootCloud ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
)) &&
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
@@ -416,6 +411,7 @@ export default {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
!this.whatsappUnauthorized &&
|
||||
this.healthError?.type !== 'authorization' &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
|
||||
+5
-7
@@ -56,7 +56,6 @@ export default {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
@@ -64,12 +63,11 @@ export default {
|
||||
showWhatsAppReconfigure() {
|
||||
return (
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
this.isOnChatwootCloud
|
||||
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
)
|
||||
(this.inbox.reauthorization_required ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
))
|
||||
);
|
||||
},
|
||||
isForwardingEnabled() {
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import ConfigurationPage from '../ConfigurationPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const showWhatsAppReconfigure = options =>
|
||||
ConfigurationPage.computed.showWhatsAppReconfigure.call({
|
||||
inbox: {
|
||||
provider_config: { source: 'embedded_signup' },
|
||||
reauthorization_required: false,
|
||||
},
|
||||
isEmbeddedSignupWhatsApp: true,
|
||||
accountId: 1,
|
||||
isFeatureEnabledonAccount: () => false,
|
||||
...options,
|
||||
});
|
||||
|
||||
describe('ConfigurationPage', () => {
|
||||
it('shows reconfiguration when an embedded signup inbox requires reauthorization', () => {
|
||||
expect(
|
||||
showWhatsAppReconfigure({
|
||||
inbox: {
|
||||
provider_config: { source: 'embedded_signup' },
|
||||
reauthorization_required: true,
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps reconfiguration feature-gated for a healthy inbox', () => {
|
||||
const isFeatureEnabledonAccount = vi.fn(() => false);
|
||||
|
||||
expect(showWhatsAppReconfigure({ isFeatureEnabledonAccount })).toBe(false);
|
||||
expect(isFeatureEnabledonAccount).toHaveBeenCalledWith(
|
||||
1,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,9 @@ class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNoti
|
||||
end
|
||||
|
||||
def whatsapp_disconnect(inbox)
|
||||
subject = 'Your Whatsapp connection has expired'
|
||||
send_notification(subject, action_url: inbox_url(inbox))
|
||||
subject = 'Your WhatsApp connection needs to be refreshed'
|
||||
meta = { 'embedded_signup' => inbox.channel.provider_config['source'] == 'embedded_signup' }
|
||||
send_notification(subject, action_url: "#{inbox_url(inbox)}/configuration", meta: meta)
|
||||
end
|
||||
|
||||
def email_disconnect(inbox)
|
||||
|
||||
@@ -40,11 +40,12 @@ module Reauthorizable
|
||||
state_changed = !reauthorization_required?
|
||||
|
||||
::Redis::Alfred.set(reauthorization_required_key, true)
|
||||
return unless state_changed
|
||||
|
||||
reauthorization_handlers[self.class.name]&.call(self)
|
||||
|
||||
invalidate_inbox_cache unless instance_of?(::AutomationRule)
|
||||
dispatch_inbox_reauthorization_event(true) if state_changed
|
||||
dispatch_inbox_reauthorization_event(true)
|
||||
end
|
||||
|
||||
def process_integration_hook_reauthorization_emails
|
||||
|
||||
@@ -129,6 +129,7 @@ class Whatsapp::HealthService
|
||||
"[WHATSAPP HEALTH] WhatsApp API request failed: http_status=#{error.http_status} " \
|
||||
"code=#{error.code} subcode=#{error.subcode} message=#{error.message}"
|
||||
)
|
||||
@channel.authorization_error! if error.authorization_error?
|
||||
raise error
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
######################################
|
||||
|
||||
class Whatsapp::Providers::BaseService
|
||||
META_AUTHORIZATION_ERROR_CODE = 190
|
||||
|
||||
pattr_initialize [:whatsapp_channel!]
|
||||
|
||||
def send_message(_phone_number, _message)
|
||||
@@ -103,4 +105,11 @@ class Whatsapp::Providers::BaseService
|
||||
json_hash = { :button => I18n.t('conversations.messages.whatsapp.list_button_label'), 'sections' => sections }
|
||||
create_payload('list', message.outgoing_content, JSON.generate(json_hash))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def meta_authorization_error?(response)
|
||||
parsed_response = response.parsed_response
|
||||
parsed_response.is_a?(Hash) && parsed_response.dig('error', 'code').to_i == META_AUTHORIZATION_ERROR_CODE
|
||||
end
|
||||
end
|
||||
|
||||
@@ -41,22 +41,19 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
def fetch_whatsapp_templates(url)
|
||||
response = HTTParty.get(url)
|
||||
unless response.success?
|
||||
whatsapp_channel.authorization_error! if meta_authorization_error?(response)
|
||||
Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \
|
||||
"inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}"
|
||||
return []
|
||||
end
|
||||
|
||||
next_url = next_url(response)
|
||||
next_url = response['paging']&.[]('next')
|
||||
|
||||
return response['data'] + fetch_whatsapp_templates(next_url) if next_url.present?
|
||||
|
||||
response['data']
|
||||
end
|
||||
|
||||
def next_url(response)
|
||||
response['paging'] ? response['paging']['next'] : ''
|
||||
end
|
||||
|
||||
def validate_provider_config?
|
||||
config = whatsapp_channel.provider_config
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?access_token=#{config['api_key']}")
|
||||
|
||||
+7
-3
@@ -1,8 +1,12 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your Whatsapp Access has expired. </p>
|
||||
<p>Please reconnect Whatsapp to continue receiving messages.</p>
|
||||
<p>Meta can no longer validate the access token for your WhatsApp inbox.</p>
|
||||
{% if meta.embedded_signup %}
|
||||
<p>Please reconfigure the inbox to refresh the connection and continue receiving messages.</p>
|
||||
{% else %}
|
||||
<p>Please update the inbox with a valid access token to continue receiving messages.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Click <a href="{{action_url}}">here</a> to re-connect.
|
||||
Open the inbox <a href="{{action_url}}">configuration page</a> to restore the connection.
|
||||
</p>
|
||||
|
||||
@@ -33,17 +33,54 @@ RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
|
||||
end
|
||||
|
||||
describe 'whatsapp_disconnect' do
|
||||
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
|
||||
let(:source) { 'embedded_signup' }
|
||||
let!(:whatsapp_channel) do
|
||||
create(
|
||||
:channel_whatsapp,
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: {
|
||||
'api_key' => 'synthetic_access_token',
|
||||
'business_account_id' => 'synthetic_business_account_id',
|
||||
'phone_number_id' => 'synthetic_phone_number_id',
|
||||
'source' => 'embedded_signup'
|
||||
},
|
||||
sync_templates: false,
|
||||
validate_provider_config: false
|
||||
)
|
||||
end
|
||||
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
|
||||
let(:mail) { described_class.with(account: account).whatsapp_disconnect(whatsapp_inbox).deliver_now }
|
||||
|
||||
before do
|
||||
allow(whatsapp_inbox.channel).to receive(:provider_config)
|
||||
.and_return(whatsapp_channel.provider_config.merge('source' => source))
|
||||
end
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Your Whatsapp connection has expired')
|
||||
expect(mail.subject).to eq('Your WhatsApp connection needs to be refreshed')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
|
||||
end
|
||||
|
||||
it 'links directly to the inbox configuration page' do
|
||||
expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/inboxes/#{whatsapp_inbox.id}/configuration"
|
||||
|
||||
expect(mail.body.decoded).to include(expected_url)
|
||||
end
|
||||
|
||||
it 'asks embedded signup inboxes to reconfigure' do
|
||||
expect(mail.body.decoded).to include('Please reconfigure the inbox')
|
||||
end
|
||||
|
||||
context 'when the inbox uses manual setup' do
|
||||
let(:source) { nil }
|
||||
|
||||
it 'asks the administrator to update the access token' do
|
||||
expect(mail.body.decoded).to include('Please update the inbox with a valid access token')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'instagram_disconnect' do
|
||||
|
||||
@@ -85,6 +85,19 @@ shared_examples_for 'reauthorizable' do
|
||||
expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account)
|
||||
end
|
||||
end
|
||||
|
||||
it 'notifies only once while reauthorization remains required' do
|
||||
obj.prompt_reauthorization!
|
||||
obj.prompt_reauthorization!
|
||||
|
||||
if model.to_s == 'AutomationRule'
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to have_received(:with).once
|
||||
elsif model.to_s == 'Integrations::Hook'
|
||||
expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).once
|
||||
else
|
||||
expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'reauthorized!' do
|
||||
|
||||
@@ -154,6 +154,7 @@ RSpec.describe Whatsapp::HealthService do
|
||||
expect(error.subcode).to eq(464)
|
||||
expect(error).to be_authorization_error
|
||||
end
|
||||
expect(channel.authorization_error_count).to eq(1)
|
||||
end
|
||||
|
||||
it 'preserves the successful snapshot and records the failed attempt' do
|
||||
|
||||
@@ -262,6 +262,26 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
subject.sync_templates
|
||||
expect(whatsapp_channel.reload.message_templates_last_updated).not_to eq(timstamp)
|
||||
end
|
||||
|
||||
it 'records an authorization error when Meta rejects the access token' do
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0/123456789/message_templates?access_token=test_key')
|
||||
.to_return(
|
||||
status: 400,
|
||||
headers: response_headers,
|
||||
body: {
|
||||
error: {
|
||||
message: 'The access token cannot authorize this request.',
|
||||
type: 'OAuthException',
|
||||
code: 190,
|
||||
error_subcode: 464
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
|
||||
subject.sync_templates
|
||||
|
||||
expect(whatsapp_channel.authorization_error_count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user