fix(whatsapp): trigger recovery for invalid tokens

This commit is contained in:
Muhsin
2026-07-23 20:04:20 +04:00
parent c2498503b8
commit 357b05065d
13 changed files with 140 additions and 25 deletions
@@ -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,
@@ -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() {
@@ -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)
+2 -1
View File
@@ -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
+1
View File
@@ -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']}")
@@ -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