Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95351705a3 | ||
|
|
4465c7c7e9 | ||
|
|
215a954d19 | ||
|
|
86b9bad778 | ||
|
|
fd5de4398d | ||
|
|
df538a6a3b | ||
|
|
361b28cb03 | ||
|
|
15227a424a | ||
|
|
1e36126a00 | ||
|
|
eb9647d6c6 | ||
|
|
b3fa179b7f | ||
|
|
da0a122919 | ||
|
|
eb30c8dea4 | ||
|
|
2a624ac800 | ||
|
|
29ecbc846b | ||
|
|
379daeef18 | ||
|
|
127a544a89 | ||
|
|
572d6d2e40 | ||
|
|
609e27a4c4 | ||
|
|
1149edbd2d | ||
|
|
9edad844a4 | ||
|
|
37db80793f | ||
|
|
fe87933223 | ||
|
|
3509a98eb2 | ||
|
|
13a9f2591b |
@@ -0,0 +1,34 @@
|
||||
class Api::V1::Accounts::GooglePlay::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include GooglePlayOauthConcern
|
||||
|
||||
def create
|
||||
redirect_url = google_client.auth_code.authorize_url(
|
||||
redirect_uri: google_play_callback_url,
|
||||
scope: scope,
|
||||
response_type: 'code',
|
||||
prompt: 'consent', # forces Google to return a refresh token
|
||||
access_type: 'offline',
|
||||
state: google_play_oauth_state,
|
||||
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def google_play_oauth_state
|
||||
google_play_verifier.generate(
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
app_id: params[:app_id],
|
||||
inbox_name: params[:inbox_name]
|
||||
},
|
||||
expires_in: 15.minutes
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -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? && !Current.account.feature_enabled?(:channel_app_store)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
@inbox = Current.account.inboxes.build(
|
||||
@@ -97,7 +99,15 @@ 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 google_play]
|
||||
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 update_inbox_working_hours
|
||||
@@ -179,7 +189,9 @@ 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,
|
||||
'google_play' => Channel::GooglePlay
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
module GooglePlayOauthConcern
|
||||
extend ActiveSupport::Concern
|
||||
include GoogleConcern
|
||||
|
||||
GOOGLE_PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
|
||||
|
||||
private
|
||||
|
||||
# Overrides the Gmail scope from GoogleConcern with the Play Developer API scope
|
||||
def scope
|
||||
GOOGLE_PLAY_SCOPE
|
||||
end
|
||||
|
||||
# Carries the account and channel details through the OAuth round trip
|
||||
def google_play_verifier
|
||||
Rails.application.message_verifier('google_play_oauth')
|
||||
end
|
||||
|
||||
def google_play_callback_url
|
||||
"#{base_url}/google_play/callback"
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
class GooglePlay::CallbacksController < ApplicationController
|
||||
include GooglePlayOauthConcern
|
||||
|
||||
def show
|
||||
return redirect_with_error(params[:error]) if params[:error].present?
|
||||
|
||||
account
|
||||
token = google_client.auth_code.get_token(params[:code], redirect_uri: google_play_callback_url)
|
||||
inbox = create_channel_with_inbox(token)
|
||||
|
||||
redirect_to app_google_play_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_with_error(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Sends the user back to the inbox setup form with the error surfaced as a query param.
|
||||
# Falls back to '/' only when we can't determine the account (e.g. tampered/missing state).
|
||||
def redirect_with_error(error_message)
|
||||
acc = safe_account
|
||||
return redirect_to '/' unless acc
|
||||
|
||||
redirect_to app_new_google_play_inbox_url(account_id: acc.id, error: error_message.to_s.truncate(300))
|
||||
end
|
||||
|
||||
def safe_account
|
||||
Account.find_by(id: state_payload[:account_id])
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def state_payload
|
||||
@state_payload ||= google_play_verifier.verify(params[:state]).with_indifferent_access
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(state_payload[:account_id])
|
||||
end
|
||||
|
||||
def create_channel_with_inbox(token)
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = Channel::GooglePlay.create!(
|
||||
account: account,
|
||||
app_id: state_payload[:app_id],
|
||||
provider_config: {
|
||||
access_token: token.token,
|
||||
refresh_token: token.refresh_token,
|
||||
expires_on: (Time.current.utc + 1.hour).to_s
|
||||
}
|
||||
)
|
||||
# Return the newly created inbox directly — `channel.inbox` is unreliable here because the polymorphic
|
||||
# has_one cache is not always populated by `account.inboxes.create!`.
|
||||
account.inboxes.create!(account: account, channel: channel, name: state_payload[:inbox_name])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -111,7 +111,9 @@ 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,
|
||||
'google_play' => Current.account.google_play_channels
|
||||
}[permitted_params[:channel][:type]]
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class GooglePlayClient extends ApiClient {
|
||||
constructor() {
|
||||
super('google_play', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization(payload) {
|
||||
return axios.post(`${this.url}/authorization`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GooglePlayClient();
|
||||
@@ -15,6 +15,8 @@ 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',
|
||||
'Channel::GooglePlay': 'i-ri-google-play-line',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -20,7 +20,9 @@ const {
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isAnAppStoreChannel,
|
||||
isATiktokChannel,
|
||||
isAGooglePlayChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
@@ -62,7 +64,9 @@ const isSent = computed(() => {
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value ||
|
||||
isAGooglePlayChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -85,7 +89,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 +115,8 @@ const isRead = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value ||
|
||||
isATiktokChannel.value
|
||||
isATiktokChannel.value ||
|
||||
isAnAppStoreChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ const isActive = computed(() => {
|
||||
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
|
||||
}
|
||||
|
||||
if (key === 'app_store') {
|
||||
return props.enabledFeatures.channel_app_store;
|
||||
}
|
||||
|
||||
if (key === 'voice') {
|
||||
return props.enabledFeatures.channel_voice;
|
||||
}
|
||||
@@ -67,6 +71,7 @@ const isActive = computed(() => {
|
||||
'instagram',
|
||||
'tiktok',
|
||||
'voice',
|
||||
'google_play',
|
||||
].includes(key);
|
||||
});
|
||||
|
||||
@@ -78,7 +83,9 @@ const isComingSoon = computed(() => {
|
||||
});
|
||||
|
||||
const isBeta = computed(() => {
|
||||
return ['tiktok', 'voice'].includes(props.channel.key);
|
||||
return ['tiktok', 'app_store', 'voice', 'google_play'].includes(
|
||||
props.channel.key
|
||||
);
|
||||
});
|
||||
|
||||
const onItemClick = () => {
|
||||
|
||||
@@ -264,6 +264,9 @@ export default {
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
if (this.isAGooglePlayChannel) {
|
||||
return MESSAGE_MAX_LENGTH.GOOGLE_PLAY;
|
||||
}
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ const mockStore = createStore({
|
||||
voice_enabled: true,
|
||||
},
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
16: { id: 16, channel_type: INBOX_TYPES.GOOGLE_PLAY },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
},
|
||||
@@ -226,6 +227,12 @@ describe('useInbox', () => {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isATiktokChannel).toBe(true);
|
||||
|
||||
// Test Google Play
|
||||
wrapper = mount(createTestComponent(16), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAGooglePlayChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,6 +285,7 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'isAGooglePlayChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
];
|
||||
|
||||
@@ -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,14 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const isAnAppStoreChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.APP_STORE;
|
||||
});
|
||||
|
||||
const isAGooglePlayChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.GOOGLE_PLAY;
|
||||
});
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
@@ -160,6 +170,8 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAnAppStoreChannel,
|
||||
isAGooglePlayChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
};
|
||||
|
||||
@@ -114,6 +114,16 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::AppStore': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::GooglePlay': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::PrivateNote': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
|
||||
@@ -11,6 +11,8 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
APP_STORE: 'Channel::AppStore',
|
||||
GOOGLE_PLAY: 'Channel::GooglePlay',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
@@ -50,6 +52,8 @@ 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',
|
||||
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -65,6 +69,8 @@ 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',
|
||||
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||
@@ -114,6 +120,12 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.APP_STORE:
|
||||
return 'app_store';
|
||||
|
||||
case INBOX_TYPES.GOOGLE_PLAY:
|
||||
return 'google_play';
|
||||
|
||||
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.",
|
||||
@@ -447,6 +480,24 @@
|
||||
"ERROR_MESSAGE": "We were not able to save the telegram channel"
|
||||
}
|
||||
},
|
||||
"GOOGLE_PLAY": {
|
||||
"TITLE": "Google Play Reviews",
|
||||
"DESC": "Manage and reply to your Google Play Store app reviews from Chatwoot.",
|
||||
"INBOX_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
"ERROR": "This field is required"
|
||||
},
|
||||
"APP_ID": {
|
||||
"LABEL": "App package name",
|
||||
"PLACEHOLDER": "Please enter your app package name (eg: com.example.app)",
|
||||
"ERROR": "Please enter a valid app package name"
|
||||
},
|
||||
"CONNECT_BUTTON": "Connect with Google",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to connect your Google Play Reviews channel"
|
||||
}
|
||||
},
|
||||
"AUTH": {
|
||||
"TITLE": "Choose a channel",
|
||||
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below.",
|
||||
@@ -493,9 +544,17 @@
|
||||
"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"
|
||||
},
|
||||
"GOOGLE_PLAY": {
|
||||
"TITLE": "Google Play Reviews",
|
||||
"DESCRIPTION": "Manage your Google Play Store app reviews"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1165,6 +1224,8 @@
|
||||
"API": "API Channel",
|
||||
"INSTAGRAM": "Instagram",
|
||||
"TIKTOK": "TikTok",
|
||||
"APP_STORE": "App Store Reviews",
|
||||
"GOOGLE_PLAY": "Google Play Reviews",
|
||||
"VOICE": "Voice"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
import GooglePlay from './channels/GooglePlay.vue';
|
||||
|
||||
const channelViewList = {
|
||||
facebook: Facebook,
|
||||
@@ -25,7 +27,9 @@ const channelViewList = {
|
||||
telegram: Telegram,
|
||||
instagram: Instagram,
|
||||
tiktok: Tiktok,
|
||||
app_store: AppStore,
|
||||
voice: Voice,
|
||||
google_play: GooglePlay,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
|
||||
@@ -77,6 +77,18 @@ 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',
|
||||
},
|
||||
{
|
||||
key: 'google_play',
|
||||
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.TITLE'),
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.GOOGLE_PLAY.DESCRIPTION'),
|
||||
icon: 'i-ri-google-play-line',
|
||||
},
|
||||
];
|
||||
|
||||
if (hasTiktokConfigured.value) {
|
||||
|
||||
@@ -249,6 +249,13 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAnAppStoreChannel || this.isAGooglePlayChannel) {
|
||||
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
|
||||
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
|
||||
tab => !unsupportedKeys.includes(tab.key)
|
||||
);
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -282,6 +289,12 @@ export default {
|
||||
if (this.isAnEmailChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.email})`;
|
||||
}
|
||||
if (
|
||||
(this.isAnAppStoreChannel || this.isAGooglePlayChannel) &&
|
||||
this.inbox.app_id
|
||||
) {
|
||||
return `${this.inbox.name} (${this.inbox.app_id})`;
|
||||
}
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
@@ -293,6 +306,7 @@ export default {
|
||||
this.isAnInstagramChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isATiktokChannel ||
|
||||
this.isAnAppStoreChannel ||
|
||||
this.isATelegramChannel
|
||||
);
|
||||
},
|
||||
@@ -829,6 +843,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAnAppStoreChannel && !isAGooglePlayChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1128,6 +1143,7 @@ export default {
|
||||
</SettingsAccordion>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="!isAnAppStoreChannel && !isAGooglePlayChannel"
|
||||
: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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import googlePlayClient from 'dashboard/api/channel/googlePlayClient';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Surface any error the OAuth callback redirected back with
|
||||
onMounted(() => {
|
||||
if (route.query.error) {
|
||||
useAlert(String(route.query.error));
|
||||
}
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
inboxName: '',
|
||||
appId: '',
|
||||
});
|
||||
|
||||
const isConnecting = ref(false);
|
||||
|
||||
const rules = {
|
||||
inboxName: { required },
|
||||
appId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const connectWithGoogle = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
try {
|
||||
isConnecting.value = true;
|
||||
const {
|
||||
data: { url },
|
||||
} = await googlePlayClient.generateAuthorization({
|
||||
app_id: state.appId.trim(),
|
||||
inbox_name: state.inboxName.trim(),
|
||||
});
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
isConnecting.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.GOOGLE_PLAY.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-6 col-span-6">
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.DESC')"
|
||||
/>
|
||||
<form
|
||||
class="flex flex-wrap flex-col mx-0"
|
||||
@submit.prevent="connectWithGoogle"
|
||||
>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.inboxName.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.LABEL') }}
|
||||
<input
|
||||
v-model="state.inboxName"
|
||||
type="text"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.inboxName.$touch"
|
||||
/>
|
||||
<span v-if="v$.inboxName.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label :class="{ error: v$.appId.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.LABEL') }}
|
||||
<input
|
||||
v-model="state.appId"
|
||||
type="text"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.PLACEHOLDER')"
|
||||
@blur="v$.appId.$touch"
|
||||
/>
|
||||
<span v-if="v$.appId.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<NextButton
|
||||
:is-loading="isConnecting"
|
||||
type="submit"
|
||||
solid
|
||||
blue
|
||||
:label="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.CONNECT_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -34,6 +34,8 @@ const i18nMap = {
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Tiktok': 'TIKTOK',
|
||||
'Channel::AppStore': 'APP_STORE',
|
||||
'Channel::GooglePlay': 'GOOGLE_PLAY',
|
||||
};
|
||||
|
||||
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 => {
|
||||
|
||||
@@ -22,4 +22,6 @@ export const MESSAGE_MAX_LENGTH = {
|
||||
TELEGRAM: 4096,
|
||||
LINE: 2000,
|
||||
EMAIL: 25000,
|
||||
// https://developers.google.com/android-publisher/reviews — developer replies are capped at 350 characters
|
||||
GOOGLE_PLAY: 350,
|
||||
};
|
||||
|
||||
@@ -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,12 @@ export default {
|
||||
isATiktokChannel() {
|
||||
return this.channelType === INBOX_TYPES.TIKTOK;
|
||||
},
|
||||
isAnAppStoreChannel() {
|
||||
return this.channelType === INBOX_TYPES.APP_STORE;
|
||||
},
|
||||
isAGooglePlayChannel() {
|
||||
return this.channelType === INBOX_TYPES.GOOGLE_PLAY;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inboxHasFeature(feature) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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.account.feature_enabled?(:channel_app_store)
|
||||
next unless inbox.channel.sync_due?
|
||||
|
||||
::Inboxes::FetchAppStoreReviewsJob.perform_later(inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform(channel)
|
||||
return unless channel.account.feature_enabled?(:channel_app_store)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,14 @@
|
||||
class Inboxes::FetchGooglePlayReviewInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
# Runs every 15 minutes via cron. Each inbox syncs at most once per Channel::GooglePlay::SYNC_INTERVAL
|
||||
# (one hour) — the extra cron ticks act as quick retries when a sync window is missed.
|
||||
def perform
|
||||
Inbox.where(channel_type: 'Channel::GooglePlay').find_each(batch_size: 100) do |inbox|
|
||||
next if inbox.account.suspended?
|
||||
next unless inbox.channel.sync_due?
|
||||
|
||||
::Inboxes::FetchGooglePlayReviewsJob.perform_later(inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class Inboxes::FetchGooglePlayReviewsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
def perform(channel)
|
||||
channel.fetch_reviews.each do |review|
|
||||
::GooglePlay::ReviewBuilder.new(review: review, channel: channel).perform
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
# Stamp the channel so the orchestrator skips it until the sync interval elapses.
|
||||
channel.update!(last_synced_at: Time.current)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,9 @@ class SendReplyJob < ApplicationJob
|
||||
'Channel::Sms' => ::Sms::SendOnSmsService,
|
||||
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
|
||||
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
|
||||
'Channel::AppStore' => ::AppStore::SendOnAppStoreService,
|
||||
'Channel::Email' => ::Email::SendOnEmailService,
|
||||
'Channel::GooglePlay' => ::GooglePlay::SendOnGooglePlayService,
|
||||
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
|
||||
'Channel::Api' => ::Messages::SendEmailNotificationService
|
||||
}.freeze
|
||||
|
||||
@@ -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
|
||||
@@ -77,6 +78,7 @@ class Account < ApplicationRecord
|
||||
has_many :data_imports, dependent: :destroy_async
|
||||
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
|
||||
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
|
||||
has_many :google_play_channels, dependent: :destroy_async, class_name: '::Channel::GooglePlay'
|
||||
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
|
||||
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
|
||||
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,110 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: channel_google_play
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# app_id :string not null
|
||||
# provider_config :jsonb not null
|
||||
# last_synced_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_channel_google_play_on_account_id_and_app_id (account_id,app_id) UNIQUE
|
||||
#
|
||||
|
||||
class Channel::GooglePlay < ApplicationRecord
|
||||
include Channelable
|
||||
|
||||
self.table_name = 'channel_google_play'
|
||||
EDITABLE_ATTRS = [:app_id, { provider_config: {} }].freeze
|
||||
|
||||
API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3'.freeze
|
||||
# Google Play caps a developer reply at 350 characters
|
||||
MAX_REPLY_LENGTH = 350
|
||||
REVIEWS_PAGE_SIZE = 100
|
||||
# Safety bound — at 100 per page this covers 5,000 reviews. The API only retains the last 7
|
||||
# days, so this is far above any realistic volume and just prevents runaway loops.
|
||||
MAX_REVIEW_PAGES = 50
|
||||
# Each inbox is synced at most once per this window. The cron runs more frequently
|
||||
# so a missed window retries on the next tick.
|
||||
SYNC_INTERVAL = 1.hour
|
||||
|
||||
validates :app_id, presence: true, uniqueness: { scope: :account_id }
|
||||
|
||||
# Pull reviews on the agent's behalf the moment the inbox is wired up so they're not waiting
|
||||
# for the next 15-minute poll. Runs after the surrounding transaction commits so the
|
||||
# associated inbox is guaranteed to be visible.
|
||||
after_create_commit :enqueue_initial_review_fetch
|
||||
|
||||
def name
|
||||
'Google PlayStore'
|
||||
end
|
||||
|
||||
def sync_due?
|
||||
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
|
||||
end
|
||||
|
||||
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently.
|
||||
# Pages through tokenPagination.nextPageToken until the API stops returning a cursor.
|
||||
def fetch_reviews
|
||||
reviews = []
|
||||
page_token = nil
|
||||
|
||||
MAX_REVIEW_PAGES.times do
|
||||
parsed = fetch_reviews_page(page_token)
|
||||
reviews.concat(parsed['reviews'] || [])
|
||||
|
||||
page_token = parsed.dig('tokenPagination', 'nextPageToken')
|
||||
break if page_token.blank?
|
||||
end
|
||||
|
||||
reviews
|
||||
end
|
||||
|
||||
# Returns a stable source_id for the reply so the outgoing message can be marked as sent.
|
||||
# Google Play has no separate reply id, so we combine the review id with the developer comment's
|
||||
# lastEdited timestamp from the response.
|
||||
def reply_to_review(review_id, reply_text)
|
||||
response = HTTParty.post(
|
||||
"#{API_BASE_URL}/applications/#{app_id}/reviews/#{review_id}:reply",
|
||||
headers: authorization_headers.merge('Content-Type' => 'application/json'),
|
||||
body: { replyText: reply_text.to_s.truncate(MAX_REPLY_LENGTH) }.to_json
|
||||
)
|
||||
raise "Google Play reply failed (#{response.code}): #{response.body}" unless response.success?
|
||||
|
||||
last_edited = response.parsed_response.dig('result', 'lastEdited', 'seconds')
|
||||
"#{review_id}::reply::#{last_edited}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_reviews_page(page_token)
|
||||
query = { maxResults: REVIEWS_PAGE_SIZE }
|
||||
query[:token] = page_token if page_token.present?
|
||||
|
||||
response = HTTParty.get(
|
||||
"#{API_BASE_URL}/applications/#{app_id}/reviews",
|
||||
headers: authorization_headers,
|
||||
query: query
|
||||
)
|
||||
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
|
||||
def authorization_headers
|
||||
{ 'Authorization' => "Bearer #{access_token}" }
|
||||
end
|
||||
|
||||
# Channels are connected through the Google OAuth flow; tokens are refreshed on demand
|
||||
def access_token
|
||||
Google::RefreshOauthTokenService.new(channel: self).access_token
|
||||
end
|
||||
|
||||
def enqueue_initial_review_fetch
|
||||
::Inboxes::FetchGooglePlayReviewsJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -146,6 +150,10 @@ class Inbox < ApplicationRecord
|
||||
channel_type == 'Channel::Email'
|
||||
end
|
||||
|
||||
def google_play?
|
||||
channel_type == 'Channel::GooglePlay'
|
||||
end
|
||||
|
||||
def twilio?
|
||||
channel_type == 'Channel::TwilioSms'
|
||||
end
|
||||
|
||||
@@ -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,45 @@
|
||||
class AppStore::SendOnAppStoreService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::AppStore
|
||||
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?
|
||||
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 validate_feature_enabled!
|
||||
return if message.account.feature_enabled?(:channel_app_store)
|
||||
|
||||
raise 'App Store Reviews channel is not enabled for this account.'
|
||||
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
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ class Conversations::MessageWindowService
|
||||
|
||||
private
|
||||
|
||||
def messaging_window
|
||||
def messaging_window # rubocop:disable Metrics/CyclomaticComplexity
|
||||
case @conversation.inbox.channel_type
|
||||
when 'Channel::Api'
|
||||
api_messaging_window
|
||||
@@ -28,6 +28,10 @@ class Conversations::MessageWindowService
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
twilio_messaging_window
|
||||
when 'Channel::GooglePlay'
|
||||
# Google Play API only allows replying to reviews left within the last 7 days.
|
||||
# https://developers.google.com/android-publisher/reviews
|
||||
MESSAGING_WINDOW_7_DAYS
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Builds a contact, conversation and messages from a single Google Play review.
|
||||
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
|
||||
# their review, a fresh incoming message is appended to the same conversation.
|
||||
# Developer replies (whether posted via Chatwoot or directly in Play Console) are mirrored
|
||||
# as outgoing messages, with a source_id that matches what `SendOnGooglePlayService`
|
||||
# produces so Chatwoot-originated replies are deduped on the next poll.
|
||||
class GooglePlay::ReviewBuilder
|
||||
pattr_initialize [:review!, :channel!]
|
||||
|
||||
def perform
|
||||
return if user_comment.blank? || review_text.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact_inbox
|
||||
build_conversation
|
||||
build_user_message
|
||||
build_developer_message if developer_comment.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= channel.inbox
|
||||
end
|
||||
|
||||
def user_comment
|
||||
@user_comment ||= comments_with_key('userComment').first
|
||||
end
|
||||
|
||||
def developer_comment
|
||||
@developer_comment ||= comments_with_key('developerComment').first
|
||||
end
|
||||
|
||||
def comments_with_key(key)
|
||||
Array(review['comments']).filter_map { |comment| comment[key] }
|
||||
end
|
||||
|
||||
def review_id
|
||||
review['reviewId']
|
||||
end
|
||||
|
||||
def review_text
|
||||
@review_text ||= user_comment['text'].to_s.strip
|
||||
end
|
||||
|
||||
def star_rating
|
||||
@star_rating ||= user_comment['starRating'].to_i
|
||||
end
|
||||
|
||||
# A new lastModified timestamp (edited review) yields a new message in the same conversation
|
||||
def user_message_source_id
|
||||
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
# Must match the format `Channel::GooglePlay#reply_to_review` returns so replies sent through
|
||||
# Chatwoot are not duplicated when the review is re-fetched.
|
||||
def developer_message_source_id
|
||||
"#{review_id}::reply::#{developer_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: review_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: review['authorName'].presence || 'Google Play User',
|
||||
additional_attributes: { source_id: "google_play:#{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: 'google_play', app_id: channel.app_id }
|
||||
)
|
||||
end
|
||||
|
||||
def build_user_message
|
||||
return if @conversation.messages.exists?(source_id: user_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @conversation.contact,
|
||||
message_type: :incoming,
|
||||
source_id: user_message_source_id,
|
||||
content: message_content,
|
||||
content_attributes: review_metadata
|
||||
)
|
||||
end
|
||||
|
||||
def build_developer_message
|
||||
text = developer_comment['text'].to_s.strip
|
||||
return if text.blank?
|
||||
return if @conversation.messages.exists?(source_id: developer_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
source_id: developer_message_source_id,
|
||||
content: text,
|
||||
status: :sent
|
||||
)
|
||||
end
|
||||
|
||||
def message_content
|
||||
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
|
||||
[
|
||||
"#{stars} (#{star_rating}/5)",
|
||||
review_text,
|
||||
review_footer
|
||||
].compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
# A compact line of context shown beneath the review text — device, app version, OS.
|
||||
def review_footer
|
||||
parts = [device_display, app_version_display, os_version_display].compact_blank
|
||||
return nil if parts.empty?
|
||||
|
||||
parts.join(' • ')
|
||||
end
|
||||
|
||||
def device_display
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
metadata['productName'].presence || user_comment['device']
|
||||
end
|
||||
|
||||
def app_version_display
|
||||
version = user_comment['appVersionName']
|
||||
version.present? ? "v#{version}" : nil
|
||||
end
|
||||
|
||||
def os_version_display
|
||||
version = user_comment['androidOsVersion']
|
||||
version.present? ? "Android #{version}" : nil
|
||||
end
|
||||
|
||||
def review_metadata
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
{
|
||||
google_play: {
|
||||
star_rating: star_rating,
|
||||
app_version: user_comment['appVersionName'],
|
||||
device: device_display,
|
||||
manufacturer: metadata['manufacturer'],
|
||||
android_os_version: user_comment['androidOsVersion'],
|
||||
reviewer_language: user_comment['reviewerLanguage']
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class GooglePlay::SendOnGooglePlayService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::GooglePlay
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
source_id = channel.reply_to_review(message.conversation.contact_inbox.source_id, message.content)
|
||||
message.update!(source_id: source_id) if source_id.present?
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
message.update!(status: :failed, external_error: e.message.to_s.truncate(255))
|
||||
end
|
||||
end
|
||||
@@ -19,10 +19,12 @@ class MessageTemplates::HookExecutionService
|
||||
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
def should_send_out_of_office_message? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# Google Play replies don't support out-of-office auto-replies; business hours don't apply to review responses
|
||||
return false if inbox.google_play?
|
||||
# should not send for outbound messages
|
||||
return false unless message.incoming?
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
@@ -40,6 +42,8 @@ class MessageTemplates::HookExecutionService
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# Google Play replies are constrained — auto-greetings don't fit the one-shot review reply model
|
||||
return false if inbox.google_play?
|
||||
|
||||
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
|
||||
end
|
||||
|
||||
@@ -63,6 +63,20 @@ 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
|
||||
|
||||
## Google Play Attributes
|
||||
if resource.google_play?
|
||||
json.app_id resource.channel.try(:app_id)
|
||||
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)
|
||||
|
||||
+3
-3
@@ -108,10 +108,10 @@
|
||||
display_name: Custom Tools
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: message_reply_to
|
||||
display_name: Message Reply To
|
||||
- name: channel_app_store
|
||||
display_name: App Store Reviews Channel
|
||||
enabled: false
|
||||
deprecated: true
|
||||
chatwoot_internal: true
|
||||
- name: insert_article_in_reply
|
||||
display_name: Insert Article in Reply
|
||||
enabled: false
|
||||
|
||||
@@ -22,10 +22,12 @@ Rails.application.routes.draw do
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/tiktok', to: 'dashboard#index', as: 'app_new_tiktok_inbox'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/google_play', to: 'dashboard#index', as: 'app_new_google_play_inbox'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_tiktok_inbox_agents'
|
||||
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_google_play_inbox_agents'
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
||||
@@ -319,6 +321,10 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :google_play do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :instagram do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
@@ -643,6 +649,7 @@ Rails.application.routes.draw do
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'google_play/callback', to: 'google_play/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
get 'tiktok/callback', to: 'tiktok/callbacks#show'
|
||||
get 'notion/callback', to: 'notion/callbacks#show'
|
||||
|
||||
@@ -26,6 +26,18 @@ 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 every 15 minutes to fetch Google Play Store app reviews
|
||||
trigger_google_play_review_inboxes_job:
|
||||
cron: '*/15 * * * *'
|
||||
class: 'Inboxes::FetchGooglePlayReviewInboxesJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed daily at 2230 UTC
|
||||
# which is our lowest traffic time
|
||||
remove_stale_contact_inboxes_job.rb:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateChannelGooglePlay < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :channel_google_play do |t|
|
||||
t.bigint :account_id, null: false
|
||||
t.string :app_id, null: false
|
||||
t.jsonb :provider_config, null: false, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :channel_google_play, [:account_id, :app_id], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSyncedAtToChannelGooglePlay < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :channel_google_play, :last_synced_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
class RepurposeMessageReplyToFlagForChannelAppStore < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
# The message_reply_to flag (deprecated) has been renamed to channel_app_store.
|
||||
# Disable it on any accounts that had message_reply_to enabled so the repurposed
|
||||
# flag starts in its intended default-off state.
|
||||
Account.feature_channel_app_store.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:channel_app_store)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
+26
-1
@@ -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_22_080000) 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
|
||||
@@ -491,6 +506,16 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do
|
||||
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
|
||||
end
|
||||
|
||||
create_table "channel_google_play", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "app_id", null: false
|
||||
t.jsonb "provider_config", default: {}, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "last_synced_at"
|
||||
t.index ["account_id", "app_id"], name: "index_channel_google_play_on_account_id_and_app_id", unique: true
|
||||
end
|
||||
|
||||
create_table "channel_instagram", force: :cascade do |t|
|
||||
t.string "access_token", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_fingerprint :string
|
||||
# external_link :string not null
|
||||
# last_sync_attempted_at :datetime
|
||||
# last_sync_error_code :string
|
||||
# last_synced_at :datetime
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# contacts_count :integer default(0), not null
|
||||
# custom_attributes :jsonb
|
||||
# description :text
|
||||
# domain :string
|
||||
# last_activity_at :datetime
|
||||
# id :bigint not null, primary key
|
||||
# contacts_count :integer
|
||||
# description :text
|
||||
# domain :string
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Google Play Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:url) { "/api/v1/accounts/#{account.id}/google_play/authorization" }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/google_play/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post url
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post url, headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
# Stand-in OAuth credentials so the authorize URL is built.
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_ID', value: 'client-id-123', locked: false)
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_SECRET', value: 'client-secret', locked: false)
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'returns a redirect URL with the androidpublisher scope' do
|
||||
post url,
|
||||
params: { app_id: 'com.example.app', inbox_name: 'My App' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = response.parsed_body
|
||||
expect(body['success']).to be true
|
||||
expect(body['url']).to include('client_id=client-id-123')
|
||||
expect(body['url']).to include('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fandroidpublisher')
|
||||
expect(body['url']).to include('access_type=offline')
|
||||
expect(body['url']).to include('prompt=consent')
|
||||
expect(body['url']).to include('google_play%2Fcallback')
|
||||
end
|
||||
|
||||
it 'signs the state with the app_id and inbox_name so the callback can decode them' do
|
||||
post url,
|
||||
params: { app_id: 'com.example.app', inbox_name: 'My App' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
state = CGI.parse(URI(response.parsed_body['url']).query)['state'].first
|
||||
decoded = Rails.application.message_verifier('google_play_oauth').verify(state).with_indifferent_access
|
||||
|
||||
expect(decoded[:account_id]).to eq(account.id)
|
||||
expect(decoded[:app_id]).to eq('com.example.app')
|
||||
expect(decoded[:inbox_name]).to eq('My App')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -434,6 +434,44 @@ 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
|
||||
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)
|
||||
|
||||
account.enable_features!(:channel_app_store)
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'GooglePlay::CallbacksController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { SecureRandom.hex(10) }
|
||||
let(:state_payload) { { account_id: account.id, app_id: 'com.example.app', inbox_name: 'My App' } }
|
||||
let(:state) { Rails.application.message_verifier('google_play_oauth').generate(state_payload, expires_in: 15.minutes) }
|
||||
let(:token_response) do
|
||||
{ access_token: 'play-access-token', refresh_token: 'play-refresh-token', token_type: 'Bearer', expires_in: 3599 }
|
||||
end
|
||||
let(:token_url) { 'https://accounts.google.com/o/oauth2/token' }
|
||||
|
||||
before do
|
||||
configure_google_oauth('GOOGLE_OAUTH_CLIENT_ID', 'client-id-123')
|
||||
configure_google_oauth('GOOGLE_OAUTH_CLIENT_SECRET', 'client-secret')
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
describe 'GET /google_play/callback' do
|
||||
it 'creates the channel + inbox and redirects to the agent assignment page' do
|
||||
stub_request(:post, token_url)
|
||||
.to_return(status: 200, body: token_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect do
|
||||
get '/google_play/callback', params: { code: code, state: state }
|
||||
end.to change(Channel::GooglePlay, :count).by(1)
|
||||
.and change(Inbox, :count).by(1)
|
||||
|
||||
inbox = Inbox.last
|
||||
channel = inbox.channel
|
||||
expect(channel.app_id).to eq('com.example.app')
|
||||
expect(channel.provider_config).to include('access_token' => 'play-access-token', 'refresh_token' => 'play-refresh-token')
|
||||
expect(inbox.name).to eq('My App')
|
||||
expect(response).to redirect_to(app_google_play_inbox_agents_url(account_id: account.id, inbox_id: inbox.id))
|
||||
end
|
||||
|
||||
it 'redirects to the inbox setup page surfacing the error when Google returns an error param' do
|
||||
get '/google_play/callback', params: { error: 'access_denied', state: state }
|
||||
|
||||
expect(Channel::GooglePlay.count).to eq(0)
|
||||
expect(response).to redirect_to(app_new_google_play_inbox_url(account_id: account.id, error: 'access_denied'))
|
||||
end
|
||||
|
||||
it 'redirects to the inbox setup page with the error when token exchange fails' do
|
||||
stub_request(:post, token_url).to_return(status: 400, body: 'invalid')
|
||||
|
||||
get '/google_play/callback', params: { code: code, state: state }
|
||||
|
||||
expect(Channel::GooglePlay.count).to eq(0)
|
||||
expect(response.location).to include("/app/accounts/#{account.id}/settings/inboxes/new/google_play")
|
||||
expect(response.location).to include('error=')
|
||||
end
|
||||
|
||||
it 'falls back to / when the state cannot be decoded' do
|
||||
get '/google_play/callback', params: { code: code, state: 'tampered' }
|
||||
expect(response).to redirect_to('/')
|
||||
end
|
||||
end
|
||||
|
||||
def configure_google_oauth(name, value)
|
||||
config = InstallationConfig.find_or_initialize_by(name: name)
|
||||
config.update!(value: value, locked: false)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_app_store, class: 'Channel::AppStore' do
|
||||
account
|
||||
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
|
||||
bundle_id { 'com.example.app' }
|
||||
app_name { 'Example App' }
|
||||
issuer_id { SecureRandom.uuid }
|
||||
key_id { SecureRandom.alphanumeric(10).upcase }
|
||||
private_key do
|
||||
key = OpenSSL::PKey::EC.generate('prime256v1')
|
||||
key.to_pem
|
||||
end
|
||||
|
||||
to_create { |instance| instance.save!(validate: false) }
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_google_play, class: 'Channel::GooglePlay' do
|
||||
account
|
||||
sequence(:app_id) { |n| "com.example.app#{n}" }
|
||||
provider_config do
|
||||
{
|
||||
'access_token' => SecureRandom.hex(16),
|
||||
'refresh_token' => SecureRandom.hex(16),
|
||||
'expires_on' => 1.hour.from_now.utc.to_s
|
||||
}
|
||||
end
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:suspended_account) { create(:account, status: 'suspended') }
|
||||
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
|
||||
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
|
||||
account.enable_features!(:channel_app_store)
|
||||
suspended_account.enable_features!(:channel_app_store)
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'enqueues fetch jobs only for due channels on active accounts' do
|
||||
due_channel
|
||||
fresh_channel
|
||||
suspended_channel
|
||||
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'skips channels when the feature is disabled for the account' do
|
||||
account.disable_features!(:channel_app_store)
|
||||
due_channel
|
||||
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
|
||||
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
|
||||
let(:review_payload) { { 'review' => { 'id' => 'review-1' }, 'response' => nil } }
|
||||
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
|
||||
|
||||
before do
|
||||
channel.account.enable_features!(:channel_app_store)
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
|
||||
.with(channel)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'fetches reviews, builds messages, and updates the sync timestamp' do
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(review_builder).to have_received(:perform)
|
||||
expect(channel.reload.last_synced_at).to be_present
|
||||
end
|
||||
|
||||
it 'captures per-review errors and continues syncing' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
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
|
||||
channel.account.disable_features!(:channel_app_store)
|
||||
|
||||
expect(channel).not_to receive(:fetch_reviews)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchGooglePlayReviewInboxesJob do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let!(:due_channel) { create(:channel_google_play, last_synced_at: 2.hours.ago) }
|
||||
let!(:fresh_channel) { create(:channel_google_play, last_synced_at: 5.minutes.ago) }
|
||||
let!(:never_synced_channel) { create(:channel_google_play, last_synced_at: nil) }
|
||||
|
||||
before { clear_enqueued_jobs } # also clear the after_create_commit fetches from factory
|
||||
|
||||
it 'enqueues a fetch job for channels whose sync interval has elapsed' do
|
||||
described_class.perform_now
|
||||
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).to have_been_enqueued.with(due_channel).once
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).to have_been_enqueued.with(never_synced_channel).once
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).not_to have_been_enqueued.with(fresh_channel)
|
||||
end
|
||||
|
||||
it 'skips inboxes belonging to suspended accounts' do
|
||||
due_channel.account.update!(status: :suspended)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).not_to have_been_enqueued.with(due_channel)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchGooglePlayReviewsJob do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:review) do
|
||||
{
|
||||
'reviewId' => 'rev-1',
|
||||
'authorName' => 'Tester',
|
||||
'comments' => [{ 'userComment' => { 'text' => 'good', 'starRating' => 5, 'lastModified' => { 'seconds' => '1' } } }]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review])
|
||||
end
|
||||
|
||||
it 'invokes ReviewBuilder for each fetched review' do
|
||||
builder = instance_double(GooglePlay::ReviewBuilder, perform: true)
|
||||
allow(GooglePlay::ReviewBuilder).to receive(:new).with(review: review, channel: channel).and_return(builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(builder).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'stamps last_synced_at after a successful pass' do
|
||||
travel_to Time.zone.parse('2026-05-19 12:00') do
|
||||
described_class.perform_now(channel)
|
||||
expect(channel.reload.last_synced_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
it 'captures per-review errors without aborting the rest of the run' do
|
||||
allow(GooglePlay::ReviewBuilder).to receive(:new).and_raise(StandardError, 'boom')
|
||||
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect { described_class.perform_now(channel) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
|
||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
||||
end
|
||||
|
||||
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
|
||||
app_store_channel = create(:channel_app_store)
|
||||
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,6 +50,23 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'app store channel feature flag' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'stores app store channel feature state in feature flags' do
|
||||
expect(account.feature_enabled?(:channel_app_store)).to be false
|
||||
|
||||
account.enable_features!(:channel_app_store)
|
||||
|
||||
expect(account.reload.feature_enabled?(:channel_app_store)).to be true
|
||||
expect(account.enabled_features).to include('channel_app_store' => true)
|
||||
|
||||
account.disable_features!(:channel_app_store)
|
||||
|
||||
expect(account.reload.feature_enabled?(:channel_app_store)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'conversation unread counts feature flag' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::AppStore do
|
||||
describe 'validations' do
|
||||
it 'normalizes auth fields and stores app metadata from App Store Connect' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(
|
||||
:channel_app_store,
|
||||
account: create(:account),
|
||||
app_id: ' 123456789 ',
|
||||
issuer_id: ' issuer-id ',
|
||||
key_id: ' key-id ',
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
|
||||
app_name: nil,
|
||||
bundle_id: nil
|
||||
)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_return(
|
||||
{
|
||||
'attributes' => {
|
||||
'name' => 'Chatwoot iOS',
|
||||
'bundleId' => 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(channel).to be_valid
|
||||
expect(channel.app_id).to eq('123456789')
|
||||
expect(channel.issuer_id).to eq('issuer-id')
|
||||
expect(channel.key_id).to eq('key-id')
|
||||
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
|
||||
expect(channel.app_name).to eq('Chatwoot iOS')
|
||||
expect(channel.bundle_id).to eq('com.chatwoot.app')
|
||||
end
|
||||
|
||||
it 'adds an error when App Store Connect validation fails' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(:channel_app_store)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
|
||||
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:base]).to include('invalid credentials')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_due?' do
|
||||
it 'returns true when the channel has never synced' do
|
||||
channel = build(:channel_app_store, last_synced_at: nil)
|
||||
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when the last sync is within the sync interval' do
|
||||
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
|
||||
|
||||
expect(channel.sync_due?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,136 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::GooglePlay do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_google_play, account: account) }
|
||||
|
||||
describe 'validations' do
|
||||
it 'requires app_id' do
|
||||
record = described_class.new(account: account, provider_config: { 'access_token' => 'x' })
|
||||
expect(record).not_to be_valid
|
||||
expect(record.errors[:app_id]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires app_id to be unique per account' do
|
||||
create(:channel_google_play, account: account, app_id: 'com.dup.app')
|
||||
duplicate = build(:channel_google_play, account: account, app_id: 'com.dup.app')
|
||||
expect(duplicate).not_to be_valid
|
||||
end
|
||||
|
||||
it 'allows the same app_id across different accounts' do
|
||||
create(:channel_google_play, account: account, app_id: 'com.shared.app')
|
||||
other = build(:channel_google_play, account: create(:account), app_id: 'com.shared.app')
|
||||
expect(other).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the human-readable channel name' do
|
||||
expect(channel.name).to eq 'Google PlayStore'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_due?' do
|
||||
it 'is true when last_synced_at is nil' do
|
||||
channel.update!(last_synced_at: nil)
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'is true when last_synced_at is older than the sync interval' do
|
||||
channel.update!(last_synced_at: 2.hours.ago)
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'is false when last_synced_at is within the sync interval' do
|
||||
channel.update!(last_synced_at: 10.minutes.ago)
|
||||
expect(channel.sync_due?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_reviews' do
|
||||
let(:base_url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews" }
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:access_token).and_return('test-token')
|
||||
end
|
||||
|
||||
it 'returns the reviews list for a single page' do
|
||||
stub_request(:get, base_url)
|
||||
.with(query: hash_including('maxResults' => '100'))
|
||||
.to_return(status: 200, body: { reviews: [{ 'reviewId' => 'r-1' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(channel.fetch_reviews).to eq([{ 'reviewId' => 'r-1' }])
|
||||
end
|
||||
|
||||
it 'follows tokenPagination.nextPageToken across pages' do
|
||||
stub_request(:get, base_url)
|
||||
.with(query: hash_including('maxResults' => '100'))
|
||||
.to_return(
|
||||
{ status: 200,
|
||||
body: { reviews: [{ 'reviewId' => 'r-1' }], tokenPagination: { nextPageToken: 'PAGE2' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' } },
|
||||
{ status: 200,
|
||||
body: { reviews: [{ 'reviewId' => 'r-2' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' } }
|
||||
)
|
||||
|
||||
expect(channel.fetch_reviews.map { |r| r['reviewId'] }).to eq(%w[r-1 r-2])
|
||||
end
|
||||
|
||||
it 'raises when the API responds with an error' do
|
||||
stub_request(:get, base_url)
|
||||
.with(query: hash_including('maxResults' => '100'))
|
||||
.to_return(status: 403, body: 'forbidden')
|
||||
|
||||
expect { channel.fetch_reviews }.to raise_error(/Google Play reviews fetch failed \(403\)/)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reply_to_review' do
|
||||
let(:url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews/REV-1:reply" }
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:access_token).and_return('test-token')
|
||||
end
|
||||
|
||||
it 'returns a source_id composed from the review id and lastEdited.seconds' do
|
||||
stub_request(:post, url).to_return(
|
||||
status: 200,
|
||||
body: { result: { replyText: 'thanks', lastEdited: { seconds: '1779000000', nanos: 0 } } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(channel.reply_to_review('REV-1', 'thanks')).to eq('REV-1::reply::1779000000')
|
||||
end
|
||||
|
||||
it 'truncates the reply text to MAX_REPLY_LENGTH' do
|
||||
stub_request(:post, url).to_return(status: 200,
|
||||
body: { result: { lastEdited: { seconds: '1' } } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
channel.reply_to_review('REV-1', 'a' * 500)
|
||||
|
||||
expect(WebMock).to(have_requested(:post, url).with do |req|
|
||||
JSON.parse(req.body)['replyText'].length <= described_class::MAX_REPLY_LENGTH
|
||||
end)
|
||||
end
|
||||
|
||||
it 'raises when the API responds with an error' do
|
||||
stub_request(:post, url).to_return(status: 400, body: 'bad request')
|
||||
|
||||
expect { channel.reply_to_review('REV-1', 'x') }
|
||||
.to raise_error(/Google Play reply failed \(400\)/)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'after_create_commit' do
|
||||
it 'enqueues an initial review fetch' do
|
||||
expect do
|
||||
create(:channel_google_play, account: account)
|
||||
end.to have_enqueued_job(Inboxes::FetchGooglePlayReviewsJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::ReviewBuilder do
|
||||
let(:channel) { create(:channel_app_store) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:review_payload) do
|
||||
{
|
||||
'review' => {
|
||||
'id' => 'review-1',
|
||||
'attributes' => {
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'body' => 'Works well for support.',
|
||||
'territory' => 'US',
|
||||
'reviewerNickname' => 'Reviewer',
|
||||
'createdDate' => '2026-05-20T10:00:00-00:00'
|
||||
},
|
||||
'relationships' => {
|
||||
'response' => {
|
||||
'data' => {
|
||||
'id' => 'response-1',
|
||||
'type' => 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'response' => {
|
||||
'id' => 'response-1',
|
||||
'attributes' => {
|
||||
'responseBody' => 'Thanks for the feedback.',
|
||||
'state' => 'PUBLISHED',
|
||||
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates a conversation with an incoming review and outgoing developer response' do
|
||||
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
|
||||
.to change(inbox.conversations, :count).by(1)
|
||||
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
|
||||
|
||||
conversation = inbox.conversations.last
|
||||
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
|
||||
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
|
||||
|
||||
expect(conversation.contact_inbox.source_id).to eq('review-1')
|
||||
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
|
||||
expect(review_message.content_attributes['app_store']).to include(
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'territory' => 'US',
|
||||
'reviewer_nickname' => 'Reviewer'
|
||||
)
|
||||
expect(response_message.content).to eq('Thanks for the feedback.')
|
||||
expect(response_message.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates an existing review message when Apple returns the same review again' do
|
||||
described_class.new(review_payload: review_payload, channel: channel).perform
|
||||
updated_payload = review_payload.deep_dup
|
||||
updated_payload['review']['attributes']['body'] = 'Updated review body.'
|
||||
|
||||
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
|
||||
.not_to change(Message.where(inbox_id: inbox.id), :count)
|
||||
|
||||
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::SendOnAppStoreService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_app_store, account: account) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: inbox.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
|
||||
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
|
||||
before do
|
||||
account.enable_features!(:channel_app_store)
|
||||
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates an App Store response for a new reply' do
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks', response_id: nil)
|
||||
expect(message.reload.source_id).to eq('response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'updates the existing App Store response when the conversation already has one' do
|
||||
create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Old reply',
|
||||
source_id: 'response-1')
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply', response_id: 'response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'marks the message as failed when attachments are present' do
|
||||
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||
message,
|
||||
'failed',
|
||||
'Sending attachments is not supported for App Store reviews.'
|
||||
)
|
||||
expect(exception_tracker).to have_received(:capture_exception)
|
||||
end
|
||||
|
||||
it 'marks the message as failed when the feature is disabled' do
|
||||
account.disable_features!(:channel_app_store)
|
||||
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
|
||||
@@ -0,0 +1,152 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::Client do
|
||||
let(:channel) { create(:channel_app_store, app_id: '123456789') }
|
||||
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
|
||||
|
||||
before do
|
||||
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
|
||||
end
|
||||
|
||||
describe '#fetch_app' do
|
||||
it 'fetches the configured app' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
id: '123456789',
|
||||
attributes: {
|
||||
name: 'Chatwoot',
|
||||
bundleId: 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_reviews' do
|
||||
it 'fetches reviews and attaches the included developer response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 'review-1',
|
||||
type: 'customerReviews',
|
||||
relationships: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
included: [
|
||||
{
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks for the review'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
review_payload = described_class.new(channel: channel).fetch_reviews.first
|
||||
|
||||
expect(review_payload['review']['id']).to eq('review-1')
|
||||
expect(review_payload['response']['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_review_response' do
|
||||
it 'creates a response for a review' do
|
||||
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks'
|
||||
},
|
||||
relationships: {
|
||||
review: {
|
||||
data: {
|
||||
type: 'customerReviews',
|
||||
id: 'review-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 201,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_review_response' do
|
||||
it 'updates an existing response' do
|
||||
stub_request(:patch, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses/response-1')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
id: 'response-1',
|
||||
attributes: {
|
||||
responseBody: 'Updated response'
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).update_review_response('response-1', 'Updated response')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises a useful error when Apple returns an error response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.to_return(
|
||||
status: 401,
|
||||
body: {
|
||||
errors: [
|
||||
{
|
||||
detail: 'Provide a properly configured and signed bearer token.'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect { described_class.new(channel: channel).fetch_app }
|
||||
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::TokenService do
|
||||
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
|
||||
let(:channel) do
|
||||
instance_double(
|
||||
Channel::AppStore,
|
||||
id: 1,
|
||||
updated_at: Time.zone.at(1_700_000_000),
|
||||
issuer_id: 'issuer-id',
|
||||
key_id: 'key-id',
|
||||
private_key: private_key
|
||||
)
|
||||
end
|
||||
|
||||
describe '#token' do
|
||||
it 'generates an App Store Connect JWT with the expected claims and headers' do
|
||||
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
|
||||
token = described_class.new(channel: channel).token
|
||||
payload, header = JWT.decode(token, nil, false)
|
||||
|
||||
expect(payload).to include(
|
||||
'iss' => 'issuer-id',
|
||||
'iat' => Time.current.to_i,
|
||||
'exp' => 19.minutes.from_now.to_i,
|
||||
'aud' => 'appstoreconnect-v1'
|
||||
)
|
||||
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns cached tokens for the channel' do
|
||||
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
|
||||
|
||||
expect(described_class.new(channel: channel).token).to eq('cached-token')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -56,6 +56,7 @@ RSpec.describe Conversations::MessageWindowService do
|
||||
describe 'on Facebook channels' do
|
||||
before do
|
||||
stub_request(:post, /graph.facebook.com/)
|
||||
InstallationConfig.where(name: 'ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
@@ -199,6 +200,11 @@ RSpec.describe Conversations::MessageWindowService do
|
||||
end
|
||||
|
||||
describe 'on Instagram channels' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
let!(:instagram_channel) { create(:channel_instagram) }
|
||||
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: instagram_channel.account) }
|
||||
let!(:conversation) { create(:conversation, inbox: instagram_inbox, account: instagram_channel.account) }
|
||||
@@ -625,4 +631,22 @@ RSpec.describe Conversations::MessageWindowService do
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Google Play channels' do
|
||||
let!(:google_play_channel) { create(:channel_google_play) }
|
||||
let(:inbox) { google_play_channel.inbox }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: google_play_channel.account) }
|
||||
|
||||
it 'allows replies when the most recent incoming review is within 7 days' do
|
||||
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 3.days.ago)
|
||||
|
||||
expect(described_class.new(conversation).can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'blocks replies when the most recent incoming review is older than 7 days' do
|
||||
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 10.days.ago)
|
||||
|
||||
expect(described_class.new(conversation).can_reply?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GooglePlay::ReviewBuilder do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
|
||||
def base_review(overrides = {})
|
||||
{
|
||||
'reviewId' => 'rev-abc',
|
||||
'authorName' => 'Jane Reviewer',
|
||||
'comments' => [
|
||||
{ 'userComment' => {
|
||||
'text' => 'Great app, but crashes sometimes.',
|
||||
'starRating' => 4,
|
||||
'reviewerLanguage' => 'en',
|
||||
'appVersionName' => '4.5.0',
|
||||
'androidOsVersion' => 33,
|
||||
'device' => 'a15',
|
||||
'deviceMetadata' => { 'productName' => 'Galaxy A15', 'manufacturer' => 'Samsung' },
|
||||
'lastModified' => { 'seconds' => '1779000000', 'nanos' => 0 }
|
||||
} }
|
||||
]
|
||||
}.deep_merge(overrides)
|
||||
end
|
||||
|
||||
it 'creates a contact, conversation, and incoming message from userComment' do
|
||||
expect do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
end.to change(Contact, :count).by(1)
|
||||
.and change(Conversation, :count).by(1)
|
||||
.and change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.message_type).to eq 'incoming'
|
||||
expect(message.source_id).to eq 'rev-abc::1779000000'
|
||||
expect(message.content).to include('★★★★☆ (4/5)')
|
||||
expect(message.content).to include('Great app, but crashes sometimes.')
|
||||
expect(message.content).to include('Galaxy A15 • v4.5.0 • Android 33')
|
||||
expect(message.content_attributes['google_play']).to include(
|
||||
'star_rating' => 4,
|
||||
'app_version' => '4.5.0',
|
||||
'manufacturer' => 'Samsung',
|
||||
'reviewer_language' => 'en'
|
||||
)
|
||||
end
|
||||
|
||||
it 'is idempotent for the same userComment lastModified seconds' do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
|
||||
expect do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'creates a new incoming message when the reviewer edits (new lastModified)' do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
|
||||
edited = base_review.deep_dup
|
||||
edited['comments'].first['userComment']['lastModified']['seconds'] = '1779999999'
|
||||
edited['comments'].first['userComment']['text'] = 'Edited review'
|
||||
|
||||
expect do
|
||||
described_class.new(review: edited, channel: channel).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
.and(not_change(Conversation, :count))
|
||||
end
|
||||
|
||||
it 'skips entirely when the user comment text is blank' do
|
||||
review = base_review
|
||||
review['comments'].first['userComment']['text'] = ''
|
||||
|
||||
expect do
|
||||
described_class.new(review: review, channel: channel).perform
|
||||
end.to(not_change(Message, :count))
|
||||
end
|
||||
|
||||
context 'when the review also has a developerComment' do
|
||||
let(:review_with_reply) do
|
||||
base_review('comments' => [
|
||||
{ 'userComment' => base_review['comments'].first['userComment'] },
|
||||
{ 'developerComment' => {
|
||||
'text' => 'Thanks for the feedback!',
|
||||
'lastModified' => { 'seconds' => '1779100000' }
|
||||
} }
|
||||
])
|
||||
end
|
||||
|
||||
it 'mirrors the developer comment as an outgoing message' do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
|
||||
outgoing = Message.outgoing.last
|
||||
expect(outgoing.content).to eq('Thanks for the feedback!')
|
||||
expect(outgoing.source_id).to eq('rev-abc::reply::1779100000')
|
||||
expect(outgoing.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'is idempotent and does not duplicate the outgoing reply' do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
|
||||
expect do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'dedupes against a reply already sent through Chatwoot with the same source_id' do
|
||||
conversation = create(:conversation, inbox: inbox, account: channel.account,
|
||||
contact_inbox: create(:contact_inbox, inbox: inbox, source_id: 'rev-abc'))
|
||||
create(:message, conversation: conversation, account: channel.account, inbox: inbox,
|
||||
message_type: :outgoing, source_id: 'rev-abc::reply::1779100000', content: 'sent via chatwoot')
|
||||
|
||||
expect do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
end.to change(Message, :count).by(1) # the incoming user message only
|
||||
expect(Message.outgoing.where(source_id: 'rev-abc::reply::1779100000').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
# RSpec helper for `not_change` (no built-in opposite of `change`)
|
||||
matcher :not_change do |obj, method|
|
||||
supports_block_expectations
|
||||
match do |block|
|
||||
before = obj.send(method)
|
||||
block.call
|
||||
obj.send(method) == before
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GooglePlay::SendOnGooglePlayService do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: channel.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: 'REV-1') }
|
||||
let(:conversation) { create(:conversation, contact: contact, contact_inbox: contact_inbox, inbox: inbox, account: channel.account) }
|
||||
let(:message) { create(:message, message_type: :outgoing, content: 'thanks', conversation: conversation, inbox: inbox, account: channel.account) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'stamps the outgoing message with the source_id returned from the API' do
|
||||
allow(channel).to receive(:reply_to_review).with('REV-1', 'thanks').and_return('REV-1::reply::42')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.source_id).to eq('REV-1::reply::42')
|
||||
expect(message.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'marks the message as failed when the API call raises' do
|
||||
allow(channel).to receive(:reply_to_review).and_raise(StandardError, 'Google Play reply failed (403)')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Google Play reply failed (403)')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -270,4 +270,28 @@ describe MessageTemplates::HookExecutionService do
|
||||
expect(out_of_office_service).not_to receive(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox is a Google Play Reviews channel' do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: channel.account) }
|
||||
|
||||
it 'does not fire the greeting template even when greeting_enabled is true' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Thanks for reviewing!')
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does not fire the out-of-office template even when configured' do
|
||||
inbox.update!(working_hours_enabled: true, out_of_office_message: 'Back tomorrow')
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new)
|
||||
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user