From 357b05065d30b90a5ab765d7e090a8e66cbe7808 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:53:13 +0400 Subject: [PATCH] fix(whatsapp): trigger recovery for invalid tokens --- .../dashboard/settings/inbox/Settings.vue | 6 +-- .../inbox/settingsPage/ConfigurationPage.vue | 12 +++--- .../specs/ConfigurationPage.spec.js | 37 +++++++++++++++++ .../channel_notifications_mailer.rb | 5 ++- app/models/concerns/reauthorizable.rb | 3 +- app/services/whatsapp/health_service.rb | 1 + .../whatsapp/providers/base_service.rb | 9 ++++ .../providers/whatsapp_cloud_service.rb | 7 +--- .../whatsapp_disconnect.liquid | 10 +++-- .../channel_notifications_mailer_spec.rb | 41 ++++++++++++++++++- spec/models/concerns/reauthorizable_shared.rb | 13 ++++++ spec/services/whatsapp/health_service_spec.rb | 1 + .../providers/whatsapp_cloud_service_spec.rb | 20 +++++++++ 13 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/specs/ConfigurationPage.spec.js diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 8c4b280ac..a58a0190e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -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, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 1e9e2f704..392c987c6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -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() { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/specs/ConfigurationPage.spec.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/specs/ConfigurationPage.spec.js new file mode 100644 index 000000000..0a465470e --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/specs/ConfigurationPage.spec.js @@ -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 + ); + }); +}); diff --git a/app/mailers/administrator_notifications/channel_notifications_mailer.rb b/app/mailers/administrator_notifications/channel_notifications_mailer.rb index 1957f421b..1790b1980 100644 --- a/app/mailers/administrator_notifications/channel_notifications_mailer.rb +++ b/app/mailers/administrator_notifications/channel_notifications_mailer.rb @@ -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) diff --git a/app/models/concerns/reauthorizable.rb b/app/models/concerns/reauthorizable.rb index acf7fd5e4..8ac4cc661 100644 --- a/app/models/concerns/reauthorizable.rb +++ b/app/models/concerns/reauthorizable.rb @@ -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 diff --git a/app/services/whatsapp/health_service.rb b/app/services/whatsapp/health_service.rb index 9aa554962..e1b15a1ba 100644 --- a/app/services/whatsapp/health_service.rb +++ b/app/services/whatsapp/health_service.rb @@ -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 diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb index 9fd1f6267..d7c34032b 100644 --- a/app/services/whatsapp/providers/base_service.rb +++ b/app/services/whatsapp/providers/base_service.rb @@ -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 diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index d65c6cc62..dc278de67 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -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']}") diff --git a/app/views/mailers/administrator_notifications/channel_notifications_mailer/whatsapp_disconnect.liquid b/app/views/mailers/administrator_notifications/channel_notifications_mailer/whatsapp_disconnect.liquid index ec13bbe01..eccec9e15 100644 --- a/app/views/mailers/administrator_notifications/channel_notifications_mailer/whatsapp_disconnect.liquid +++ b/app/views/mailers/administrator_notifications/channel_notifications_mailer/whatsapp_disconnect.liquid @@ -1,8 +1,12 @@
Hello,
-Your Whatsapp Access has expired.
-Please reconnect Whatsapp to continue receiving messages.
+Meta can no longer validate the access token for your WhatsApp inbox.
+{% if meta.embedded_signup %} +Please reconfigure the inbox to refresh the connection and continue receiving messages.
+{% else %} +Please update the inbox with a valid access token to continue receiving messages.
+{% endif %}-Click here to re-connect. +Open the inbox configuration page to restore the connection.
diff --git a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb index 39bc4ee1a..b9a04eac8 100644 --- a/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb +++ b/spec/mailers/administrator_notifications/channel_notifications_mailer_spec.rb @@ -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 diff --git a/spec/models/concerns/reauthorizable_shared.rb b/spec/models/concerns/reauthorizable_shared.rb index 558312e6c..8882acaa0 100644 --- a/spec/models/concerns/reauthorizable_shared.rb +++ b/spec/models/concerns/reauthorizable_shared.rb @@ -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 diff --git a/spec/services/whatsapp/health_service_spec.rb b/spec/services/whatsapp/health_service_spec.rb index f3c6d9858..ff11efa72 100644 --- a/spec/services/whatsapp/health_service_spec.rb +++ b/spec/services/whatsapp/health_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index a94ba2e44..269d50ad8 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -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