feat(inboxes): add app store reviews channel

This commit is contained in:
Muhsin
2026-05-21 08:18:22 +04:00
parent 27f2c2b392
commit 609e27a4c4
31 changed files with 1262 additions and 9 deletions
@@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
%w[web_widget api email line telegram whatsapp sms app_store]
end
def update_inbox_working_hours
@@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms
'sms' => Channel::Sms,
'app_store' => Channel::AppStore
}[permitted_params[:channel][:type]]
end
+2 -1
View File
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
'sms' => Current.account.sms_channels,
'app_store' => Current.account.app_store_channels
}[permitted_params[:channel][:type]]
end
@@ -15,6 +15,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::AppStore': 'i-ri-app-store-fill',
};
const providerIconMap = {
@@ -20,6 +20,7 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAnAppStoreChannel,
isATiktokChannel,
} = useInbox();
@@ -62,7 +63,8 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -85,7 +87,8 @@ const isDelivered = computed(() => {
isASmsInbox.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -110,7 +113,8 @@ const isRead = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isATiktokChannel.value ||
isAnAppStoreChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -66,6 +66,7 @@ const isActive = computed(() => {
'line',
'instagram',
'tiktok',
'app_store',
'voice',
].includes(key);
});
@@ -78,7 +79,7 @@ const isComingSoon = computed(() => {
});
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
return ['tiktok', 'app_store', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
@@ -22,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -30,6 +31,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
};
@@ -138,6 +140,10 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAnAppStoreChannel = computed(() => {
return channelType.value === INBOX_TYPES.APP_STORE;
});
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
@@ -160,6 +166,7 @@ export const useInbox = (inboxId = null) => {
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAnAppStoreChannel,
voiceCallEnabled,
voiceCallProvider,
};
@@ -114,6 +114,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::AppStore': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
+6
View File
@@ -11,6 +11,7 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
APP_STORE: 'Channel::AppStore',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
@@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
[INBOX_TYPES.APP_STORE]: 'i-ri-app-store-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.APP_STORE:
return 'app_store';
default:
return 'chat';
}
@@ -382,6 +382,39 @@
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESC": "Manage and reply to your App Store reviews from Chatwoot.",
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
"ERROR": "This field is required"
},
"APP_ID": {
"LABEL": "App Store Connect app ID",
"PLACEHOLDER": "Enter your App Store Connect app ID",
"ERROR": "This field is required"
},
"ISSUER_ID": {
"LABEL": "Issuer ID",
"PLACEHOLDER": "Enter your App Store Connect issuer ID",
"ERROR": "This field is required"
},
"KEY_ID": {
"LABEL": "Key ID",
"PLACEHOLDER": "Enter your App Store Connect key ID",
"ERROR": "This field is required"
},
"PRIVATE_KEY": {
"LABEL": "Private key",
"PLACEHOLDER": "Paste the contents of your .p8 private key",
"ERROR": "This field is required"
},
"SUBMIT_BUTTON": "Create App Store Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the App Store channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate your email inbox.",
@@ -493,6 +526,10 @@
"TITLE": "TikTok",
"DESCRIPTION": "Connect your TikTok account"
},
"APP_STORE": {
"TITLE": "App Store Reviews",
"DESCRIPTION": "Manage your App Store app reviews"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
@@ -1165,6 +1202,7 @@
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"APP_STORE": "App Store Reviews",
"VOICE": "Voice"
}
}
@@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Tiktok from './channels/Tiktok.vue';
import Voice from './channels/Voice.vue';
import AppStore from './channels/AppStore.vue';
const channelViewList = {
facebook: Facebook,
@@ -25,6 +26,7 @@ const channelViewList = {
telegram: Telegram,
instagram: Instagram,
tiktok: Tiktok,
app_store: AppStore,
voice: Voice,
};
@@ -77,6 +77,12 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'app_store',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.APP_STORE.DESCRIPTION'),
icon: 'i-ri-app-store-line',
},
];
if (hasTiktokConfigured.value) {
@@ -249,6 +249,13 @@ export default {
];
}
if (this.isAnAppStoreChannel) {
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
tab => !unsupportedKeys.includes(tab.key)
);
}
return visibleToAllChannelTabs;
},
currentInboxId() {
@@ -282,6 +289,9 @@ export default {
if (this.isAnEmailChannel) {
return `${this.inbox.name} (${this.inbox.email})`;
}
if (this.isAnAppStoreChannel && this.inbox.app_id) {
return `${this.inbox.name} (${this.inbox.app_id})`;
}
return this.inbox.name;
},
canLocktoSingleConversation() {
@@ -293,6 +303,7 @@ export default {
this.isAnInstagramChannel ||
this.isALineChannel ||
this.isATiktokChannel ||
this.isAnAppStoreChannel ||
this.isATelegramChannel
);
},
@@ -829,6 +840,7 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAnAppStoreChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1128,6 +1140,7 @@ export default {
</SettingsAccordion>
<SettingsAccordion
v-if="!isAnAppStoreChannel"
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
class="mt-6"
>
@@ -0,0 +1,176 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
channelName: '',
appId: '',
issuerId: '',
keyId: '',
privateKey: '',
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
},
validations: {
channelName: { required },
appId: { required },
issuerId: { required },
keyId: { required },
privateKey: { required },
},
methods: {
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) return;
try {
const appStoreChannel = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName?.trim(),
channel: {
type: 'app_store',
app_id: this.appId.trim(),
issuer_id: this.issuerId.trim(),
key_id: this.keyId.trim(),
private_key: this.privateKey.trim(),
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: appStoreChannel.id,
},
});
} catch (error) {
useAlert(
error.message || this.$t('INBOX_MGMT.ADD.APP_STORE.API.ERROR_MESSAGE')
);
}
},
},
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.APP_STORE.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.APP_STORE.DESC')"
/>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.LABEL') }}
<input
v-model="channelName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.PLACEHOLDER')
"
@blur="v$.channelName.$touch"
/>
<span v-if="v$.channelName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.CHANNEL_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.LABEL') }}
<input
v-model="appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.issuerId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.LABEL') }}
<input
v-model="issuerId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.PLACEHOLDER')"
@blur="v$.issuerId.$touch"
/>
<span v-if="v$.issuerId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.ISSUER_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.keyId.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.LABEL') }}
<input
v-model="keyId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.PLACEHOLDER')"
@blur="v$.keyId.$touch"
/>
<span v-if="v$.keyId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.KEY_ID.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.privateKey.$error }">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.LABEL') }}
<textarea
v-model="privateKey"
rows="8"
:placeholder="
$t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.PLACEHOLDER')
"
@blur="v$.privateKey.$touch"
/>
<span v-if="v$.privateKey.$error" class="message">
{{ $t('INBOX_MGMT.ADD.APP_STORE.PRIVATE_KEY.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.APP_STORE.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -34,6 +34,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::AppStore': 'APP_STORE',
};
const twilioChannelName = () => {
@@ -156,7 +156,8 @@ export const getters = {
},
dialogFlowEnabledInboxes($state) {
return $state.records.filter(
item => item.channel_type !== INBOX_TYPES.EMAIL
item =>
![INBOX_TYPES.EMAIL, INBOX_TYPES.APP_STORE].includes(item.channel_type)
);
},
getFacebookInboxByInstagramId: $state => instagramId => {
@@ -15,6 +15,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -23,6 +24,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.APP_STORE,
INBOX_TYPES.API,
],
};
@@ -119,6 +121,8 @@ export default {
badgeKey = 'whatsapp';
} else if (this.isATiktokChannel) {
badgeKey = 'tiktok';
} else if (this.isAnAppStoreChannel) {
badgeKey = 'app_store';
}
return badgeKey || this.channelType;
},
@@ -134,6 +138,9 @@ export default {
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
isAnAppStoreChannel() {
return this.channelType === INBOX_TYPES.APP_STORE;
},
},
methods: {
inboxHasFeature(feature) {
@@ -0,0 +1,12 @@
class Inboxes::FetchAppStoreReviewInboxesJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Inbox.where(channel_type: 'Channel::AppStore').find_each(batch_size: 100) do |inbox|
next if inbox.account.suspended?
next unless inbox.channel.sync_due?
::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel)
end
end
end
@@ -0,0 +1,15 @@
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
queue_as :scheduled_jobs
def perform(channel)
channel.fetch_reviews.each do |review_payload|
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
channel.update!(last_synced_at: Time.current)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
end
+1
View File
@@ -10,6 +10,7 @@ class SendReplyJob < ApplicationJob
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
'Channel::AppStore' => ::AppStore::SendOnAppStoreService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService
+1
View File
@@ -61,6 +61,7 @@ class Account < ApplicationRecord
has_many :agent_bot_inboxes, dependent: :destroy_async
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :app_store_channels, dependent: :destroy_async, class_name: '::Channel::AppStore'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
+72
View File
@@ -0,0 +1,72 @@
class Channel::AppStore < ApplicationRecord
include Channelable
self.table_name = 'channel_app_store'
EDITABLE_ATTRS = [:app_id, :bundle_id, :app_name, :issuer_id, :key_id, :private_key, { provider_config: {} }].freeze
API_BASE_URL = 'https://api.appstoreconnect.apple.com'.freeze
REVIEWS_PAGE_SIZE = 200
SYNC_INTERVAL = 1.hour
if Chatwoot.encryption_configured?
encrypts :issuer_id
encrypts :key_id
encrypts :private_key
end
validates :app_id, presence: true, uniqueness: { scope: :account_id }
validates :issuer_id, :key_id, :private_key, presence: true
validate :validate_app_access, on: :create
before_validation :normalize_auth_fields
after_create_commit :enqueue_initial_review_fetch
def name
'App Store'
end
def sync_due?
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
end
def fetch_reviews
app_store_client.fetch_reviews
end
def reply_to_review(review_id, response_body, response_id: nil)
response = if response_id.present?
app_store_client.update_review_response(response_id, response_body)
else
app_store_client.create_review_response(review_id, response_body)
end
response['id']
end
def app_store_client
@app_store_client ||= AppStoreConnect::Client.new(channel: self)
end
private
def normalize_auth_fields
self.app_id = app_id.to_s.strip
self.issuer_id = issuer_id.to_s.strip
self.key_id = key_id.to_s.strip
self.private_key = private_key.to_s.gsub('\n', "\n").gsub("\r\n", "\n").strip
end
def validate_app_access
app = app_store_client.fetch_app
self.app_name = app.dig('attributes', 'name') if app_name.blank?
self.bundle_id = app.dig('attributes', 'bundleId') if bundle_id.blank?
rescue StandardError => e
errors.add(:base, e.message)
end
def enqueue_initial_review_fetch
::Inboxes::FetchAppStoreReviewsJob.perform_later(self)
end
end
+4
View File
@@ -134,6 +134,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Tiktok'
end
def app_store?
channel_type == 'Channel::AppStore'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
+173
View File
@@ -0,0 +1,173 @@
class AppStore::ReviewBuilder
pattr_initialize [:review_payload!, :channel!]
def perform
return if review_id.blank? || review_body.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
build_conversation
upsert_review_message
build_response_message if response_body.present?
end
end
private
def inbox
@inbox ||= channel.inbox
end
def review
@review ||= review_payload['review'] || {}
end
def response
@response ||= review_payload['response'] || {}
end
def attributes
@attributes ||= review['attributes'] || {}
end
def response_attributes
@response_attributes ||= response['attributes'] || {}
end
def review_id
review['id']
end
def review_body
attributes['body'].to_s.strip
end
def review_title
attributes['title'].to_s.strip
end
def rating
attributes['rating'].to_i
end
def created_at
Time.zone.parse(attributes['createdDate'].to_s)
rescue StandardError
Time.current
end
def response_id
response['id']
end
def response_body
response_attributes['responseBody'].to_s.strip
end
def response_created_at
Time.zone.parse(response_attributes['lastModifiedDate'].to_s)
rescue StandardError
Time.current
end
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: review_id,
inbox: inbox,
contact_attributes: {
name: attributes['reviewerNickname'].presence || 'App Store User',
additional_attributes: { source_id: "app_store:#{review_id}" }
}
).perform
end
def build_conversation
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { source: 'app_store', app_id: channel.app_id }
)
end
def upsert_review_message
message = @conversation.messages.find_by(source_id: review_id)
if message
message.update!(content: message_content, content_attributes: review_metadata)
return
end
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
sender: @conversation.contact,
message_type: :incoming,
source_id: review_id,
content: message_content,
content_attributes: review_metadata,
created_at: created_at,
updated_at: created_at
)
end
def build_response_message
return if response_id.blank?
return if @conversation.messages.exists?(source_id: response_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: :outgoing,
source_id: response_id,
content: response_body,
status: :delivered,
content_attributes: response_metadata,
created_at: response_created_at,
updated_at: response_created_at
)
end
def message_content
[
rating_line,
review_title.presence,
review_body,
review_footer
].compact_blank.join("\n\n")
end
def rating_line
stars = ('★' * rating) + ('☆' * (5 - rating))
"#{stars} (#{rating}/5)"
end
def review_footer
parts = [attributes['territory'], attributes['reviewerNickname']].compact_blank
return nil if parts.empty?
parts.join(' • ')
end
def review_metadata
{
app_store: {
rating: rating,
title: review_title,
territory: attributes['territory'],
reviewer_nickname: attributes['reviewerNickname'],
created_date: attributes['createdDate']
}
}
end
def response_metadata
{
app_store: {
response_id: response_id,
response_state: response_attributes['state'],
response_last_modified_date: response_attributes['lastModifiedDate']
}
}
end
end
@@ -0,0 +1,38 @@
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
private
def channel_class
Channel::AppStore
end
def perform_reply
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?
Messages::StatusUpdateService.new(message, 'delivered').perform
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
def validate_message_support!
raise 'Sending attachments is not supported for App Store reviews.' if message.attachments.any?
end
def review_id
message.conversation.contact_inbox.source_id
end
def reply_content
message.outgoing_content.presence || message.content
end
def existing_response_id
message.conversation.messages
.outgoing
.where.not(id: message.id)
.where.not(source_id: [nil, ''])
.order(created_at: :desc)
.pick(:source_id)
end
end
+143
View File
@@ -0,0 +1,143 @@
class AppStoreConnect::Client
class Error < StandardError; end
pattr_initialize [:channel!]
def fetch_app
get("/v1/apps/#{channel.app_id}")['data']
end
def fetch_reviews
reviews = []
next_url = nil
loop do
payload = next_url ? get_url(next_url) : get(reviews_path, reviews_query)
included = Array(payload['included'])
reviews.concat(Array(payload['data']).map { |review| normalize_review(review, included) })
next_url = payload.dig('links', 'next')
break if next_url.blank?
end
reviews
end
def create_review_response(review_id, response_body)
post('/v1/customerReviewResponses', review_response_payload(review_id, response_body))['data']
end
def update_review_response(response_id, response_body)
patch("/v1/customerReviewResponses/#{response_id}", review_response_update_payload(response_id, response_body))['data']
end
private
def reviews_path
"/v1/apps/#{channel.app_id}/customerReviews"
end
def reviews_query
{
include: 'response',
limit: Channel::AppStore::REVIEWS_PAGE_SIZE,
sort: '-createdDate'
}
end
def normalize_review(review, included)
response_id = review.dig('relationships', 'response', 'data', 'id')
response = included.find { |item| item['type'] == 'customerReviewResponses' && item['id'] == response_id }
{
'review' => review,
'response' => response
}
end
def get(path, query = {})
request(:get, "#{Channel::AppStore::API_BASE_URL}#{path}", query: query)
end
def get_url(url)
request(:get, url)
end
def post(path, body)
request(:post, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
end
def patch(path, body)
request(:patch, "#{Channel::AppStore::API_BASE_URL}#{path}", body: body)
end
def request(method, url, query: {}, body: nil)
response = HTTParty.public_send(
method,
url,
headers: headers,
query: query,
body: body&.to_json
)
log_rate_limit(response)
return response.parsed_response if response.success?
raise Error, error_message(response)
end
def headers
{
'Authorization' => "Bearer #{token}",
'Content-Type' => 'application/json'
}
end
def token
@token ||= AppStoreConnect::TokenService.new(channel: channel).token
end
def review_response_payload(review_id, response_body)
{
data: {
type: 'customerReviewResponses',
attributes: {
responseBody: response_body.to_s
},
relationships: {
review: {
data: {
type: 'customerReviews',
id: review_id
}
}
}
}
}
end
def review_response_update_payload(response_id, response_body)
{
data: {
type: 'customerReviewResponses',
id: response_id,
attributes: {
responseBody: response_body.to_s
}
}
}
end
def log_rate_limit(response)
rate_limit = response.headers['x-rate-limit']
Rails.logger.info("[APP_STORE_CONNECT] rate_limit=#{rate_limit}") if rate_limit.present?
end
def error_message(response)
parsed_response = response.parsed_response
errors = parsed_response.is_a?(Hash) ? parsed_response['errors'] : []
details = Array(errors).filter_map { |error| error['detail'] || error['title'] }.join(', ')
details = response.body if details.blank?
"App Store Connect API failed (#{response.code}): #{details}"
end
end
@@ -0,0 +1,47 @@
class AppStoreConnect::TokenService
TOKEN_TTL = 19.minutes
EXPIRY_BUFFER = 1.minute
pattr_initialize [:channel!]
def token
cached_token || generate_token
end
private
def cached_token
Rails.cache.read(cache_key)
end
def generate_token
token = JWT.encode(payload, private_key, 'ES256', headers)
Rails.cache.write(cache_key, token, expires_in: TOKEN_TTL - EXPIRY_BUFFER)
token
end
def payload
now = Time.current.to_i
{
iss: channel.issuer_id,
iat: now,
exp: now + TOKEN_TTL.to_i,
aud: 'appstoreconnect-v1'
}
end
def headers
{
kid: channel.key_id,
typ: 'JWT'
}
end
def private_key
OpenSSL::PKey.read(channel.private_key.to_s.gsub('\\n', "\n"))
end
def cache_key
"app_store_connect_token:#{channel.id}:#{channel.updated_at.to_i}"
end
end
@@ -63,6 +63,14 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
## Tiktok Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
## App Store Attributes
if resource.app_store?
json.app_id resource.channel.try(:app_id)
json.bundle_id resource.channel.try(:bundle_id)
json.app_name resource.channel.try(:app_name)
json.last_synced_at resource.channel.try(:last_synced_at)
end
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
json.phone_number resource.channel.try(:phone_number)
+429
View File
@@ -0,0 +1,429 @@
# App Store Reviews Inbox Plan
## Goal
Add Apple App Store reviews as a Chatwoot inbox, similar to the Google Play Reviews inbox, so agents can read App Store reviews in Chatwoot and post developer responses from the conversation reply box.
## API Findings
- Use the official App Store Connect API, not public RSS/iTunes review feeds.
- App Store Connect API uses API keys and ES256 JWT bearer tokens, not OAuth user sign-in.
- Required credentials:
- Issuer ID
- Key ID
- `.p8` private key
- JWTs should be short-lived. Apple generally rejects App Store Connect API tokens with expiration more than 20 minutes in the future.
- Reviews can be fetched from:
- `GET /v1/apps/{id}/customerReviews`
- `GET /v1/appStoreVersions/{id}/customerReviews`
- Review fields include:
- `rating`
- `title`
- `body`
- `reviewerNickname`
- `createdDate`
- `territory`
- `response`
- Developer responses can be included with `include=response`.
- Developer replies are created or updated through:
- `POST /v1/customerReviewResponses`
- A review can have at most one developer response. Posting another response updates/replaces the existing one.
- Apple says responses can take up to 24 hours to appear publicly.
- Required App Store Connect role for responding: Account Holder, Admin, or Customer Support.
- No Apple equivalent of Google Play's 7-day review fetch limit was found. Do not add a 7-day reply window unless real API testing proves one exists.
References:
- https://developer.apple.com/documentation/appstoreconnectapi/generating-tokens-for-api-requests
- https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses
- https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses
- https://developer.apple.com/documentation/appstoreconnectapi/list_all_customer_reviews_for_an_app_store_version
- https://developer.apple.com/help/app-store-connect/monitor-ratings-and-reviews/respond-to-reviews/
## Product Shape
The App Store inbox should be a new channel type, separate from Google Play:
- Channel name: App Store Reviews
- One inbox per App Store Connect app per Chatwoot account.
- Setup should be credential-form based, not OAuth redirect based.
- Agents should see each review as a conversation.
- Agents should be able to reply once; later replies update the App Store response.
- Existing developer responses from App Store Connect should be mirrored as outgoing messages.
- Review title, body, rating, territory, and reviewer nickname should be visible in the conversation.
## Data Model
Add `channel_app_store`.
Suggested fields:
- `account_id`
- `app_id` - App Store Connect app resource ID
- `bundle_id`
- `app_name`
- `provider_config` - non-secret metadata only, if needed
- `issuer_id`
- `key_id`
- `private_key`
- `last_synced_at`
- timestamps
Indexes:
- unique index on `[:account_id, :app_id]`
Security:
- Treat `.p8` private key as a sensitive credential.
- Prefer explicit encrypted columns for `issuer_id`, `key_id`, and `private_key`.
- Avoid storing the private key inside unencrypted JSONB.
- Follow existing `Chatwoot.encryption_configured?` patterns used by channel credentials.
Model wiring:
- Add `Channel::AppStore`.
- Add `Account#app_store_channels`.
- Add `Inbox#app_store?`.
- Add API serialization for `app_id`, `bundle_id`, `app_name`, and `last_synced_at`.
- Add `SendReplyJob` mapping to `AppStore::SendOnAppStoreService`.
## Backend Services
### `AppStoreConnect::TokenService`
Responsibilities:
- Build an ES256 JWT using the channel credentials.
- Use existing `jwt` gem.
- Parse private key with `OpenSSL::PKey`.
- Set JWT header:
- `alg: ES256`
- `kid: key_id`
- `typ: JWT`
- Set JWT payload:
- `iss: issuer_id`
- `iat`
- `exp`
- `aud: appstoreconnect-v1`
- Cache token per channel until close to expiry.
### `AppStoreConnect::Client`
Responsibilities:
- Add bearer token auth header.
- Fetch reviews with pagination.
- Fetch included developer responses.
- Post developer responses.
- Raise clear errors for:
- 401 invalid credentials
- 403 missing permissions
- 404 app/review not found
- 409/422 invalid response payload
- 429 rate limited
- 5xx Apple errors
Suggested methods:
- `list_reviews(app_id, cursor: nil)`
- `reply_to_review(review_id, response_body)`
- `fetch_app(app_id)` or `validate_app_access(app_id)`
## Import Pipeline
Add jobs:
- `Inboxes::FetchAppStoreReviewInboxesJob`
- `Inboxes::FetchAppStoreReviewsJob`
Polling behavior:
- Scheduled polling similar to Google Play.
- Skip suspended accounts.
- Use `last_synced_at` to avoid excessive polling.
- Page through review results using `links.next`.
- Consider sorting by `-createdDate`.
Add `AppStore::ReviewBuilder`.
Mapping:
- Apple review ID maps to `ContactInbox#source_id`.
- One review maps to one conversation.
- Review edits should be handled idempotently.
- Incoming message source ID can be based on review ID plus `createdDate` or a stable edit/version field if Apple exposes one.
- Existing developer response maps to an outgoing message.
- Developer response message source ID should use Apple response ID if present.
Message content:
- Include star rating.
- Include title.
- Include body.
- Include a compact footer with territory and reviewer nickname when useful.
Message timestamps:
- Use Apple `createdDate` as `created_at` and `updated_at` for imported review messages.
- Do not use import time for review messages.
Metadata:
Store under `content_attributes[:app_store]`:
- `rating`
- `title`
- `territory`
- `reviewer_nickname`
- `created_date`
- `response_state`
- `response_id`
## Reply Pipeline
Add `AppStore::SendOnAppStoreService`.
Behavior:
- Use `conversation.contact_inbox.source_id` as the Apple review ID.
- Call `POST /v1/customerReviewResponses`.
- On success:
- Update `message.source_id` with Apple response ID if returned.
- Mark message as sent/delivered through `Messages::StatusUpdateService`.
- On failure:
- Mark message as failed through `Messages::StatusUpdateService`.
- Store `external_error`.
Constraints:
- Disable attachments.
- Disable rich-text formatting.
- Confirm Apple's response length limit during implementation. Do not guess a hard cap unless verified.
## Frontend
Add App Store Reviews to inbox creation.
Setup form fields:
- Inbox name
- App Store Connect app ID
- Bundle ID, optional if app ID is enough
- Issuer ID
- Key ID
- Private key `.p8`
Backend should validate credentials before creating the inbox by calling App Store Connect.
Frontend wiring:
- Add `INBOX_TYPES.APP_STORE`.
- Add `isAnAppStoreChannel` / equivalent composable and mixin helpers.
- Add channel icon.
- Add i18n strings in `en.json` only.
- Add channel to inbox list and channel factory.
- Add API client for creating/validating App Store channel.
- Add plain text editor config for `Channel::AppStore`.
- Add reply max length only after Apple limit is confirmed.
Unsupported settings:
- Hide bots.
- Hide business hours.
- Hide CSAT.
- Hide help center.
- Hide channel preferences that do not apply.
- Disable attachments.
## Tests
Backend specs:
- `Channel::AppStore` validations and associations.
- JWT generation with generated EC key.
- Client review pagination.
- Client reply request body.
- Client error handling.
- Inbox creation credential validation.
- Review builder creates contact, conversation, incoming message.
- Review builder idempotency.
- Review builder mirrors existing developer response.
- Send service success and failure.
- Polling job skips suspended accounts.
- Polling job respects sync interval.
Frontend specs:
- Channel detection helper/composable.
- Inbox type icon/readable label.
- Setup form validation.
- Reply box behavior for unsupported attachments/formatting.
## Resolved Open Questions
### App-level reviews vs version-level reviews
Use app-level reviews as the primary fetch path:
- `GET /v1/apps/{id}/customerReviews`
Reasoning:
- Chatwoot inboxes should map to an app, not to a specific App Store version.
- Apple documents app-level customer reviews as the endpoint for getting reviews for a specific app.
- Version-level reviews are still useful for narrower workflows, but they would make inbox setup more complicated and could fragment one app's support queue across several inboxes.
Implementation decision:
- Store the App Store Connect app resource ID on `Channel::AppStore`.
- Fetch app-level reviews by default.
- Keep version-level support out of MVP.
- Add `platform` or `app_store_version_id` later only if real API testing shows app-level reviews mix platforms in a way agents cannot work with.
Still needs real API validation:
- Confirm whether app-level reviews include all platforms and all versions for a multi-platform app.
- Confirm whether app-level review payload includes enough context to identify platform/version. The documented review attributes include `rating`, `title`, `body`, `reviewerNickname`, `createdDate`, and `territory`, but not platform/version.
### Response payload shape
Use JSON:API format for `POST /v1/customerReviewResponses`.
Request body:
```json
{
"data": {
"type": "customerReviewResponses",
"attributes": {
"responseBody": "Thanks for the feedback."
},
"relationships": {
"review": {
"data": {
"type": "customerReviews",
"id": "CUSTOMER_REVIEW_ID"
}
}
}
}
}
```
Expected successful response:
- HTTP `201 Created`.
- Response resource type: `customerReviewResponses`.
- Response fields can include:
- `responseBody`
- `lastModifiedDate`
- `state`
- `review`
Implementation decision:
- Use the returned customer review response ID as outgoing message `source_id`.
- Store `state` and `lastModifiedDate` in `content_attributes[:app_store]` when present.
### Review edits and idempotency
Apple's documented `CustomerReview.Attributes` include `createdDate`, but not an update timestamp for the customer review itself.
Implementation decision:
- Use the Apple review ID as the stable incoming message source ID.
- Create one incoming message per review.
- If a fetched review with the same ID has changed title/body/rating, update the existing message content and metadata instead of creating a new message.
- Do not append edit history in MVP because the API docs do not expose a review edit timestamp.
Still needs real API validation:
- Confirm how Apple represents a reviewer editing an existing review in API responses.
- Confirm whether edited reviews preserve the same review ID.
### Response body length
No official customer review response length limit was found in the App Store Connect API docs checked.
Implementation decision:
- Do not hardcode a special App Store response length limit in MVP.
- Use Chatwoot's general reply validation on the frontend.
- Let Apple return `409` or `422` for invalid response payloads and surface the error through `external_error`.
Still needs real API validation:
- Check whether App Store Connect applies a hidden maximum length for `responseBody`.
### Rate limits and retries
Apple documents rate limits through the `X-Rate-Limit` response header.
Header shape:
```text
user-hour-lim:3500;user-hour-rem:500;
```
Behavior:
- Limits apply to requests using the same API key.
- The window is a rolling hour.
- Exceeding the limit returns HTTP `429` with `RATE_LIMIT_EXCEEDED`.
Implementation decision:
- Parse and log `X-Rate-Limit` headers in the client.
- On `429`, do not mark the inbox broken.
- Re-enqueue the fetch job later with backoff.
- Keep polling conservative, similar to Google Play, and page with `limit=200`.
### Inbox scope for multiple platforms and versions
Implementation decision:
- MVP scope is one inbox per App Store Connect app resource ID per Chatwoot account.
- Do not create separate inboxes by platform or app version.
- Store `platform` only if we can reliably derive it during setup or fetch.
Reasoning:
- This matches the way agents think about supporting one app.
- It avoids forcing customers to know App Store version resource IDs.
- It keeps parity with Google Play's one-app-per-inbox model.
Still needs real API validation:
- Confirm if app-level reviews for multi-platform apps are agent-friendly without platform separation.
## Remaining Risks
- Credential storage must be handled carefully because `.p8` private keys are highly sensitive.
- Apple responses can remain pending for up to 24 hours, so Chatwoot send status and public App Store visibility are not the same.
- A real App Store Connect app and API key are required before finalizing response-length handling, review-edit behavior, and multi-platform behavior.
## Suggested Implementation Order
1. Add model, migration, associations, and inbox serialization.
2. Add JWT token service and low-level App Store Connect client.
3. Add backend channel creation/validation endpoint.
4. Add review fetch job and review builder.
5. Add send service and `SendReplyJob` mapping.
6. Add frontend inbox setup and channel helpers.
7. Add settings/reply-box restrictions.
8. Add specs.
9. Manually verify with a real App Store Connect app and API key.
10. Revisit shared abstractions with Google Play after both integrations work.
## Potential Shared Abstraction Later
After Google Play and App Store are both implemented, consider extracting shared store-review behavior:
- Store review polling orchestration.
- Review-to-conversation builder conventions.
- Reply status handling.
- Unsupported inbox settings.
- Plain-text reply channel behavior.
Do not extract this upfront. Let both implementations settle first.
+6
View File
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# executed every 15 minutes to fetch App Store reviews
trigger_app_store_review_inboxes_job:
cron: '*/15 * * * *'
class: 'Inboxes::FetchAppStoreReviewInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
@@ -0,0 +1,19 @@
class CreateChannelAppStore < ActiveRecord::Migration[7.1]
def change
create_table :channel_app_store do |t|
t.bigint :account_id, null: false
t.string :app_id, null: false
t.string :bundle_id
t.string :app_name
t.string :issuer_id, null: false
t.string :key_id, null: false
t.text :private_key, null: false
t.jsonb :provider_config, null: false, default: {}
t.datetime :last_synced_at
t.timestamps
end
add_index :channel_app_store, [:account_id, :app_id], unique: true
end
end
+16 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_05_20_090000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -449,6 +449,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
t.index ["identifier"], name: "index_channel_api_on_identifier", unique: true
end
create_table "channel_app_store", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "app_id", null: false
t.string "bundle_id"
t.string "app_name"
t.string "issuer_id", null: false
t.string "key_id", null: false
t.text "private_key", null: false
t.jsonb "provider_config", default: {}, null: false
t.datetime "last_synced_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "app_id"], name: "index_channel_app_store_on_account_id_and_app_id", unique: true
end
create_table "channel_email", force: :cascade do |t|
t.integer "account_id", null: false
t.string "email", null: false