From 13a9f2591bc336f69e17dc51882652c1c3f4d05f Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 19 May 2026 15:32:49 -0700 Subject: [PATCH 1/6] feat: Google Play Store Reviews --- .../google_play/authorizations_controller.rb | 34 ++++++ .../api/v1/accounts/inboxes_controller.rb | 5 +- .../concerns/google_play_oauth_concern.rb | 26 +++++ .../google_play/callbacks_controller.rb | 57 +++++++++ app/helpers/api/v1/inboxes_helper.rb | 3 +- .../dashboard/api/channel/googlePlayClient.js | 14 +++ .../components-next/icon/provider.js | 1 + .../components-next/message/MessageMeta.vue | 4 +- .../components/widgets/ChannelItem.vue | 1 + .../widgets/conversation/ReplyBox.vue | 3 + .../dashboard/composables/useInbox.js | 5 + app/javascript/dashboard/constants/editor.js | 6 + app/javascript/dashboard/helper/inbox.js | 6 + .../dashboard/i18n/locale/en/inboxMgmt.json | 23 ++++ .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 6 + .../settings/inbox/channels/GooglePlay.vue | 109 ++++++++++++++++++ .../settings/inbox/components/ChannelName.vue | 1 + .../shared/helpers/MessageTypeHelper.js | 2 + app/javascript/shared/mixins/inboxMixin.js | 3 + .../fetch_google_play_review_inboxes_job.rb | 11 ++ .../inboxes/fetch_google_play_reviews_job.rb | 13 +++ app/jobs/send_reply_job.rb | 1 + app/models/account.rb | 1 + app/models/channel/google_play.rb | 70 +++++++++++ app/models/inbox.rb | 4 + .../conversations/message_window_service.rb | 6 +- app/services/google_play/review_builder.rb | 94 +++++++++++++++ .../send_on_google_play_service.rb | 15 +++ config/routes.rb | 7 ++ config/schedule.yml | 6 + ...260519000000_create_channel_google_play.rb | 13 +++ db/schema.rb | 90 +++++++++------ 33 files changed, 603 insertions(+), 39 deletions(-) create mode 100644 app/controllers/api/v1/accounts/google_play/authorizations_controller.rb create mode 100644 app/controllers/concerns/google_play_oauth_concern.rb create mode 100644 app/controllers/google_play/callbacks_controller.rb create mode 100644 app/javascript/dashboard/api/channel/googlePlayClient.js create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/GooglePlay.vue create mode 100644 app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb create mode 100644 app/jobs/inboxes/fetch_google_play_reviews_job.rb create mode 100644 app/models/channel/google_play.rb create mode 100644 app/services/google_play/review_builder.rb create mode 100644 app/services/google_play/send_on_google_play_service.rb create mode 100644 db/migrate/20260519000000_create_channel_google_play.rb diff --git a/app/controllers/api/v1/accounts/google_play/authorizations_controller.rb b/app/controllers/api/v1/accounts/google_play/authorizations_controller.rb new file mode 100644 index 000000000..f9588d5bb --- /dev/null +++ b/app/controllers/api/v1/accounts/google_play/authorizations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 757af9b62..6dd9c5ac9 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -97,7 +97,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def allowed_channel_types - %w[web_widget api email line telegram whatsapp sms] + %w[web_widget api email line telegram whatsapp sms google_play] end def update_inbox_working_hours @@ -179,7 +179,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController 'line' => Channel::Line, 'telegram' => Channel::Telegram, 'whatsapp' => Channel::Whatsapp, - 'sms' => Channel::Sms + 'sms' => Channel::Sms, + 'google_play' => Channel::GooglePlay }[permitted_params[:channel][:type]] end diff --git a/app/controllers/concerns/google_play_oauth_concern.rb b/app/controllers/concerns/google_play_oauth_concern.rb new file mode 100644 index 000000000..9562a23ec --- /dev/null +++ b/app/controllers/concerns/google_play_oauth_concern.rb @@ -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 diff --git a/app/controllers/google_play/callbacks_controller.rb b/app/controllers/google_play/callbacks_controller.rb new file mode 100644 index 000000000..83a28355d --- /dev/null +++ b/app/controllers/google_play/callbacks_controller.rb @@ -0,0 +1,57 @@ +class GooglePlay::CallbacksController < ApplicationController + include GooglePlayOauthConcern + + def show + return redirect_with_error(params[:error]) if params[:error].present? + + 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 diff --git a/app/helpers/api/v1/inboxes_helper.rb b/app/helpers/api/v1/inboxes_helper.rb index 8a10fa99c..d404c7981 100644 --- a/app/helpers/api/v1/inboxes_helper.rb +++ b/app/helpers/api/v1/inboxes_helper.rb @@ -111,7 +111,8 @@ module Api::V1::InboxesHelper 'line' => Current.account.line_channels, 'telegram' => Current.account.telegram_channels, 'whatsapp' => Current.account.whatsapp_channels, - 'sms' => Current.account.sms_channels + 'sms' => Current.account.sms_channels, + 'google_play' => Current.account.google_play_channels }[permitted_params[:channel][:type]] end diff --git a/app/javascript/dashboard/api/channel/googlePlayClient.js b/app/javascript/dashboard/api/channel/googlePlayClient.js new file mode 100644 index 000000000..7bf3b2501 --- /dev/null +++ b/app/javascript/dashboard/api/channel/googlePlayClient.js @@ -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(); diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index d7a9c93ad..d47d600d7 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -15,6 +15,7 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', + 'Channel::GooglePlay': 'i-ri-google-play-line', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index f26339cbb..71061093d 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -21,6 +21,7 @@ const { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, + isAGooglePlayChannel, } = useInbox(); const { @@ -62,7 +63,8 @@ const isSent = computed(() => { isASmsInbox.value || isATelegramChannel.value || isAnInstagramChannel.value || - isATiktokChannel.value + isATiktokChannel.value || + isAGooglePlayChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 2cbad72c5..fbce735e8 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -67,6 +67,7 @@ const isActive = computed(() => { 'instagram', 'tiktok', 'voice', + 'google_play', ].includes(key); }); diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 75410a3c4..ee53a0858 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -249,6 +249,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; } diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 6e8d0a52a..f3d6b7d18 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -138,6 +138,10 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); + const isAGooglePlayChannel = computed(() => { + return channelType.value === INBOX_TYPES.GOOGLE_PLAY; + }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); @@ -160,6 +164,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, + isAGooglePlayChannel, voiceCallEnabled, voiceCallProvider, }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b4..5528a9b43 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -114,6 +114,12 @@ export const FORMATTING = { nodes: [], menu: [], }, + 'Channel::GooglePlay': { + // Google Play developer replies are plain text only — no formatting is supported. + marks: [], + nodes: [], + menu: [], + }, // Special contexts (not actual channels) 'Context::PrivateNote': { marks: ['strong', 'em', 'code', 'link', 'strike'], diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 4039a07d1..abf7ba2ed 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,6 +11,7 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', + GOOGLE_PLAY: 'Channel::GooglePlay', }; // Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) @@ -50,6 +51,7 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', + [INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -65,6 +67,7 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', + [INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-line', }; const DEFAULT_ICON_LINE = 'i-ri-chat-1-line'; @@ -114,6 +117,9 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; + case INBOX_TYPES.GOOGLE_PLAY: + return 'google_play'; + default: return 'chat'; } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 2620f89f7..adfe5e6c1 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -447,6 +447,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.", @@ -496,6 +514,10 @@ "VOICE": { "TITLE": "Voice", "DESCRIPTION": "Integrate with Twilio Voice" + }, + "GOOGLE_PLAY": { + "TITLE": "Google Play Reviews", + "DESCRIPTION": "Manage your Google Play Store app reviews" } } }, @@ -1165,6 +1187,7 @@ "API": "API Channel", "INSTAGRAM": "Instagram", "TIKTOK": "TikTok", + "GOOGLE_PLAY": "Google Play Reviews", "VOICE": "Voice" } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index 7d1d58854..0742a42c7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -12,6 +12,7 @@ import Telegram from './channels/Telegram.vue'; import Instagram from './channels/Instagram.vue'; import Tiktok from './channels/Tiktok.vue'; import Voice from './channels/Voice.vue'; +import GooglePlay from './channels/GooglePlay.vue'; const channelViewList = { facebook: Facebook, @@ -26,6 +27,7 @@ const channelViewList = { instagram: Instagram, tiktok: Tiktok, voice: Voice, + google_play: GooglePlay, }; export default defineComponent({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index e2ebd27cd..f162d9cac 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -77,6 +77,12 @@ const channelList = computed(() => { description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'), icon: 'i-woot-instagram', }, + { + 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) { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/GooglePlay.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/GooglePlay.vue new file mode 100644 index 000000000..031357e65 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/GooglePlay.vue @@ -0,0 +1,109 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 711217f32..c844108da 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -34,6 +34,7 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', + 'Channel::GooglePlay': 'GOOGLE_PLAY', }; const twilioChannelName = () => { diff --git a/app/javascript/shared/helpers/MessageTypeHelper.js b/app/javascript/shared/helpers/MessageTypeHelper.js index d2bc6f187..86e6d0c95 100644 --- a/app/javascript/shared/helpers/MessageTypeHelper.js +++ b/app/javascript/shared/helpers/MessageTypeHelper.js @@ -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, }; diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js index 40579125b..5cf7e0d4a 100644 --- a/app/javascript/shared/mixins/inboxMixin.js +++ b/app/javascript/shared/mixins/inboxMixin.js @@ -134,6 +134,9 @@ export default { isATiktokChannel() { return this.channelType === INBOX_TYPES.TIKTOK; }, + isAGooglePlayChannel() { + return this.channelType === INBOX_TYPES.GOOGLE_PLAY; + }, }, methods: { inboxHasFeature(feature) { diff --git a/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb b/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb new file mode 100644 index 000000000..cac7865bc --- /dev/null +++ b/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb @@ -0,0 +1,11 @@ +class Inboxes::FetchGooglePlayReviewInboxesJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + Inbox.where(channel_type: 'Channel::GooglePlay').find_each(batch_size: 100) do |inbox| + next if inbox.account.suspended? + + ::Inboxes::FetchGooglePlayReviewsJob.perform_later(inbox.channel) + end + end +end diff --git a/app/jobs/inboxes/fetch_google_play_reviews_job.rb b/app/jobs/inboxes/fetch_google_play_reviews_job.rb new file mode 100644 index 000000000..112fca528 --- /dev/null +++ b/app/jobs/inboxes/fetch_google_play_reviews_job.rb @@ -0,0 +1,13 @@ +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 + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end +end diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index e892c189a..6eab8d4c5 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -11,6 +11,7 @@ class SendReplyJob < ApplicationJob 'Channel::Instagram' => ::Instagram::SendOnInstagramService, 'Channel::Tiktok' => ::Tiktok::SendOnTiktokService, 'Channel::Email' => ::Email::SendOnEmailService, + 'Channel::GooglePlay' => ::GooglePlay::SendOnGooglePlayService, 'Channel::WebWidget' => ::Messages::SendEmailNotificationService, 'Channel::Api' => ::Messages::SendEmailNotificationService }.freeze diff --git a/app/models/account.rb b/app/models/account.rb index b4cc03337..074aa9311 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -77,6 +77,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' diff --git a/app/models/channel/google_play.rb b/app/models/channel/google_play.rb new file mode 100644 index 000000000..b32545afc --- /dev/null +++ b/app/models/channel/google_play.rb @@ -0,0 +1,70 @@ +# == Schema Information +# +# Table name: channel_google_play +# +# id :bigint not null, primary key +# app_id :string not null +# provider_config :jsonb not null +# 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 + + validates :app_id, presence: true, uniqueness: { scope: :account_id } + + def name + 'Google Play' + end + + # Google Play only retains reviews from the last 7 days, hence the channel is polled frequently + def fetch_reviews + response = HTTParty.get( + "#{API_BASE_URL}/applications/#{app_id}/reviews", + headers: authorization_headers, + query: { maxResults: 100 } + ) + raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success? + + response.parsed_response['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 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 +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 82b250560..38514aa29 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -146,6 +146,10 @@ class Inbox < ApplicationRecord channel_type == 'Channel::Email' end + def google_play? + channel_type == 'Channel::GooglePlay' + end + def twilio? channel_type == 'Channel::TwilioSms' end diff --git a/app/services/conversations/message_window_service.rb b/app/services/conversations/message_window_service.rb index ee154125e..6b080f14a 100644 --- a/app/services/conversations/message_window_service.rb +++ b/app/services/conversations/message_window_service.rb @@ -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 diff --git a/app/services/google_play/review_builder.rb b/app/services/google_play/review_builder.rb new file mode 100644 index 000000000..c6e07e5ad --- /dev/null +++ b/app/services/google_play/review_builder.rb @@ -0,0 +1,94 @@ +# Builds a contact, conversation and incoming message 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. +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_message + end + end + + private + + def inbox + @inbox ||= channel.inbox + end + + def user_comment + @user_comment ||= Array(review['comments']).filter_map { |comment| comment['userComment'] }.first + 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 message_source_id + "#{review_id}::#{user_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_message + return if @conversation.messages.exists?(source_id: message_source_id) + + @conversation.messages.create!( + account_id: inbox.account_id, + inbox_id: inbox.id, + sender: @conversation.contact, + message_type: :incoming, + source_id: message_source_id, + content: message_content, + content_attributes: review_metadata + ) + end + + def message_content + stars = ('★' * star_rating) + ('☆' * (5 - star_rating)) + "#{stars} (#{star_rating}/5)\n\n#{review_text}" + end + + def review_metadata + { + google_play: { + star_rating: star_rating, + app_version: user_comment['appVersionName'], + device: user_comment['device'], + reviewer_language: user_comment['reviewerLanguage'] + } + } + end +end diff --git a/app/services/google_play/send_on_google_play_service.rb b/app/services/google_play/send_on_google_play_service.rb new file mode 100644 index 000000000..391785136 --- /dev/null +++ b/app/services/google_play/send_on_google_play_service.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 355491d5b..d3cb209b8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' @@ -318,6 +320,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 @@ -644,6 +650,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' diff --git a/config/schedule.yml b/config/schedule.yml index f5e335ba3..be69a9c44 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job: class: 'Inboxes::FetchImapEmailInboxesJob' 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: diff --git a/db/migrate/20260519000000_create_channel_google_play.rb b/db/migrate/20260519000000_create_channel_google_play.rb new file mode 100644 index 000000000..3bbcdcba2 --- /dev/null +++ b/db/migrate/20260519000000_create_channel_google_play.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 diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..117b4366a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_19_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -52,6 +52,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.boolean "auto_offline", default: true, null: false t.bigint "custom_role_id" t.bigint "agent_capacity_policy_id" + t.integer "status", default: 0, null: false t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true t.index ["account_id"], name: "index_account_users_on_account_id" t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id" @@ -71,6 +72,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.jsonb "limits", default: {} t.jsonb "custom_attributes", default: {} t.integer "status", default: 0 + t.integer "contactable_contacts_count", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} t.index ["status"], name: "index_accounts_on_status" @@ -473,8 +475,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.boolean "smtp_enable_ssl_tls", default: false t.jsonb "provider_config", default: {} t.string "provider" - t.string "imap_authentication", default: "plain" t.boolean "verified_for_sending", default: false, null: false + t.string "imap_authentication", default: "plain" t.index ["email"], name: "index_channel_email_on_email", unique: true t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true end @@ -491,6 +493,15 @@ 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.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 @@ -613,7 +624,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "contacts_count" + t.integer "contacts_count", default: 0, null: false t.jsonb "additional_attributes", default: {} t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil @@ -622,7 +633,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" end - create_table "contact_inboxes", force: :cascade do |t| + create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" t.text "source_id", null: false @@ -630,14 +641,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.datetime "updated_at", null: false t.boolean "hmac_verified", default: false t.string "pubsub_token" - t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" - t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true - t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" - t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true - t.index ["source_id"], name: "index_contact_inboxes_on_source_id" + t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx" + t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true + t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx" + t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true + t.index ["source_id"], name: "contact_inboxes2_source_id_idx" end - create_table "contacts", id: :serial, force: :cascade do |t| + create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t| t.string "name", default: "" t.string "email" t.string "phone_number" @@ -648,25 +659,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.string "identifier" t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil - t.integer "contact_type", default: 0 + t.boolean "resolved", default: false, null: false + t.integer "contact_type", default: 0, null: false t.string "middle_name", default: "" t.string "last_name", default: "" t.string "location", default: "" t.string "country_code", default: "" t.boolean "blocked", default: false, null: false t.bigint "company_id" - t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" + t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx" t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type" - t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" } - t.index ["account_id"], name: "index_contacts_on_account_id" - t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved" + t.index ["account_id"], name: "contacts2_account_id_idx" + t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["blocked"], name: "index_contacts_on_blocked" t.index ["company_id"], name: "index_contacts_on_company_id" - t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true - t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true - t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin - t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" + t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true + t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true + t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx" end create_table "conversation_participants", force: :cascade do |t| @@ -694,7 +707,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.datetime "agent_last_seen_at", precision: nil t.jsonb "additional_attributes", default: {} t.bigint "contact_inbox_id" - t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false t.string "identifier" t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" @@ -708,6 +721,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.datetime "waiting_since" t.text "cached_label_list" t.bigint "assignee_agent_bot_id" + t.bigint "last_message_id" + t.bigint "last_incoming_message_id" + t.bigint "last_non_activity_message_id" + t.text "cached_summary" + t.datetime "cached_summary_at" + t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -719,6 +738,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at" t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id" t.index ["inbox_id"], name: "index_conversations_on_inbox_id" + t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id" + t.index ["last_message_id"], name: "index_conversations_on_last_message_id" + t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id" t.index ["priority"], name: "index_conversations_on_priority" t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" @@ -785,7 +807,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.string "regex_pattern" t.string "regex_cue" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" - t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true end create_table "custom_filters", force: :cascade do |t| @@ -968,7 +989,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.integer "visibility", default: 0 t.bigint "created_by_id" t.bigint "updated_by_id" - t.jsonb "actions", default: {}, null: false + t.jsonb "actions", default: "{}", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_macros_on_account_id" @@ -987,7 +1008,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.index ["user_id"], name: "index_mentions_on_user_id" end - create_table "messages", id: :serial, force: :cascade do |t| + create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t| t.text "content" t.integer "account_id", null: false t.integer "inbox_id", null: false @@ -1006,18 +1027,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.jsonb "additional_attributes", default: {} t.text "processed_message_content" t.jsonb "sentiment", default: {} - t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin + t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created" t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type" - t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id" - t.index ["account_id"], name: "index_messages_on_account_id" - t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin - t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created" - t.index ["conversation_id"], name: "index_messages_on_conversation_id" - t.index ["created_at"], name: "index_messages_on_created_at" - t.index ["inbox_id"], name: "index_messages_on_inbox_id" - t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id" - t.index ["source_id"], name: "index_messages_on_source_id" + t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx" + t.index ["account_id"], name: "messages2_account_id_idx" + t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx" + t.index ["conversation_id"], name: "messages2_conversation_id_idx" + t.index ["created_at"], name: "messages2_created_at_idx" + t.index ["inbox_id"], name: "messages2_inbox_id_idx" + t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx" + t.index ["source_id"], name: "messages2_source_id_idx" end create_table "notes", force: :cascade do |t| @@ -1149,6 +1170,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.float "value_in_business_hours" t.datetime "event_start_time", precision: nil t.datetime "event_end_time", precision: nil + t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox" t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution" t.index ["account_id"], name: "index_reporting_events_on_account_id" @@ -1282,7 +1304,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do t.text "message_signature" t.string "otp_secret" t.integer "consumed_timestep" - t.boolean "otp_required_for_login", default: false + t.boolean "otp_required_for_login", default: false, null: false t.text "otp_backup_codes" t.index ["email"], name: "index_users_on_email" t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" From 3509a98eb29a44eca0fd5c8ad96ed356ef71d41c Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 19 May 2026 17:23:00 -0700 Subject: [PATCH 2/6] revert schema.rb --- db/schema.rb | 90 ++++++++++++++++++++-------------------------------- 1 file changed, 34 insertions(+), 56 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 117b4366a..9d9fe3cbc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_19_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_05_15_000000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -52,7 +52,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.boolean "auto_offline", default: true, null: false t.bigint "custom_role_id" t.bigint "agent_capacity_policy_id" - t.integer "status", default: 0, null: false t.index ["account_id", "user_id"], name: "uniq_user_id_per_account_id", unique: true t.index ["account_id"], name: "index_account_users_on_account_id" t.index ["agent_capacity_policy_id"], name: "index_account_users_on_agent_capacity_policy_id" @@ -72,7 +71,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.jsonb "limits", default: {} t.jsonb "custom_attributes", default: {} t.integer "status", default: 0 - t.integer "contactable_contacts_count", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} t.index ["status"], name: "index_accounts_on_status" @@ -475,8 +473,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.boolean "smtp_enable_ssl_tls", default: false t.jsonb "provider_config", default: {} t.string "provider" - t.boolean "verified_for_sending", default: false, null: false t.string "imap_authentication", default: "plain" + t.boolean "verified_for_sending", default: false, null: false t.index ["email"], name: "index_channel_email_on_email", unique: true t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true end @@ -493,15 +491,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_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.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 @@ -624,7 +613,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "contacts_count", default: 0, null: false + t.integer "contacts_count" t.jsonb "additional_attributes", default: {} t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil @@ -633,7 +622,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" end - create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t| + create_table "contact_inboxes", force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" t.text "source_id", null: false @@ -641,14 +630,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.datetime "updated_at", null: false t.boolean "hmac_verified", default: false t.string "pubsub_token" - t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx" - t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true - t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx" - t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true - t.index ["source_id"], name: "contact_inboxes2_source_id_idx" + t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" + t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true + t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" + t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true + t.index ["source_id"], name: "index_contact_inboxes_on_source_id" end - create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t| + create_table "contacts", id: :serial, force: :cascade do |t| t.string "name", default: "" t.string "email" t.string "phone_number" @@ -659,27 +648,25 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.string "identifier" t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil - t.boolean "resolved", default: false, null: false - t.integer "contact_type", default: 0, null: false + t.integer "contact_type", default: 0 t.string "middle_name", default: "" t.string "last_name", default: "" t.string "location", default: "" t.string "country_code", default: "" t.boolean "blocked", default: false, null: false t.bigint "company_id" - t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx" + t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type" - t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" } - t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved" - t.index ["account_id"], name: "contacts2_account_id_idx" - t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id"], name: "index_contacts_on_account_id" + t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["blocked"], name: "index_contacts_on_blocked" t.index ["company_id"], name: "index_contacts_on_company_id" - t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true - t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true - t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin - t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx" + t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true + t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true + t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin + t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" end create_table "conversation_participants", force: :cascade do |t| @@ -707,7 +694,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.datetime "agent_last_seen_at", precision: nil t.jsonb "additional_attributes", default: {} t.bigint "contact_inbox_id" - t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false + t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false t.string "identifier" t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" @@ -721,12 +708,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.datetime "waiting_since" t.text "cached_label_list" t.bigint "assignee_agent_bot_id" - t.bigint "last_message_id" - t.bigint "last_incoming_message_id" - t.bigint "last_non_activity_message_id" - t.text "cached_summary" - t.datetime "cached_summary_at" - t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -738,9 +719,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at" t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id" t.index ["inbox_id"], name: "index_conversations_on_inbox_id" - t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id" - t.index ["last_message_id"], name: "index_conversations_on_last_message_id" - t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id" t.index ["priority"], name: "index_conversations_on_priority" t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" @@ -807,6 +785,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.string "regex_pattern" t.string "regex_cue" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" + t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true end create_table "custom_filters", force: :cascade do |t| @@ -989,7 +968,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.integer "visibility", default: 0 t.bigint "created_by_id" t.bigint "updated_by_id" - t.jsonb "actions", default: "{}", null: false + t.jsonb "actions", default: {}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_macros_on_account_id" @@ -1008,7 +987,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.index ["user_id"], name: "index_mentions_on_user_id" end - create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t| + create_table "messages", id: :serial, force: :cascade do |t| t.text "content" t.integer "account_id", null: false t.integer "inbox_id", null: false @@ -1027,18 +1006,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.jsonb "additional_attributes", default: {} t.text "processed_message_content" t.jsonb "sentiment", default: {} - t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin + t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created" t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type" - t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx" - t.index ["account_id"], name: "messages2_account_id_idx" - t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin - t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx" - t.index ["conversation_id"], name: "messages2_conversation_id_idx" - t.index ["created_at"], name: "messages2_created_at_idx" - t.index ["inbox_id"], name: "messages2_inbox_id_idx" - t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx" - t.index ["source_id"], name: "messages2_source_id_idx" + t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id" + t.index ["account_id"], name: "index_messages_on_account_id" + t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin + t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created" + t.index ["conversation_id"], name: "index_messages_on_conversation_id" + t.index ["created_at"], name: "index_messages_on_created_at" + t.index ["inbox_id"], name: "index_messages_on_inbox_id" + t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id" + t.index ["source_id"], name: "index_messages_on_source_id" end create_table "notes", force: :cascade do |t| @@ -1170,7 +1149,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.float "value_in_business_hours" t.datetime "event_start_time", precision: nil t.datetime "event_end_time", precision: nil - t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox" t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at" t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution" t.index ["account_id"], name: "index_reporting_events_on_account_id" @@ -1304,7 +1282,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_19_000000) do t.text "message_signature" t.string "otp_secret" t.integer "consumed_timestep" - t.boolean "otp_required_for_login", default: false, null: false + t.boolean "otp_required_for_login", default: false t.text "otp_backup_codes" t.index ["email"], name: "index_users_on_email" t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" From fe879332233bfe1706cd8ca89e08c1643d1ca280 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 19 May 2026 17:23:34 -0700 Subject: [PATCH 3/6] Update spec --- .../composables/spec/useInbox.spec.js | 8 ++ .../dashboard/settings/inbox/Settings.vue | 13 ++ .../fetch_google_play_review_inboxes_job.rb | 3 + .../inboxes/fetch_google_play_reviews_job.rb | 3 + app/models/channel/google_play.rb | 58 ++++++-- app/services/google_play/review_builder.rb | 81 +++++++++-- .../hook_execution_service.rb | 6 +- app/views/api/v1/models/_inbox.json.jbuilder | 6 + ...d_last_synced_at_to_channel_google_play.rb | 5 + enterprise/app/models/captain/document.rb | 2 - enterprise/app/models/company.rb | 16 +-- .../authorizations_controller_spec.rb | 65 +++++++++ .../google_play/callbacks_controller_spec.rb | 58 ++++++++ spec/factories/channel/channel_google_play.rb | 19 +++ ...tch_google_play_review_inboxes_job_spec.rb | 29 ++++ .../fetch_google_play_reviews_job_spec.rb | 39 +++++ spec/models/channel/google_play_spec.rb | 134 ++++++++++++++++++ .../message_window_service_spec.rb | 18 +++ .../google_play/review_builder_spec.rb | 127 +++++++++++++++++ .../send_on_google_play_service_spec.rb | 34 +++++ .../hook_execution_service_spec.rb | 24 ++++ 21 files changed, 719 insertions(+), 29 deletions(-) create mode 100644 db/migrate/20260519160000_add_last_synced_at_to_channel_google_play.rb create mode 100644 spec/controllers/api/v1/accounts/google_play/authorizations_controller_spec.rb create mode 100644 spec/controllers/google_play/callbacks_controller_spec.rb create mode 100644 spec/factories/channel/channel_google_play.rb create mode 100644 spec/jobs/inboxes/fetch_google_play_review_inboxes_job_spec.rb create mode 100644 spec/jobs/inboxes/fetch_google_play_reviews_job_spec.rb create mode 100644 spec/models/channel/google_play_spec.rb create mode 100644 spec/services/google_play/review_builder_spec.rb create mode 100644 spec/services/google_play/send_on_google_play_service_spec.rb diff --git a/app/javascript/dashboard/composables/spec/useInbox.spec.js b/app/javascript/dashboard/composables/spec/useInbox.spec.js index 1c01032d1..6b00400b4 100644 --- a/app/javascript/dashboard/composables/spec/useInbox.spec.js +++ b/app/javascript/dashboard/composables/spec/useInbox.spec.js @@ -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', ]; diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index a26eb0e18..6abb4fc33 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -249,6 +249,14 @@ export default { ]; } + // Google Play has no support for bots, business hours, or CSAT — strip those tabs + if (this.isAGooglePlayChannel) { + const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration']; + visibleToAllChannelTabs = visibleToAllChannelTabs.filter( + tab => !unsupportedKeys.includes(tab.key) + ); + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -282,6 +290,9 @@ export default { if (this.isAnEmailChannel) { return `${this.inbox.name} (${this.inbox.email})`; } + if (this.isAGooglePlayChannel && this.inbox.app_id) { + return `${this.inbox.name} (${this.inbox.app_id})`; + } return this.inbox.name; }, canLocktoSingleConversation() { @@ -829,6 +840,7 @@ export default { @@ -1128,6 +1140,7 @@ export default { diff --git a/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb b/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb index cac7865bc..0575eefa4 100644 --- a/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb +++ b/app/jobs/inboxes/fetch_google_play_review_inboxes_job.rb @@ -1,9 +1,12 @@ 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 diff --git a/app/jobs/inboxes/fetch_google_play_reviews_job.rb b/app/jobs/inboxes/fetch_google_play_reviews_job.rb index 112fca528..e54f07e9d 100644 --- a/app/jobs/inboxes/fetch_google_play_reviews_job.rb +++ b/app/jobs/inboxes/fetch_google_play_reviews_job.rb @@ -7,6 +7,9 @@ class Inboxes::FetchGooglePlayReviewsJob < ApplicationJob 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 diff --git a/app/models/channel/google_play.rb b/app/models/channel/google_play.rb index b32545afc..df5f77842 100644 --- a/app/models/channel/google_play.rb +++ b/app/models/channel/google_play.rb @@ -5,6 +5,7 @@ # 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 @@ -23,23 +24,44 @@ class Channel::GooglePlay < ApplicationRecord 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 Play' end - # Google Play only retains reviews from the last 7 days, hence the channel is polled frequently - def fetch_reviews - response = HTTParty.get( - "#{API_BASE_URL}/applications/#{app_id}/reviews", - headers: authorization_headers, - query: { maxResults: 100 } - ) - raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success? + def sync_due? + last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago + end - response.parsed_response['reviews'] || [] + # 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. @@ -59,6 +81,20 @@ class Channel::GooglePlay < ApplicationRecord 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 @@ -67,4 +103,8 @@ class Channel::GooglePlay < ApplicationRecord def access_token Google::RefreshOauthTokenService.new(channel: self).access_token end + + def enqueue_initial_review_fetch + ::Inboxes::FetchGooglePlayReviewsJob.perform_later(self) + end end diff --git a/app/services/google_play/review_builder.rb b/app/services/google_play/review_builder.rb index c6e07e5ad..20f7f2330 100644 --- a/app/services/google_play/review_builder.rb +++ b/app/services/google_play/review_builder.rb @@ -1,6 +1,9 @@ -# Builds a contact, conversation and incoming message from a single Google Play review. +# 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!] @@ -10,7 +13,8 @@ class GooglePlay::ReviewBuilder ActiveRecord::Base.transaction do build_contact_inbox build_conversation - build_message + build_user_message + build_developer_message if developer_comment.present? end end @@ -21,7 +25,15 @@ class GooglePlay::ReviewBuilder end def user_comment - @user_comment ||= Array(review['comments']).filter_map { |comment| comment['userComment'] }.first + @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 @@ -37,10 +49,16 @@ class GooglePlay::ReviewBuilder end # A new lastModified timestamp (edited review) yields a new message in the same conversation - def message_source_id + 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, @@ -62,31 +80,76 @@ class GooglePlay::ReviewBuilder ) end - def build_message - return if @conversation.messages.exists?(source_id: message_source_id) + 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: message_source_id, + 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)\n\n#{review_text}" + [ + "#{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: user_comment['device'], + device: device_display, + manufacturer: metadata['manufacturer'], + android_os_version: user_comment['androidOsVersion'], reviewer_language: user_comment['reviewerLanguage'] } } diff --git a/app/services/message_templates/hook_execution_service.rb b/app/services/message_templates/hook_execution_service.rb index 93f0447f3..2665b32a5 100644 --- a/app/services/message_templates/hook_execution_service.rb +++ b/app/services/message_templates/hook_execution_service.rb @@ -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 diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 0ae0745cd..080fa94ad 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -63,6 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? ## Tiktok Attributes json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok? +## 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) diff --git a/db/migrate/20260519160000_add_last_synced_at_to_channel_google_play.rb b/db/migrate/20260519160000_add_last_synced_at_to_channel_google_play.rb new file mode 100644 index 000000000..adac40de4 --- /dev/null +++ b/db/migrate/20260519160000_add_last_synced_at_to_channel_google_play.rb @@ -0,0 +1,5 @@ +class AddLastSyncedAtToChannelGooglePlay < ActiveRecord::Migration[7.1] + def change + add_column :channel_google_play, :last_synced_at, :datetime + end +end diff --git a/enterprise/app/models/captain/document.rb b/enterprise/app/models/captain/document.rb index 0fe14813b..d4fb400f5 100644 --- a/enterprise/app/models/captain/document.rb +++ b/enterprise/app/models/captain/document.rb @@ -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 diff --git a/enterprise/app/models/company.rb b/enterprise/app/models/company.rb index c60e9423c..0af914cae 100644 --- a/enterprise/app/models/company.rb +++ b/enterprise/app/models/company.rb @@ -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 # diff --git a/spec/controllers/api/v1/accounts/google_play/authorizations_controller_spec.rb b/spec/controllers/api/v1/accounts/google_play/authorizations_controller_spec.rb new file mode 100644 index 000000000..ab06b68ef --- /dev/null +++ b/spec/controllers/api/v1/accounts/google_play/authorizations_controller_spec.rb @@ -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 diff --git a/spec/controllers/google_play/callbacks_controller_spec.rb b/spec/controllers/google_play/callbacks_controller_spec.rb new file mode 100644 index 000000000..1e0778617 --- /dev/null +++ b/spec/controllers/google_play/callbacks_controller_spec.rb @@ -0,0 +1,58 @@ +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 + + before do + 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 + + describe 'GET /google_play/callback' do + it 'creates the channel + inbox and redirects to the agent assignment page' do + stub_request(:post, 'https://oauth2.googleapis.com/o/oauth2/token') + .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, 'https://oauth2.googleapis.com/o/oauth2/token').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 +end diff --git a/spec/factories/channel/channel_google_play.rb b/spec/factories/channel/channel_google_play.rb new file mode 100644 index 000000000..b7ca64167 --- /dev/null +++ b/spec/factories/channel/channel_google_play.rb @@ -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 diff --git a/spec/jobs/inboxes/fetch_google_play_review_inboxes_job_spec.rb b/spec/jobs/inboxes/fetch_google_play_review_inboxes_job_spec.rb new file mode 100644 index 000000000..73d046879 --- /dev/null +++ b/spec/jobs/inboxes/fetch_google_play_review_inboxes_job_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe Inboxes::FetchGooglePlayReviewInboxesJob do + include ActiveJob::TestHelper + + before { clear_enqueued_jobs } + + 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 diff --git a/spec/jobs/inboxes/fetch_google_play_reviews_job_spec.rb b/spec/jobs/inboxes/fetch_google_play_reviews_job_spec.rb new file mode 100644 index 000000000..61ee62120 --- /dev/null +++ b/spec/jobs/inboxes/fetch_google_play_reviews_job_spec.rb @@ -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 diff --git a/spec/models/channel/google_play_spec.rb b/spec/models/channel/google_play_spec.rb new file mode 100644 index 000000000..ef9903473 --- /dev/null +++ b/spec/models/channel/google_play_spec.rb @@ -0,0 +1,134 @@ +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 Play' + 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_any_instance_of(described_class).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).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_any_instance_of(described_class).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 + long_text = 'a' * 500 + stub_request(:post, url) + .with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH)) + .to_return(status: 200, body: { result: { lastEdited: { seconds: '1' } } }.to_json, + headers: { 'Content-Type' => 'application/json' }) + + channel.reply_to_review('REV-1', long_text) + expect(WebMock).to have_requested(:post, url) + .with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH)) + 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 diff --git a/spec/services/conversations/message_window_service_spec.rb b/spec/services/conversations/message_window_service_spec.rb index 32542e87e..a9b0e641c 100644 --- a/spec/services/conversations/message_window_service_spec.rb +++ b/spec/services/conversations/message_window_service_spec.rb @@ -625,4 +625,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 diff --git a/spec/services/google_play/review_builder_spec.rb b/spec/services/google_play/review_builder_spec.rb new file mode 100644 index 000000000..a5b073f9e --- /dev/null +++ b/spec/services/google_play/review_builder_spec.rb @@ -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 diff --git a/spec/services/google_play/send_on_google_play_service_spec.rb b/spec/services/google_play/send_on_google_play_service_spec.rb new file mode 100644 index 000000000..f0ea003f2 --- /dev/null +++ b/spec/services/google_play/send_on_google_play_service_spec.rb @@ -0,0 +1,34 @@ +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') + allow_any_instance_of(Inbox).to receive(:channel).and_return(channel) + + 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_any_instance_of(Inbox).to receive(:channel).and_return(channel) + 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 diff --git a/spec/services/message_templates/hook_execution_service_spec.rb b/spec/services/message_templates/hook_execution_service_spec.rb index e186227be..cb0b600e2 100644 --- a/spec/services/message_templates/hook_execution_service_spec.rb +++ b/spec/services/message_templates/hook_execution_service_spec.rb @@ -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 From 37db80793f5ec32a468095df672d355a9bb2ff11 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 19 May 2026 17:27:45 -0700 Subject: [PATCH 4/6] Update schema.rb --- db/schema.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/db/schema.rb b/db/schema.rb index 9d9fe3cbc..a309c6bb8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_19_160000) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -491,6 +491,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 From 9edad844a46bfd2462eaed1b377591331ceac355 Mon Sep 17 00:00:00 2001 From: Pranav Date: Tue, 19 May 2026 17:34:32 -0700 Subject: [PATCH 5/6] fix spec --- spec/models/channel/google_play_spec.rb | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/spec/models/channel/google_play_spec.rb b/spec/models/channel/google_play_spec.rb index ef9903473..22c735f29 100644 --- a/spec/models/channel/google_play_spec.rb +++ b/spec/models/channel/google_play_spec.rb @@ -81,7 +81,9 @@ RSpec.describe Channel::GooglePlay do end it 'raises when the API responds with an error' do - stub_request(:get, base_url).to_return(status: 403, body: 'forbidden') + 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 @@ -105,15 +107,15 @@ RSpec.describe Channel::GooglePlay do end it 'truncates the reply text to MAX_REPLY_LENGTH' do - long_text = 'a' * 500 - stub_request(:post, url) - .with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH)) - .to_return(status: 200, body: { result: { lastEdited: { seconds: '1' } } }.to_json, - headers: { 'Content-Type' => 'application/json' }) + 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', long_text) - expect(WebMock).to have_requested(:post, url) - .with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH)) + 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 From 1149edbd2d19d17e3c06e038834c85ee7d315576 Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 20 May 2026 07:06:53 -0700 Subject: [PATCH 6/6] Fix name --- app/models/channel/google_play.rb | 2 +- spec/models/channel/google_play_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/channel/google_play.rb b/app/models/channel/google_play.rb index df5f77842..ea517222d 100644 --- a/app/models/channel/google_play.rb +++ b/app/models/channel/google_play.rb @@ -40,7 +40,7 @@ class Channel::GooglePlay < ApplicationRecord after_create_commit :enqueue_initial_review_fetch def name - 'Google Play' + 'Google PlayStore' end def sync_due? diff --git a/spec/models/channel/google_play_spec.rb b/spec/models/channel/google_play_spec.rb index 22c735f29..5b42165c4 100644 --- a/spec/models/channel/google_play_spec.rb +++ b/spec/models/channel/google_play_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Channel::GooglePlay do describe '#name' do it 'returns the human-readable channel name' do - expect(channel.name).to eq 'Google Play' + expect(channel.name).to eq 'Google PlayStore' end end