Merge branch 'feat/app-store-reviews' of github.com:chatwoot/chatwoot into feat/app-store-reviews

This commit is contained in:
Muhsin
2026-05-22 10:35:44 +04:00
11 changed files with 136 additions and 1 deletions
@@ -31,6 +31,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create
return render_app_store_feature_disabled if app_store_channel_requested? && !app_store_reviews_enabled?
ActiveRecord::Base.transaction do
channel = create_channel
@inbox = Current.account.inboxes.build(
@@ -100,6 +102,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
%w[web_widget api email line telegram whatsapp sms app_store]
end
def app_store_channel_requested?
permitted_params.dig(:channel, :type) == 'app_store'
end
def render_app_store_feature_disabled
render json: { message: 'App Store Reviews channel is not enabled for this account' }, status: :forbidden
end
def app_store_reviews_enabled?
GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true'
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
+1
View File
@@ -74,6 +74,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
ENABLE_APP_STORE_REVIEWS_CHANNEL: GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false'),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
@@ -27,6 +27,10 @@ const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isAppStoreReviewsEnabled = computed(() => {
return window.chatwootConfig?.enableAppStoreReviewsChannel === 'true';
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
@@ -52,6 +56,10 @@ const isActive = computed(() => {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'app_store') {
return isAppStoreReviewsEnabled.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -66,7 +74,6 @@ const isActive = computed(() => {
'line',
'instagram',
'tiktok',
'app_store',
'voice',
].includes(key);
});
@@ -2,6 +2,8 @@ class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
return unless app_store_reviews_enabled?
Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
next if inbox.account.suspended?
next unless inbox.channel.sync_due?
@@ -9,4 +11,10 @@ class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel)
end
end
private
def app_store_reviews_enabled?
GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true'
end
end
@@ -2,6 +2,8 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
queue_as :scheduled_jobs
def perform(channel)
return unless app_store_reviews_enabled?
channel.fetch_reviews.each do |review_payload|
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
rescue StandardError => e
@@ -12,4 +14,10 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
private
def app_store_reviews_enabled?
GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true'
end
end
@@ -6,6 +6,7 @@ class AppStore::SendOnAppStoreService < Base::SendOnChannelService
end
def perform_reply
validate_feature_enabled!
validate_message_support!
source_id = channel.reply_to_review(review_id, reply_content, response_id: existing_response_id)
message.update!(source_id: source_id) if source_id.present?
@@ -19,6 +20,12 @@ class AppStore::SendOnAppStoreService < Base::SendOnChannelService
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
end
def validate_feature_enabled!
return if GlobalConfigService.load('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').to_s == 'true'
raise 'App Store Reviews channel is not enabled for this account.'
end
def review_id
message.conversation.contact_inbox.source_id
end
+5
View File
@@ -277,6 +277,11 @@
display_title: 'Inactive WhatsApp Numbers'
description: 'Comma-separated list of WhatsApp numbers that should be rejected with a 422 error'
type: code
- name: ENABLE_APP_STORE_REVIEWS_CHANNEL
value: false
display_title: 'Enable App Store Reviews Channel'
description: 'Enable the App Store Reviews inbox channel'
type: boolean
# ------- End of Chatwoot Internal Config for Cloud ----#
# ------- Chatwoot Internal Config for Self Hosted ----#
@@ -434,6 +434,48 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response.body).to include('+123456789')
end
it 'does not create an app store inbox when the feature is disabled' do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false')
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'App Store Reviews',
channel: { type: 'app_store', app_id: '123456789', issuer_id: SecureRandom.uuid, key_id: 'KEY123',
private_key: OpenSSL::PKey::EC.generate('prime256v1').to_pem } },
as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['message']).to eq('App Store Reviews channel is not enabled for this account')
end
it 'creates an app store inbox when the feature is enabled' do
app_store_client = instance_double(AppStoreConnect::Client)
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true')
allow(AppStoreConnect::Client).to receive(:new).and_return(app_store_client)
allow(app_store_client).to receive(:fetch_app).and_return(
{
'attributes' => {
'name' => 'Chatwoot',
'bundleId' => 'com.chatwoot.app'
}
}
)
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'App Store Reviews',
channel: { type: 'app_store', app_id: '123456789', issuer_id: SecureRandom.uuid, key_id: 'KEY123',
private_key: OpenSSL::PKey::EC.generate('prime256v1').to_pem } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('App Store Reviews')
expect(Channel::AppStore.last.app_name).to eq('Chatwoot')
end
it 'creates the webwidget inbox that allow messages after conversation is resolved' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
@@ -9,6 +9,11 @@ RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true')
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
@@ -25,4 +30,13 @@ RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
described_class.perform_now
end
it 'skips channels when the feature is disabled for the account' do
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false')
due_channel
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later)
described_class.perform_now
end
end
@@ -7,6 +7,11 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do
let(:review_payload) { { 'review' => { 'id' => 'review-1' }, 'response' => nil } }
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true')
end
it 'enqueues the job' do
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
.with(channel)
@@ -36,4 +41,12 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do
expect(exception_tracker).to have_received(:capture_exception)
expect(channel.reload.last_synced_at).to be_present
end
it 'does not fetch reviews when the feature is disabled for the account' do
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false')
expect(channel).not_to receive(:fetch_reviews)
described_class.perform_now(channel)
end
end
@@ -12,6 +12,8 @@ RSpec.describe AppStore::SendOnAppStoreService do
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('true')
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
end
@@ -54,5 +56,19 @@ RSpec.describe AppStore::SendOnAppStoreService do
)
expect(exception_tracker).to have_received(:capture_exception)
end
it 'marks the message as failed when the feature is disabled' do
allow(GlobalConfigService).to receive(:load).with('ENABLE_APP_STORE_REVIEWS_CHANNEL', 'false').and_return('false')
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
described_class.new(message: message).perform
expect(Messages::StatusUpdateService).to have_received(:new).with(
message,
'failed',
'App Store Reviews channel is not enabled for this account.'
)
expect(exception_tracker).to have_received(:capture_exception)
end
end
end