Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 343466b9c9 Merge branch 'develop' into feat/google-play-reviews 2026-06-04 12:55:57 +04:00
Muhsin 1f0d2a1049 Update provider.js 2026-06-02 21:31:09 +04:00
Muhsin KelothandGitHub ee00f445c0 Merge branch 'develop' into feat/google-play-reviews 2026-06-02 21:18:40 +04:00
Muhsin KelothandGitHub 29ecbc846b Merge branch 'develop' into feat/google-play-reviews 2026-05-22 09:03:26 +04:00
Pranav 1149edbd2d Fix name 2026-05-20 07:06:53 -07:00
Pranav 9edad844a4 fix spec 2026-05-19 17:34:32 -07:00
Pranav 37db80793f Update schema.rb 2026-05-19 17:27:45 -07:00
Pranav fe87933223 Update spec 2026-05-19 17:23:34 -07:00
Pranav 3509a98eb2 revert schema.rb 2026-05-19 17:23:00 -07:00
Pranav 13a9f2591b feat: Google Play Store Reviews 2026-05-19 15:32:49 -07:00
50 changed files with 1258 additions and 15 deletions
@@ -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
@@ -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
@@ -0,0 +1,26 @@
module GooglePlayOauthConcern
extend ActiveSupport::Concern
include GoogleConcern
GOOGLE_PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
private
# Overrides the Gmail scope from GoogleConcern with the Play Developer API scope
def scope
GOOGLE_PLAY_SCOPE
end
# Carries the account and channel details through the OAuth round trip
def google_play_verifier
Rails.application.message_verifier('google_play_oauth')
end
def google_play_callback_url
"#{base_url}/google_play/callback"
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -0,0 +1,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
+2 -1
View File
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
'sms' => Current.account.sms_channels,
'google_play' => Current.account.google_play_channels
}[permitted_params[:channel][:type]]
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class GooglePlayClient extends ApiClient {
constructor() {
super('google_play', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new GooglePlayClient();
@@ -14,6 +14,7 @@ const channelTypeIconMap = {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::GooglePlay': 'i-ri-google-play-line',
};
const providerIconMap = {
@@ -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;
}
@@ -75,6 +75,7 @@ const isActive = computed(() => {
'instagram',
'tiktok',
'voice',
'google_play',
].includes(key);
});
@@ -264,6 +264,9 @@ export default {
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isAGooglePlayChannel) {
return MESSAGE_MAX_LENGTH.GOOGLE_PLAY;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -53,6 +53,7 @@ const mockStore = createStore({
voice_enabled: true,
},
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
16: { id: 16, channel_type: INBOX_TYPES.GOOGLE_PLAY },
};
return inboxes[id] || null;
},
@@ -226,6 +227,12 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
// Test Google Play
wrapper = mount(createTestComponent(16), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAGooglePlayChannel).toBe(true);
});
});
@@ -278,6 +285,7 @@ describe('useInbox', () => {
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAGooglePlayChannel',
'voiceCallEnabled',
'voiceCallProvider',
];
@@ -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,
};
@@ -116,6 +116,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'],
+6
View File
@@ -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)
@@ -53,6 +54,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';
@@ -68,6 +70,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';
@@ -117,6 +120,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.GOOGLE_PLAY:
return 'google_play';
default:
return 'chat';
}
@@ -449,6 +449,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.",
@@ -502,6 +520,10 @@
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
},
"GOOGLE_PLAY": {
"TITLE": "Google Play Reviews",
"DESCRIPTION": "Manage your Google Play Store app reviews"
}
}
},
@@ -1196,6 +1218,7 @@
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"GOOGLE_PLAY": "Google Play Reviews",
"VOICE": "Voice"
}
}
@@ -13,6 +13,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,
@@ -28,6 +29,7 @@ const channelViewList = {
instagram: Instagram,
tiktok: Tiktok,
voice: Voice,
google_play: GooglePlay,
};
export default defineComponent({
@@ -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) {
@@ -250,6 +250,12 @@ export default {
},
];
}
if (this.isAGooglePlayChannel) {
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
tab => !unsupportedKeys.includes(tab.key)
);
}
if (
this.isAWhatsAppCloudChannel &&
@@ -300,6 +306,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() {
@@ -847,6 +856,7 @@ export default {
</SettingsFieldSection>
<SettingsFieldSection
v-if="!isAGooglePlayChannel"
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
>
@@ -1146,6 +1156,7 @@ export default {
</SettingsAccordion>
<SettingsAccordion
v-if="!isAGooglePlayChannel"
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
class="mt-6"
>
@@ -0,0 +1,109 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import googlePlayClient from 'dashboard/api/channel/googlePlayClient';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const route = useRoute();
const { t } = useI18n();
// Surface any error the OAuth callback redirected back with
onMounted(() => {
if (route.query.error) {
useAlert(String(route.query.error));
}
});
const state = reactive({
inboxName: '',
appId: '',
});
const isConnecting = ref(false);
const rules = {
inboxName: { required },
appId: { required },
};
const v$ = useVuelidate(rules, state);
const connectWithGoogle = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
try {
isConnecting.value = true;
const {
data: { url },
} = await googlePlayClient.generateAuthorization({
app_id: state.appId.trim(),
inbox_name: state.inboxName.trim(),
});
window.location.href = url;
} catch (error) {
isConnecting.value = false;
useAlert(t('INBOX_MGMT.ADD.GOOGLE_PLAY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.DESC')"
/>
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="connectWithGoogle"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.LABEL') }}
<input
v-model="state.inboxName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.PLACEHOLDER')
"
@blur="v$.inboxName.$touch"
/>
<span v-if="v$.inboxName.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.INBOX_NAME.ERROR') }}
</span>
</label>
</div>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.appId.$error }">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.LABEL') }}
<input
v-model="state.appId"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.PLACEHOLDER')"
@blur="v$.appId.$touch"
/>
<span v-if="v$.appId.$error" class="message">
{{ $t('INBOX_MGMT.ADD.GOOGLE_PLAY.APP_ID.ERROR') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="isConnecting"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.GOOGLE_PLAY.CONNECT_BUTTON')"
/>
</div>
</form>
</div>
</template>
@@ -34,6 +34,7 @@ const i18nMap = {
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::GooglePlay': 'GOOGLE_PLAY',
};
const twilioChannelName = () => {
@@ -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,
};
@@ -134,6 +134,9 @@ export default {
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
isAGooglePlayChannel() {
return this.channelType === INBOX_TYPES.GOOGLE_PLAY;
},
},
methods: {
inboxHasFeature(feature) {
@@ -0,0 +1,14 @@
class Inboxes::FetchGooglePlayReviewInboxesJob < ApplicationJob
queue_as :scheduled_jobs
# Runs every 15 minutes via cron. Each inbox syncs at most once per Channel::GooglePlay::SYNC_INTERVAL
# (one hour) — the extra cron ticks act as quick retries when a sync window is missed.
def perform
Inbox.where(channel_type: 'Channel::GooglePlay').find_each(batch_size: 100) do |inbox|
next if inbox.account.suspended?
next unless inbox.channel.sync_due?
::Inboxes::FetchGooglePlayReviewsJob.perform_later(inbox.channel)
end
end
end
@@ -0,0 +1,16 @@
class Inboxes::FetchGooglePlayReviewsJob < ApplicationJob
queue_as :scheduled_jobs
def perform(channel)
channel.fetch_reviews.each do |review|
::GooglePlay::ReviewBuilder.new(review: review, channel: channel).perform
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
# Stamp the channel so the orchestrator skips it until the sync interval elapses.
channel.update!(last_synced_at: Time.current)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
end
+1
View File
@@ -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
+1
View File
@@ -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'
+110
View File
@@ -0,0 +1,110 @@
# == Schema Information
#
# Table name: channel_google_play
#
# id :bigint not null, primary key
# app_id :string not null
# provider_config :jsonb not null
# last_synced_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_channel_google_play_on_account_id_and_app_id (account_id,app_id) UNIQUE
#
class Channel::GooglePlay < ApplicationRecord
include Channelable
self.table_name = 'channel_google_play'
EDITABLE_ATTRS = [:app_id, { provider_config: {} }].freeze
API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3'.freeze
# Google Play caps a developer reply at 350 characters
MAX_REPLY_LENGTH = 350
REVIEWS_PAGE_SIZE = 100
# Safety bound — at 100 per page this covers 5,000 reviews. The API only retains the last 7
# days, so this is far above any realistic volume and just prevents runaway loops.
MAX_REVIEW_PAGES = 50
# Each inbox is synced at most once per this window. The cron runs more frequently
# so a missed window retries on the next tick.
SYNC_INTERVAL = 1.hour
validates :app_id, presence: true, uniqueness: { scope: :account_id }
# Pull reviews on the agent's behalf the moment the inbox is wired up so they're not waiting
# for the next 15-minute poll. Runs after the surrounding transaction commits so the
# associated inbox is guaranteed to be visible.
after_create_commit :enqueue_initial_review_fetch
def name
'Google PlayStore'
end
def sync_due?
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
end
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently.
# Pages through tokenPagination.nextPageToken until the API stops returning a cursor.
def fetch_reviews
reviews = []
page_token = nil
MAX_REVIEW_PAGES.times do
parsed = fetch_reviews_page(page_token)
reviews.concat(parsed['reviews'] || [])
page_token = parsed.dig('tokenPagination', 'nextPageToken')
break if page_token.blank?
end
reviews
end
# Returns a stable source_id for the reply so the outgoing message can be marked as sent.
# Google Play has no separate reply id, so we combine the review id with the developer comment's
# lastEdited timestamp from the response.
def reply_to_review(review_id, reply_text)
response = HTTParty.post(
"#{API_BASE_URL}/applications/#{app_id}/reviews/#{review_id}:reply",
headers: authorization_headers.merge('Content-Type' => 'application/json'),
body: { replyText: reply_text.to_s.truncate(MAX_REPLY_LENGTH) }.to_json
)
raise "Google Play reply failed (#{response.code}): #{response.body}" unless response.success?
last_edited = response.parsed_response.dig('result', 'lastEdited', 'seconds')
"#{review_id}::reply::#{last_edited}"
end
private
def fetch_reviews_page(page_token)
query = { maxResults: REVIEWS_PAGE_SIZE }
query[:token] = page_token if page_token.present?
response = HTTParty.get(
"#{API_BASE_URL}/applications/#{app_id}/reviews",
headers: authorization_headers,
query: query
)
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
response.parsed_response
end
def authorization_headers
{ 'Authorization' => "Bearer #{access_token}" }
end
# Channels are connected through the Google OAuth flow; tokens are refreshed on demand
def access_token
Google::RefreshOauthTokenService.new(channel: self).access_token
end
def enqueue_initial_review_fetch
::Inboxes::FetchGooglePlayReviewsJob.perform_later(self)
end
end
+4
View File
@@ -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
@@ -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
+157
View File
@@ -0,0 +1,157 @@
# Builds a contact, conversation and messages from a single Google Play review.
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
# their review, a fresh incoming message is appended to the same conversation.
# Developer replies (whether posted via Chatwoot or directly in Play Console) are mirrored
# as outgoing messages, with a source_id that matches what `SendOnGooglePlayService`
# produces so Chatwoot-originated replies are deduped on the next poll.
class GooglePlay::ReviewBuilder
pattr_initialize [:review!, :channel!]
def perform
return if user_comment.blank? || review_text.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
build_conversation
build_user_message
build_developer_message if developer_comment.present?
end
end
private
def inbox
@inbox ||= channel.inbox
end
def user_comment
@user_comment ||= comments_with_key('userComment').first
end
def developer_comment
@developer_comment ||= comments_with_key('developerComment').first
end
def comments_with_key(key)
Array(review['comments']).filter_map { |comment| comment[key] }
end
def review_id
review['reviewId']
end
def review_text
@review_text ||= user_comment['text'].to_s.strip
end
def star_rating
@star_rating ||= user_comment['starRating'].to_i
end
# A new lastModified timestamp (edited review) yields a new message in the same conversation
def user_message_source_id
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
end
# Must match the format `Channel::GooglePlay#reply_to_review` returns so replies sent through
# Chatwoot are not duplicated when the review is re-fetched.
def developer_message_source_id
"#{review_id}::reply::#{developer_comment.dig('lastModified', 'seconds')}"
end
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: review_id,
inbox: inbox,
contact_attributes: {
name: review['authorName'].presence || 'Google Play User',
additional_attributes: { source_id: "google_play:#{review_id}" }
}
).perform
end
def build_conversation
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { source: 'google_play', app_id: channel.app_id }
)
end
def build_user_message
return if @conversation.messages.exists?(source_id: user_message_source_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
sender: @conversation.contact,
message_type: :incoming,
source_id: user_message_source_id,
content: message_content,
content_attributes: review_metadata
)
end
def build_developer_message
text = developer_comment['text'].to_s.strip
return if text.blank?
return if @conversation.messages.exists?(source_id: developer_message_source_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: :outgoing,
source_id: developer_message_source_id,
content: text,
status: :sent
)
end
def message_content
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
[
"#{stars} (#{star_rating}/5)",
review_text,
review_footer
].compact_blank.join("\n\n")
end
# A compact line of context shown beneath the review text — device, app version, OS.
def review_footer
parts = [device_display, app_version_display, os_version_display].compact_blank
return nil if parts.empty?
parts.join(' • ')
end
def device_display
metadata = user_comment['deviceMetadata'] || {}
metadata['productName'].presence || user_comment['device']
end
def app_version_display
version = user_comment['appVersionName']
version.present? ? "v#{version}" : nil
end
def os_version_display
version = user_comment['androidOsVersion']
version.present? ? "Android #{version}" : nil
end
def review_metadata
metadata = user_comment['deviceMetadata'] || {}
{
google_play: {
star_rating: star_rating,
app_version: user_comment['appVersionName'],
device: device_display,
manufacturer: metadata['manufacturer'],
android_os_version: user_comment['androidOsVersion'],
reviewer_language: user_comment['reviewerLanguage']
}
}
end
end
@@ -0,0 +1,15 @@
class GooglePlay::SendOnGooglePlayService < Base::SendOnChannelService
private
def channel_class
Channel::GooglePlay
end
def perform_reply
source_id = channel.reply_to_review(message.conversation.contact_inbox.source_id, message.content)
message.update!(source_id: source_id) if source_id.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
message.update!(status: :failed, external_error: e.message.to_s.truncate(255))
end
end
@@ -19,10 +19,12 @@ class MessageTemplates::HookExecutionService
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
end
def should_send_out_of_office_message?
def should_send_out_of_office_message? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
# Google Play replies don't support out-of-office auto-replies; business hours don't apply to review responses
return false if inbox.google_play?
# should not send for outbound messages
return false unless message.incoming?
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
@@ -40,6 +42,8 @@ class MessageTemplates::HookExecutionService
return false if conversation.campaign.present?
# should not send if its a tweet message
return false if conversation.tweet?
# Google Play replies are constrained — auto-greetings don't fit the one-shot review reply model
return false if inbox.google_play?
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
end
@@ -63,6 +63,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)
+7
View File
@@ -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'
@@ -323,6 +325,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
@@ -649,6 +655,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'
+6
View File
@@ -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:
@@ -0,0 +1,13 @@
class CreateChannelGooglePlay < ActiveRecord::Migration[7.1]
def change
create_table :channel_google_play do |t|
t.bigint :account_id, null: false
t.string :app_id, null: false
t.jsonb :provider_config, null: false, default: {}
t.timestamps
end
add_index :channel_google_play, [:account_id, :app_id], unique: true
end
end
@@ -0,0 +1,5 @@
class AddLastSyncedAtToChannelGooglePlay < ActiveRecord::Migration[7.1]
def change
add_column :channel_google_play, :last_synced_at, :datetime
end
end
+10
View File
@@ -491,6 +491,16 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) 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
@@ -7,7 +7,6 @@
# content_fingerprint :string
# external_link :text not null
# last_sync_attempted_at :datetime
# last_sync_error_code :string
# last_synced_at :datetime
# metadata :jsonb
# name :string
+8 -8
View File
@@ -2,17 +2,17 @@
#
# Table name: companies
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# contacts_count :integer default(0), not null
# custom_attributes :jsonb
# description :text
# domain :string
# last_activity_at :datetime
# id :bigint not null, primary key
# contacts_count :integer
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -0,0 +1,65 @@
require 'rails_helper'
RSpec.describe 'Google Play Authorization API', type: :request do
let(:account) { create(:account) }
let(:url) { "/api/v1/accounts/#{account.id}/google_play/authorization" }
describe 'POST /api/v1/accounts/{account.id}/google_play/authorization' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post url
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized' do
post url, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated administrator' do
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
# Stand-in OAuth credentials so the authorize URL is built.
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_ID', value: 'client-id-123', locked: false)
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_SECRET', value: 'client-secret', locked: false)
GlobalConfig.clear_cache
end
it 'returns a redirect URL with the androidpublisher scope' do
post url,
params: { app_id: 'com.example.app', inbox_name: 'My App' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
body = response.parsed_body
expect(body['success']).to be true
expect(body['url']).to include('client_id=client-id-123')
expect(body['url']).to include('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fandroidpublisher')
expect(body['url']).to include('access_type=offline')
expect(body['url']).to include('prompt=consent')
expect(body['url']).to include('google_play%2Fcallback')
end
it 'signs the state with the app_id and inbox_name so the callback can decode them' do
post url,
params: { app_id: 'com.example.app', inbox_name: 'My App' },
headers: administrator.create_new_auth_token,
as: :json
state = CGI.parse(URI(response.parsed_body['url']).query)['state'].first
decoded = Rails.application.message_verifier('google_play_oauth').verify(state).with_indifferent_access
expect(decoded[:account_id]).to eq(account.id)
expect(decoded[:app_id]).to eq('com.example.app')
expect(decoded[:inbox_name]).to eq('My App')
end
end
end
end
@@ -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
@@ -0,0 +1,19 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_google_play, class: 'Channel::GooglePlay' do
account
sequence(:app_id) { |n| "com.example.app#{n}" }
provider_config do
{
'access_token' => SecureRandom.hex(16),
'refresh_token' => SecureRandom.hex(16),
'expires_on' => 1.hour.from_now.utc.to_s
}
end
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
end
end
@@ -0,0 +1,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
@@ -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
+136
View File
@@ -0,0 +1,136 @@
require 'rails_helper'
RSpec.describe Channel::GooglePlay do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:channel) { create(:channel_google_play, account: account) }
describe 'validations' do
it 'requires app_id' do
record = described_class.new(account: account, provider_config: { 'access_token' => 'x' })
expect(record).not_to be_valid
expect(record.errors[:app_id]).to include("can't be blank")
end
it 'requires app_id to be unique per account' do
create(:channel_google_play, account: account, app_id: 'com.dup.app')
duplicate = build(:channel_google_play, account: account, app_id: 'com.dup.app')
expect(duplicate).not_to be_valid
end
it 'allows the same app_id across different accounts' do
create(:channel_google_play, account: account, app_id: 'com.shared.app')
other = build(:channel_google_play, account: create(:account), app_id: 'com.shared.app')
expect(other).to be_valid
end
end
describe '#name' do
it 'returns the human-readable channel name' do
expect(channel.name).to eq 'Google PlayStore'
end
end
describe '#sync_due?' do
it 'is true when last_synced_at is nil' do
channel.update!(last_synced_at: nil)
expect(channel.sync_due?).to be true
end
it 'is true when last_synced_at is older than the sync interval' do
channel.update!(last_synced_at: 2.hours.ago)
expect(channel.sync_due?).to be true
end
it 'is false when last_synced_at is within the sync interval' do
channel.update!(last_synced_at: 10.minutes.ago)
expect(channel.sync_due?).to be false
end
end
describe '#fetch_reviews' do
let(:base_url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews" }
before do
allow_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)
.with(query: hash_including('maxResults' => '100'))
.to_return(status: 403, body: 'forbidden')
expect { channel.fetch_reviews }.to raise_error(/Google Play reviews fetch failed \(403\)/)
end
end
describe '#reply_to_review' do
let(:url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews/REV-1:reply" }
before do
allow_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
stub_request(:post, url).to_return(status: 200,
body: { result: { lastEdited: { seconds: '1' } } }.to_json,
headers: { 'Content-Type' => 'application/json' })
channel.reply_to_review('REV-1', 'a' * 500)
expect(WebMock).to(have_requested(:post, url).with do |req|
JSON.parse(req.body)['replyText'].length <= described_class::MAX_REPLY_LENGTH
end)
end
it 'raises when the API responds with an error' do
stub_request(:post, url).to_return(status: 400, body: 'bad request')
expect { channel.reply_to_review('REV-1', 'x') }
.to raise_error(/Google Play reply failed \(400\)/)
end
end
describe 'after_create_commit' do
it 'enqueues an initial review fetch' do
expect do
create(:channel_google_play, account: account)
end.to have_enqueued_job(Inboxes::FetchGooglePlayReviewsJob)
end
end
end
@@ -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
@@ -0,0 +1,127 @@
require 'rails_helper'
RSpec.describe GooglePlay::ReviewBuilder do
let(:channel) { create(:channel_google_play) }
let(:inbox) { channel.inbox }
def base_review(overrides = {})
{
'reviewId' => 'rev-abc',
'authorName' => 'Jane Reviewer',
'comments' => [
{ 'userComment' => {
'text' => 'Great app, but crashes sometimes.',
'starRating' => 4,
'reviewerLanguage' => 'en',
'appVersionName' => '4.5.0',
'androidOsVersion' => 33,
'device' => 'a15',
'deviceMetadata' => { 'productName' => 'Galaxy A15', 'manufacturer' => 'Samsung' },
'lastModified' => { 'seconds' => '1779000000', 'nanos' => 0 }
} }
]
}.deep_merge(overrides)
end
it 'creates a contact, conversation, and incoming message from userComment' do
expect do
described_class.new(review: base_review, channel: channel).perform
end.to change(Contact, :count).by(1)
.and change(Conversation, :count).by(1)
.and change(Message, :count).by(1)
message = Message.last
expect(message.message_type).to eq 'incoming'
expect(message.source_id).to eq 'rev-abc::1779000000'
expect(message.content).to include('★★★★☆ (4/5)')
expect(message.content).to include('Great app, but crashes sometimes.')
expect(message.content).to include('Galaxy A15 • v4.5.0 • Android 33')
expect(message.content_attributes['google_play']).to include(
'star_rating' => 4,
'app_version' => '4.5.0',
'manufacturer' => 'Samsung',
'reviewer_language' => 'en'
)
end
it 'is idempotent for the same userComment lastModified seconds' do
described_class.new(review: base_review, channel: channel).perform
expect do
described_class.new(review: base_review, channel: channel).perform
end.not_to change(Message, :count)
end
it 'creates a new incoming message when the reviewer edits (new lastModified)' do
described_class.new(review: base_review, channel: channel).perform
edited = base_review.deep_dup
edited['comments'].first['userComment']['lastModified']['seconds'] = '1779999999'
edited['comments'].first['userComment']['text'] = 'Edited review'
expect do
described_class.new(review: edited, channel: channel).perform
end.to change(Message, :count).by(1)
.and(not_change(Conversation, :count))
end
it 'skips entirely when the user comment text is blank' do
review = base_review
review['comments'].first['userComment']['text'] = ''
expect do
described_class.new(review: review, channel: channel).perform
end.to(not_change(Message, :count))
end
context 'when the review also has a developerComment' do
let(:review_with_reply) do
base_review('comments' => [
{ 'userComment' => base_review['comments'].first['userComment'] },
{ 'developerComment' => {
'text' => 'Thanks for the feedback!',
'lastModified' => { 'seconds' => '1779100000' }
} }
])
end
it 'mirrors the developer comment as an outgoing message' do
described_class.new(review: review_with_reply, channel: channel).perform
outgoing = Message.outgoing.last
expect(outgoing.content).to eq('Thanks for the feedback!')
expect(outgoing.source_id).to eq('rev-abc::reply::1779100000')
expect(outgoing.status).to eq('sent')
end
it 'is idempotent and does not duplicate the outgoing reply' do
described_class.new(review: review_with_reply, channel: channel).perform
expect do
described_class.new(review: review_with_reply, channel: channel).perform
end.not_to change(Message, :count)
end
it 'dedupes against a reply already sent through Chatwoot with the same source_id' do
conversation = create(:conversation, inbox: inbox, account: channel.account,
contact_inbox: create(:contact_inbox, inbox: inbox, source_id: 'rev-abc'))
create(:message, conversation: conversation, account: channel.account, inbox: inbox,
message_type: :outgoing, source_id: 'rev-abc::reply::1779100000', content: 'sent via chatwoot')
expect do
described_class.new(review: review_with_reply, channel: channel).perform
end.to change(Message, :count).by(1) # the incoming user message only
expect(Message.outgoing.where(source_id: 'rev-abc::reply::1779100000').count).to eq(1)
end
end
# RSpec helper for `not_change` (no built-in opposite of `change`)
matcher :not_change do |obj, method|
supports_block_expectations
match do |block|
before = obj.send(method)
block.call
obj.send(method) == before
end
end
end
@@ -0,0 +1,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
@@ -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