Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
343466b9c9 | ||
|
|
cd9192f7d1 | ||
|
|
eaffad12e7 | ||
|
|
18ef019cd4 | ||
|
|
ea910227ac | ||
|
|
94ddd98050 | ||
|
|
0e87519ecd | ||
|
|
de89391031 | ||
|
|
8e42307bdc | ||
|
|
1beaa284c6 | ||
|
|
d028cc1984 | ||
|
|
0d59fb4459 | ||
|
|
1f0d2a1049 | ||
|
|
ee00f445c0 | ||
|
|
29ecbc846b | ||
|
|
1149edbd2d | ||
|
|
9edad844a4 | ||
|
|
37db80793f | ||
|
|
fe87933223 | ||
|
|
3509a98eb2 | ||
|
|
13a9f2591b |
@@ -28,17 +28,6 @@ module.exports = {
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// The stories harness is dev-only tooling, not user-facing UI.
|
||||
files: ['app/javascript/stories/**/*.{js,vue}'],
|
||||
rules: {
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||
// `group` is declared on <Story> only to accept Histoire's API.
|
||||
'vue/no-unused-properties': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ CLAUDE.local.md
|
||||
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.stories
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
Procfile.worktree
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ disable_branding:
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
enterprise: true
|
||||
voice_calls:
|
||||
name: 'Voice Calls'
|
||||
description: 'Enable voice calling capabilities for your agents and customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-voice-line'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
help_center:
|
||||
|
||||
@@ -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();
|
||||
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
setMuted(shouldMute) {
|
||||
if (!this.activeConnection) return false;
|
||||
this.activeConnection.mute(shouldMute);
|
||||
return shouldMute;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
|
||||
+1
-26
@@ -59,38 +59,13 @@ const articles = [
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Getting started', icon: '🚀' },
|
||||
{ id: 2, name: 'Marketing', icon: '⚡️' },
|
||||
{ id: 3, name: 'Development', icon: '🛠️' },
|
||||
];
|
||||
|
||||
const allowedLocales = [{ code: 'en', name: 'English' }];
|
||||
|
||||
const portalName = 'Chatwoot Help Center';
|
||||
|
||||
const meta = {
|
||||
currentPage: 1,
|
||||
allArticlesCount: articles.length,
|
||||
articlesCount: articles.length,
|
||||
mineArticlesCount: 2,
|
||||
draftArticlesCount: 1,
|
||||
archivedArticlesCount: 2,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Articles">
|
||||
<div class="w-full min-h-screen bg-n-background">
|
||||
<ArticlesPage
|
||||
:articles="articles"
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:portal-name="portalName"
|
||||
:meta="meta"
|
||||
/>
|
||||
<ArticlesPage :articles="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
+175
-31
@@ -3,62 +3,206 @@ import CategoriesPage from './CategoriesPage.vue';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Getting started',
|
||||
icon: '🚀',
|
||||
slug: 'getting-started',
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
meta: { articles_count: 12 },
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Marketing',
|
||||
icon: '⚡️',
|
||||
slug: 'marketing',
|
||||
id: 'marketing',
|
||||
title: 'Marketing',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
|
||||
meta: { articles_count: 4 },
|
||||
articlesCount: '4',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Development',
|
||||
icon: '🛠️',
|
||||
slug: 'development',
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
description: '',
|
||||
meta: { articles_count: 5 },
|
||||
articlesCount: '5',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Roadmap',
|
||||
icon: '🛣️',
|
||||
slug: 'roadmap',
|
||||
id: 'roadmap',
|
||||
title: '🛣️ Roadmap',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
meta: { articles_count: 3 },
|
||||
articlesCount: '3',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Finance',
|
||||
icon: '💰',
|
||||
slug: 'finance',
|
||||
id: 'finance',
|
||||
title: '💰 Finance',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
meta: { articles_count: 2 },
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const allowedLocales = [{ code: 'en', name: 'English' }];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Categories">
|
||||
<div class="w-full min-h-screen bg-n-background">
|
||||
<CategoriesPage
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
/>
|
||||
<CategoriesPage :categories="categories" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
+1
-17
@@ -1,28 +1,12 @@
|
||||
<script setup>
|
||||
import PortalSettings from './PortalSettings.vue';
|
||||
|
||||
// `slug` matches the route the stories harness seeds (portals/chatwoot), so
|
||||
// PortalSettings resolves an active portal and renders the settings forms.
|
||||
const portals = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
slug: 'chatwoot',
|
||||
header_text: 'How can we help you today?',
|
||||
page_title: 'Chatwoot Help Center',
|
||||
homepage_link: 'https://www.chatwoot.com',
|
||||
custom_domain: '',
|
||||
config: {},
|
||||
ssl_settings: {},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-[1000px] min-h-screen bg-n-background">
|
||||
<PortalSettings :portals="portals" :is-fetching="false" />
|
||||
<PortalSettings />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
|
||||
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
|
||||
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
|
||||
"
|
||||
icon="i-ph-phone-x-bold"
|
||||
icon="i-ph-phone-bold"
|
||||
ruby
|
||||
class="!rounded-full rotate-[134deg]"
|
||||
class="!rounded-full rotate-[135deg]"
|
||||
@click="isOngoing ? $emit('end') : $emit('reject')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
|
||||
import CallCard from 'dashboard/components-next/call/CallCard.vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
|
||||
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
|
||||
const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -29,8 +30,8 @@ const {
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
|
||||
// don't expose a mic track on the browser side.
|
||||
// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
|
||||
// the Voice SDK connection's native mute. Both surface the same button.
|
||||
const isMuted = ref(false);
|
||||
const isWhatsappActive = computed(
|
||||
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||
@@ -63,7 +64,11 @@ const stackedCardState = call =>
|
||||
|
||||
const toggleMute = () => {
|
||||
isMuted.value = !isMuted.value;
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
if (isWhatsappActive.value) {
|
||||
setWhatsappCallMuted(isMuted.value);
|
||||
} else {
|
||||
TwilioVoiceClient.setMuted(isMuted.value);
|
||||
}
|
||||
};
|
||||
|
||||
watch(hasActiveCall, active => {
|
||||
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
|
||||
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
|
||||
:duration="hasActiveCall ? formattedCallDuration : ''"
|
||||
:is-muted="isMuted"
|
||||
:show-mute="hasActiveCall && isWhatsappActive"
|
||||
:show-mute="hasActiveCall"
|
||||
@accept="handleJoinCall(primaryIncomingCall)"
|
||||
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
|
||||
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
|
||||
|
||||
@@ -2,51 +2,44 @@
|
||||
import { ref } from 'vue';
|
||||
import Copilot from './Copilot.vue';
|
||||
|
||||
const supportAgent = {
|
||||
available_name: 'Pranav Raj',
|
||||
avatar_url:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
const messages = ref([
|
||||
{
|
||||
id: 1,
|
||||
message_type: 'user',
|
||||
message: { content: 'Hi there! How can I help you today?' },
|
||||
role: 'user',
|
||||
content: 'Hi there! How can I help you today?',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
message_type: 'assistant_thinking',
|
||||
message: {
|
||||
content: 'Analyzing the conversation',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
message_type: 'assistant_thinking',
|
||||
message: {
|
||||
content: 'Searching the knowledge base',
|
||||
reasoning: 'Looking for relevant articles and past replies',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
message_type: 'assistant',
|
||||
message: {
|
||||
content:
|
||||
"Hello! I'm the AI assistant. I'll be helping the support team today.",
|
||||
},
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello! I'm the AI assistant. I'll be helping the support team today.",
|
||||
},
|
||||
]);
|
||||
|
||||
const isCaptainTyping = ref(false);
|
||||
|
||||
const sendMessage = message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
message_type: 'user',
|
||||
message: { content: message },
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
// Simulate AI response
|
||||
isCaptainTyping.value = true;
|
||||
setTimeout(() => {
|
||||
isCaptainTyping.value = false;
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
message_type: 'assistant',
|
||||
message: { content: 'This is a simulated AI response.' },
|
||||
role: 'assistant',
|
||||
content: 'This is a simulated AI response.',
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
@@ -58,8 +51,9 @@ const sendMessage = message => {
|
||||
:layout="{ type: 'grid', width: '400px', height: '800px' }"
|
||||
>
|
||||
<Copilot
|
||||
:support-agent="supportAgent"
|
||||
:messages="messages"
|
||||
conversation-inbox-type="Channel::WebWidget"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</Story>
|
||||
|
||||
@@ -4,24 +4,18 @@ import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
|
||||
const messages = [
|
||||
{
|
||||
id: 1,
|
||||
message: {
|
||||
content: 'Analyzing the user query',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
},
|
||||
content: 'Analyzing the user query',
|
||||
reasoning: 'Breaking down the request into actionable steps',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
message: {
|
||||
content: 'Searching codebase',
|
||||
reasoning: 'Looking for relevant files and functions',
|
||||
},
|
||||
content: 'Searching codebase',
|
||||
reasoning: 'Looking for relevant files and functions',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
message: {
|
||||
content: 'Generating response',
|
||||
reasoning: 'Composing a helpful and accurate answer',
|
||||
},
|
||||
content: 'Generating response',
|
||||
reasoning: 'Composing a helpful and accurate answer',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
CONVERSATION_EVENTS,
|
||||
CAPTAIN_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -93,7 +93,6 @@ const props = defineProps({
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
@@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
||||
const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature';
|
||||
const INLINE_IMAGE_PASTE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
@@ -192,12 +199,8 @@ const cannedSearchTerm = ref('');
|
||||
const variableSearchTerm = ref('');
|
||||
const emojiSearchTerm = ref('');
|
||||
const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
const editorRoot = useTemplateRef('editorRoot');
|
||||
@@ -475,16 +478,6 @@ function removeSignature() {
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
function setToolbarPosition() {
|
||||
const editorRect = editorRoot.value.getBoundingClientRect();
|
||||
const rect = selectedImageNode.value.getBoundingClientRect();
|
||||
|
||||
toolbarPosition.value = {
|
||||
top: `${rect.top - editorRect.top - 30}px`,
|
||||
left: `${rect.left - editorRect.left - 4}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
@@ -520,30 +513,6 @@ function checkSelection(editorState) {
|
||||
if (hasSelection) setMenubarPosition(editorState);
|
||||
}
|
||||
|
||||
function setURLWithQueryAndImageSize(size) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
setURLWithQueryAndSize(selectedImageNode.value, size, editorView);
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
|
||||
function isEditorMouseFocusedOnAnImage() {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
}
|
||||
selectedImageNode.value = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (selectedImageNode.value) {
|
||||
isImageNodeSelected.value = !!selectedImageNode.value;
|
||||
// Get the position of the selected node
|
||||
setToolbarPosition();
|
||||
} else {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('input', contentFromEditor());
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
@@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) {
|
||||
emitOnChange();
|
||||
}
|
||||
|
||||
function updateImgToolbarOnDelete() {
|
||||
// check if the selected node is present or not on keyup
|
||||
// this is needed because the user can select an image and then delete it
|
||||
// in that case, the selected node will be null and we need to hide the toolbar
|
||||
// otherwise, the toolbar will be visible even when the image is deleted and cause some errors
|
||||
if (selectedImageNode.value) {
|
||||
const hasImgSelectedNode = document.querySelector(
|
||||
'img.ProseMirror-selectednode'
|
||||
);
|
||||
if (!hasImgSelectedNode) {
|
||||
isImageNodeSelected.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('enter');
|
||||
}
|
||||
@@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled('cmd_enter');
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
});
|
||||
|
||||
function onImageInsertInEditor(fileUrl) {
|
||||
const { tr } = editorView.state;
|
||||
|
||||
@@ -617,7 +560,11 @@ async function uploadImageToStorage(file) {
|
||||
onImageInsertInEditor(fileUrl);
|
||||
}
|
||||
useAlert(
|
||||
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS')
|
||||
props.channelType === MESSAGE_SIGNATURE_FORMATTING
|
||||
? t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||
)
|
||||
: t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
@@ -626,8 +573,8 @@ async function uploadImageToStorage(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const file = imageUpload.value.files[0];
|
||||
function uploadImageIfWithinSizeLimit(file) {
|
||||
if (!file) return;
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
uploadImageToStorage(file);
|
||||
} else {
|
||||
@@ -640,10 +587,61 @@ function onFileChange() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
imageUpload.value = '';
|
||||
}
|
||||
|
||||
function onFileChange() {
|
||||
const input = imageUpload.value;
|
||||
uploadImageIfWithinSizeLimit(input.files[0]);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const allowsInlineImagePaste = computed(
|
||||
() =>
|
||||
!props.isPrivate &&
|
||||
(props.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.channelType === INBOX_TYPES.WEB)
|
||||
);
|
||||
|
||||
// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This
|
||||
// gesture's native paste event carries no image, so clipboard.read() is the
|
||||
// only way to get the bytes. No preventDefault: text still pastes natively.
|
||||
async function pasteInlineImageFromClipboard() {
|
||||
if (!editorView?.hasFocus()) return;
|
||||
if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return;
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const imageItem = items.find(item =>
|
||||
item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type))
|
||||
);
|
||||
if (!imageItem) return;
|
||||
const imageType = imageItem.types.find(type =>
|
||||
INLINE_IMAGE_PASTE_TYPES.includes(type)
|
||||
);
|
||||
const blob = await imageItem.getType(imageType);
|
||||
uploadImageIfWithinSizeLimit(
|
||||
new File([blob], 'pasted-image', { type: imageType })
|
||||
);
|
||||
} catch (error) {
|
||||
// clipboard-read denied/unfocused (NotAllowedError): image can't be read.
|
||||
// Text paste is unaffected — ProseMirror handles it from the native event.
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboardEvents({
|
||||
'Alt+KeyP': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: focusEditorInputField,
|
||||
allowOnFocusedInput: false,
|
||||
},
|
||||
'$mod+Shift+KeyV': {
|
||||
action: pasteInlineImageFromClipboard,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleLineBreakWhenEnterToSendEnabled(event) {
|
||||
if (
|
||||
hasPressedEnterAndNotCmdOrShift(event) &&
|
||||
@@ -736,6 +734,9 @@ function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
editable: () => !props.disabled,
|
||||
nodeViews: {
|
||||
image: imageResizeView,
|
||||
},
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
@@ -748,12 +749,10 @@ function createEditorView() {
|
||||
keyup: () => {
|
||||
if (!props.disabled) {
|
||||
typingIndicator.start();
|
||||
updateImgToolbarOnDelete();
|
||||
}
|
||||
},
|
||||
keydown: (view, event) => !props.disabled && onKeydown(event),
|
||||
focus: () => !props.disabled && emit('focus'),
|
||||
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
@@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
<div
|
||||
v-show="isImageNodeSelected && showImageResizeToolbar"
|
||||
class="absolute shadow-md rounded-[6px] flex gap-1 py-1 px-1 bg-n-solid-3 outline outline-1 outline-n-weak text-n-slate-12"
|
||||
:style="{
|
||||
top: toolbarPosition.top,
|
||||
left: toolbarPosition.left,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
v-for="size in sizes"
|
||||
:key="size.name"
|
||||
class="text-xs font-medium rounded-[4px] outline outline-1 outline-n-strong px-1.5 py-0.5 hover:bg-n-slate-5"
|
||||
@click="setURLWithQueryAndImageSize(size)"
|
||||
>
|
||||
{{ size.name }}
|
||||
</button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -997,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
|
||||
ol li {
|
||||
@apply list-item list-decimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FORMATTING = {
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -30,6 +31,7 @@ export const FORMATTING = {
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'imageUpload',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
@@ -114,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'],
|
||||
@@ -263,23 +271,3 @@ export const MARKDOWN_PATTERNS = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
height: '24px',
|
||||
},
|
||||
{
|
||||
name: 'Medium',
|
||||
height: '48px',
|
||||
},
|
||||
{
|
||||
name: 'Large',
|
||||
height: '72px',
|
||||
},
|
||||
{
|
||||
name: 'Original Size',
|
||||
height: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set URL with query and size.
|
||||
*
|
||||
* @param {Object} selectedImageNode - The current selected node.
|
||||
* @param {Object} size - The size to set.
|
||||
* @param {Object} editorView - The editor view.
|
||||
*/
|
||||
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
if (selectedImageNode) {
|
||||
// Create and apply the transaction
|
||||
const tr = editorView.state.tr.setNodeMarkup(
|
||||
editorView.state.selection.from,
|
||||
null,
|
||||
{
|
||||
src: selectedImageNode.src,
|
||||
height: size.height,
|
||||
}
|
||||
);
|
||||
|
||||
if (tr.docChanged) {
|
||||
editorView.dispatch(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
insertAtCursor,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
setURLWithQueryAndSize,
|
||||
stripInlineBase64Images,
|
||||
stripUnsupportedFormatting,
|
||||
stripUnsupportedMarkdown,
|
||||
@@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setURLWithQueryAndSize', () => {
|
||||
let selectedNode;
|
||||
let editorView;
|
||||
|
||||
beforeEach(() => {
|
||||
selectedNode = {
|
||||
setAttribute: vi.fn(),
|
||||
};
|
||||
|
||||
const tr = {
|
||||
setNodeMarkup: vi.fn().mockReturnValue({
|
||||
docChanged: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const state = {
|
||||
selection: { from: 0 },
|
||||
tr,
|
||||
};
|
||||
|
||||
editorView = {
|
||||
state,
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view', () => {
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the URL with the given size and updates the editor view with original size', () => {
|
||||
const size = { height: 'auto' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view is updated
|
||||
expect(editorView.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not update the editor view if the document has not changed', () => {
|
||||
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
|
||||
docChanged: false,
|
||||
});
|
||||
|
||||
const size = { height: '20px' };
|
||||
|
||||
setURLWithQueryAndSize(selectedNode, size, editorView);
|
||||
|
||||
// Check if the editor view dispatch was not called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not perform any operations if selectedNode is not provided', () => {
|
||||
setURLWithQueryAndSize(null, { height: '20px' }, editorView);
|
||||
|
||||
// Ensure the dispatch method wasn't called
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -48,7 +48,6 @@ const updateSignature = () => {
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
channel-type="Context::MessageSignature"
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
<div>
|
||||
<NextButton
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@import 'dashboard/assets/scss/app';
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
--_histoire-color-primary-50: 235 245 255;
|
||||
--_histoire-color-primary-100: 194 225 255;
|
||||
--_histoire-color-primary-200: 153 206 255;
|
||||
--_histoire-color-primary-300: 112 186 255;
|
||||
--_histoire-color-primary-400: 71 166 255;
|
||||
--_histoire-color-primary-500: 31 147 255;
|
||||
--_histoire-color-primary-600: 25 118 204;
|
||||
--_histoire-color-primary-700: 19 88 153;
|
||||
--_histoire-color-primary-800: 12 59 102;
|
||||
--_histoire-color-primary-900: 6 29 51;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
@import 'dashboard/assets/scss/app';
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@@ -1,33 +1,14 @@
|
||||
import '../design-system/stories.scss';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import FloatingVue from 'floating-vue';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
import './design-system/histoire.scss';
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue';
|
||||
import dashboardI18n from 'dashboard/i18n';
|
||||
import widgetI18n from 'widget/i18n';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import store from 'dashboard/store';
|
||||
import FloatingVue from 'floating-vue';
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||
|
||||
// A throwaway router so composables like useRoute()/useAccount() resolve a real
|
||||
// route object instead of throwing. We seed realistic Help Center params
|
||||
// (accountId/portalSlug/locale) so page-level stories that key off the route
|
||||
// render instead of bailing out. Story selection itself is hash-based and
|
||||
// independent of this router.
|
||||
const noop = { render: () => null };
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/app/accounts/:accountId/portals/:portalSlug/:locale?',
|
||||
name: 'stories',
|
||||
component: noop,
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', name: 'fallback', component: noop },
|
||||
],
|
||||
});
|
||||
router.replace('/app/accounts/1/portals/chatwoot/en');
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
|
||||
function mergeMessages(...sources) {
|
||||
return sources.reduce((acc, src) => {
|
||||
@@ -55,12 +36,9 @@ const i18n = createI18n({
|
||||
),
|
||||
});
|
||||
|
||||
// Registers the same global plugins/directives the dashboard components expect
|
||||
// at runtime, so stories render identically to the real app.
|
||||
export function setupApp(app) {
|
||||
export const setupVue3 = defineSetupVue3(({ app }) => {
|
||||
app.use(store);
|
||||
app.use(i18n);
|
||||
app.use(router);
|
||||
app.use(FloatingVue, {
|
||||
instantMove: true,
|
||||
arrowOverflow: false,
|
||||
@@ -70,4 +48,4 @@ export function setupApp(app) {
|
||||
app.directive('resize', vResizeObserver);
|
||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
app.directive('on-clickaway', onClickaway);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +1,37 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import mila from 'markdown-it-link-attributes';
|
||||
import mentionPlugin from './markdownIt/link';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const setImageHeight = inlineToken => {
|
||||
const setImageSizing = inlineToken => {
|
||||
const imgSrc = inlineToken.attrGet('src');
|
||||
if (!imgSrc) return;
|
||||
const url = new URL(imgSrc);
|
||||
const width = url.searchParams.get('cw_image_width');
|
||||
if (width) {
|
||||
inlineToken.attrSet(
|
||||
'style',
|
||||
`width: ${width}; max-width: 100%; height: auto;`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const height = url.searchParams.get('cw_image_height');
|
||||
if (!height) return;
|
||||
inlineToken.attrSet('style', `height: ${height};`);
|
||||
if (height) inlineToken.attrSet('style', `height: ${height};`);
|
||||
};
|
||||
|
||||
const processInlineToken = blockToken => {
|
||||
blockToken.children.forEach(inlineToken => {
|
||||
if (inlineToken.type === 'image') {
|
||||
setImageHeight(inlineToken);
|
||||
setImageSizing(inlineToken);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const imgResizeManager = md => {
|
||||
// Custom rule for image resize in markdown
|
||||
// If the image url has a query param cw_image_height, then add a style attribute to the image
|
||||
md.core.ruler.after('inline', 'add-image-height', state => {
|
||||
// If the image URL carries a cw_image_width or cw_image_height query param,
|
||||
// add an inline style attribute so the rendered <img> respects the agent's
|
||||
// resize choice. Width takes precedence (HC drag-resize); height is kept for
|
||||
// legacy messages and the message-signature use case.
|
||||
md.core.ruler.after('inline', 'add-image-sizing', state => {
|
||||
state.tokens.forEach(blockToken => {
|
||||
if (blockToken.type === 'inline') {
|
||||
processInlineToken(blockToken);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, defineAsyncComponent } from 'vue';
|
||||
import { stories, buildTree } from './registry';
|
||||
import TreeNode from './TreeNode.vue';
|
||||
import StoryFrame from './StoryFrame.vue';
|
||||
|
||||
const query = ref('');
|
||||
const filteredStories = computed(() => {
|
||||
const term = query.value.trim().toLowerCase();
|
||||
if (!term) return stories;
|
||||
return stories.filter(story => story.title.toLowerCase().includes(term));
|
||||
});
|
||||
const tree = computed(() => buildTree(filteredStories.value));
|
||||
const isSearching = computed(() => query.value.trim().length > 0);
|
||||
|
||||
function pathFromHash() {
|
||||
const hash = decodeURIComponent(window.location.hash.replace(/^#/, ''));
|
||||
return stories.find(story => story.path === hash)?.path;
|
||||
}
|
||||
|
||||
const selectedPath = ref(pathFromHash() || stories[0]?.path || '');
|
||||
const current = computed(() =>
|
||||
stories.find(story => story.path === selectedPath.value)
|
||||
);
|
||||
const StoryComponent = computed(() =>
|
||||
current.value ? defineAsyncComponent(current.value.loader) : null
|
||||
);
|
||||
|
||||
function select(path) {
|
||||
selectedPath.value = path;
|
||||
window.location.hash = encodeURIComponent(path);
|
||||
}
|
||||
|
||||
const isDark = ref(false);
|
||||
function toggleDark() {
|
||||
isDark.value = !isDark.value;
|
||||
document.documentElement.classList.toggle('dark', isDark.value);
|
||||
}
|
||||
|
||||
// Scoped to the preview canvas only (see the <main :dir> below), so the
|
||||
// sidebar stays LTR while stories can be inspected in RTL.
|
||||
const isRtl = ref(false);
|
||||
function toggleDir() {
|
||||
isRtl.value = !isRtl.value;
|
||||
}
|
||||
|
||||
// Remount the active story on every hot update so a fixed file clears the
|
||||
// error boundary (otherwise a caught error stays latched until a full reload).
|
||||
const hmrTick = ref(0);
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('vite:afterUpdate', () => {
|
||||
hmrTick.value += 1;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-screen h-screen overflow-hidden bg-n-background text-n-slate-12"
|
||||
>
|
||||
<aside
|
||||
dir="ltr"
|
||||
class="flex flex-col border-r w-72 shrink-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between h-12 gap-2 px-4 border-b shrink-0 border-n-weak"
|
||||
>
|
||||
<span class="text-sm font-semibold text-n-slate-12">
|
||||
@chatwoot/design
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium uppercase rounded-md text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggleDir"
|
||||
>
|
||||
{{ isRtl ? 'RTL' : 'LTR' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-sm rounded-md text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggleDark"
|
||||
>
|
||||
{{ isDark ? 'Light' : 'Dark' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 border-b shrink-0 border-n-weak">
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="Search stories"
|
||||
class="w-full px-3 py-1.5 text-sm rounded-md border outline-none border-n-weak bg-n-background text-n-slate-12 placeholder:text-n-slate-10 focus:border-n-brand"
|
||||
/>
|
||||
</div>
|
||||
<nav class="flex-1 p-2 overflow-y-auto">
|
||||
<TreeNode
|
||||
:nodes="tree"
|
||||
:selected-path="selectedPath"
|
||||
:force-expand="isSearching"
|
||||
@select="select"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredStories.length"
|
||||
class="px-2 py-4 text-sm text-n-slate-10"
|
||||
>
|
||||
No stories match "{{ query }}".
|
||||
</p>
|
||||
</nav>
|
||||
</aside>
|
||||
<main :dir="isRtl ? 'rtl' : 'ltr'" class="flex-1 min-w-0 overflow-hidden">
|
||||
<StoryFrame
|
||||
v-if="StoryComponent"
|
||||
:key="`${selectedPath}:${hmrTick}`"
|
||||
:component="StoryComponent"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full text-n-slate-10"
|
||||
>
|
||||
Select a story to preview.
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, provide } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
layout: {
|
||||
type: Object,
|
||||
default: () => ({ type: 'grid', width: '80%' }),
|
||||
},
|
||||
// Accepted for API compatibility with Histoire; not used by the harness.
|
||||
group: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const layout = computed(() => ({ type: 'grid', ...props.layout }));
|
||||
provide('storyLayout', layout);
|
||||
|
||||
const name = computed(() => props.title.split('/').pop());
|
||||
const containerClass = computed(() =>
|
||||
layout.value.type === 'single'
|
||||
? 'flex flex-col gap-6'
|
||||
: 'flex flex-wrap items-start gap-6'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full min-h-0 bg-n-background">
|
||||
<header
|
||||
class="flex flex-col gap-0.5 px-6 py-4 border-b border-n-weak shrink-0"
|
||||
>
|
||||
<h1 class="text-lg font-semibold text-n-slate-12">{{ name }}</h1>
|
||||
<p class="text-xs text-n-slate-10">{{ title }}</p>
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div :class="containerClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onErrorCaptured } from 'vue';
|
||||
|
||||
// Per-story error boundary. Keyed by the story path in App.vue, so it remounts
|
||||
// (and resets) on navigation. Containing the error here keeps one broken story
|
||||
// from tearing down the whole harness, mirroring Histoire's per-story isolation.
|
||||
defineProps({
|
||||
component: { type: [Object, Function], default: null },
|
||||
});
|
||||
|
||||
const error = ref(null);
|
||||
onErrorCaptured(err => {
|
||||
error.value = err;
|
||||
return false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="error" class="flex flex-col h-full gap-3 p-6 overflow-auto">
|
||||
<p class="text-sm font-semibold text-n-ruby-11">
|
||||
This story failed to render.
|
||||
</p>
|
||||
<div
|
||||
class="p-3 font-mono text-xs whitespace-pre-wrap rounded-md text-n-slate-11 bg-n-alpha-1"
|
||||
>
|
||||
{{ error.stack || error.message }}
|
||||
</div>
|
||||
</div>
|
||||
<component :is="component" v-else />
|
||||
</template>
|
||||
@@ -1,76 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
|
||||
// Recursive sidebar node. Each instance owns the collapsed state of its own
|
||||
// direct child groups, so nesting just works. Plain buttons/divs (no <ul>/<li>)
|
||||
// avoid the list markers the dashboard stylesheet injects.
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, required: true },
|
||||
selectedPath: { type: String, default: '' },
|
||||
forceExpand: { type: Boolean, default: false },
|
||||
depth: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
defineEmits(['select']);
|
||||
|
||||
// Default: top-level (L1) groups open, everything deeper collapsed.
|
||||
// `overrides` holds the explicit open/closed state once the user toggles a group.
|
||||
const overrides = reactive({});
|
||||
const isOpen = name => {
|
||||
if (props.forceExpand) return true;
|
||||
if (name in overrides) return overrides[name];
|
||||
return props.depth === 0;
|
||||
};
|
||||
const toggle = name => {
|
||||
overrides[name] = !isOpen(name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-px">
|
||||
<template v-for="node in nodes" :key="node.name">
|
||||
<div v-if="node.type === 'group'" class="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center w-full gap-1.5 px-2 py-1 rounded-md group text-n-slate-11 hover:bg-n-alpha-1"
|
||||
@click="toggle(node.name)"
|
||||
>
|
||||
<span
|
||||
class="transition-transform i-lucide-chevron-right size-3.5 shrink-0 text-n-slate-10"
|
||||
:class="{ 'rotate-90': isOpen(node.name) }"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide uppercase truncate text-n-slate-10"
|
||||
>
|
||||
{{ node.name }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-show="isOpen(node.name)"
|
||||
class="ml-2.5 pl-2 border-l border-n-weak"
|
||||
>
|
||||
<TreeNode
|
||||
:nodes="node.children"
|
||||
:selected-path="selectedPath"
|
||||
:force-expand="forceExpand"
|
||||
:depth="depth + 1"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="block w-full px-2 py-1 ml-1 text-sm text-left truncate transition-colors rounded-md"
|
||||
:class="
|
||||
node.path === selectedPath
|
||||
? 'bg-n-brand/10 text-n-brand font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
|
||||
"
|
||||
@click="$emit('select', node.path)"
|
||||
>
|
||||
{{ node.name }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const layout = inject('storyLayout', null);
|
||||
|
||||
// Normalizes layout sizes: numbers and bare numeric strings -> px,
|
||||
// everything else (e.g. '100%', '800px') passes through unchanged.
|
||||
function normalizeSize(value) {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (typeof value === 'number') return `${value}px`;
|
||||
return /^\d+$/.test(value) ? `${value}px` : value;
|
||||
}
|
||||
|
||||
const cellStyle = computed(() => {
|
||||
const current = layout?.value ?? {};
|
||||
return {
|
||||
width: normalizeSize(current.width),
|
||||
height: normalizeSize(current.height),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-n-weak bg-n-solid-1"
|
||||
:style="cellStyle"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 text-sm font-medium border-b text-n-slate-12 border-n-weak bg-n-alpha-1"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="flex-1 p-4 bg-n-background">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>@chatwoot/design</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import Story from './Story.vue';
|
||||
import Variant from './Variant.vue';
|
||||
import { setupApp } from './setup';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// Story files reference <Story> and <Variant> as globals (same as Histoire).
|
||||
app.component('Story', Story);
|
||||
app.component('Variant', Variant);
|
||||
|
||||
setupApp(app);
|
||||
|
||||
app.mount('#app');
|
||||
@@ -1,61 +0,0 @@
|
||||
// Lazy component loaders for every *.story.vue file under app/javascript.
|
||||
const storyModules = import.meta.glob('../**/*.story.vue');
|
||||
|
||||
// Raw sources, eagerly loaded, so we can read each story's title without
|
||||
// instantiating the (potentially heavy) component just to build the sidebar.
|
||||
const storySources = import.meta.glob('../**/*.story.vue', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
});
|
||||
|
||||
function parseTitle(source) {
|
||||
const match = source.match(/<Story\b[^>]*?\btitle\s*=\s*"([^"]+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export const stories = Object.keys(storyModules)
|
||||
.map(filePath => ({
|
||||
path: filePath,
|
||||
title: parseTitle(storySources[filePath] || ''),
|
||||
loader: storyModules[filePath],
|
||||
}))
|
||||
.filter(story => story.title)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
function insert(nodes, parts, story) {
|
||||
const [head, ...rest] = parts;
|
||||
if (rest.length === 0) {
|
||||
nodes.push({ type: 'story', name: head, path: story.path });
|
||||
return;
|
||||
}
|
||||
let group = nodes.find(node => node.type === 'group' && node.name === head);
|
||||
if (!group) {
|
||||
group = { type: 'group', name: head, children: [] };
|
||||
nodes.push(group);
|
||||
}
|
||||
insert(group.children, rest, story);
|
||||
}
|
||||
|
||||
// Sorts each level so groups come before individual stories, both alphabetically.
|
||||
function sortNodes(nodes) {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'group' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
if (node.type === 'group') sortNodes(node.children);
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Builds a nested tree from the slash-delimited story titles
|
||||
// (e.g. "Components/Button" -> group "Components" > story "Button").
|
||||
export function buildTree(items) {
|
||||
const roots = [];
|
||||
items.forEach(story => {
|
||||
const parts = story.title.split('/').map(part => part.trim());
|
||||
insert(roots, parts, story);
|
||||
});
|
||||
return sortNodes(roots);
|
||||
}
|
||||
@@ -34,13 +34,24 @@ body {
|
||||
|
||||
.message-content {
|
||||
ul {
|
||||
list-style: disc;
|
||||
@apply ltr:pl-3 rtl:pr-3;
|
||||
@apply list-disc list-inside;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
@apply ltr:pl-4 rtl:pr-4;
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-inline-start: 1.5em;
|
||||
text-indent: -1.5em;
|
||||
|
||||
> p:first-child {
|
||||
@apply inline;
|
||||
}
|
||||
|
||||
> * {
|
||||
text-indent: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -54,8 +54,7 @@ module ConversationReplyMailerHelper
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
open_timeout: 15,
|
||||
read_timeout: 15,
|
||||
**smtp_timeout_settings,
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
end
|
||||
@@ -72,6 +71,7 @@ module ConversationReplyMailerHelper
|
||||
tls: @channel.smtp_enable_ssl_tls,
|
||||
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
|
||||
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
|
||||
**smtp_timeout_settings,
|
||||
authentication: @channel.smtp_authentication
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ module ConversationReplyMailerHelper
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def smtp_timeout_settings
|
||||
{
|
||||
open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
|
||||
read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
|
||||
}.transform_values(&:to_i)
|
||||
end
|
||||
|
||||
def email_smtp_enabled?
|
||||
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
end
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -42,6 +42,7 @@ class Portal < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: true
|
||||
validates :custom_domain, uniqueness: true, allow_nil: true
|
||||
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
|
||||
validate :config_json_format
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
@@ -14,7 +14,7 @@ class Conversations::MessageWindowService
|
||||
|
||||
private
|
||||
|
||||
def messaging_window
|
||||
def messaging_window # rubocop:disable Metrics/CyclomaticComplexity
|
||||
case @conversation.inbox.channel_type
|
||||
when 'Channel::Api'
|
||||
api_messaging_window
|
||||
@@ -28,6 +28,10 @@ class Conversations::MessageWindowService
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
twilio_messaging_window
|
||||
when 'Channel::GooglePlay'
|
||||
# Google Play API only allows replying to reviews left within the last 7 days.
|
||||
# https://developers.google.com/android-publisher/reviews
|
||||
MESSAGING_WINDOW_7_DAYS
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Builds a contact, conversation and messages from a single Google Play review.
|
||||
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
|
||||
# their review, a fresh incoming message is appended to the same conversation.
|
||||
# Developer replies (whether posted via Chatwoot or directly in Play Console) are mirrored
|
||||
# as outgoing messages, with a source_id that matches what `SendOnGooglePlayService`
|
||||
# produces so Chatwoot-originated replies are deduped on the next poll.
|
||||
class GooglePlay::ReviewBuilder
|
||||
pattr_initialize [:review!, :channel!]
|
||||
|
||||
def perform
|
||||
return if user_comment.blank? || review_text.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact_inbox
|
||||
build_conversation
|
||||
build_user_message
|
||||
build_developer_message if developer_comment.present?
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= channel.inbox
|
||||
end
|
||||
|
||||
def user_comment
|
||||
@user_comment ||= comments_with_key('userComment').first
|
||||
end
|
||||
|
||||
def developer_comment
|
||||
@developer_comment ||= comments_with_key('developerComment').first
|
||||
end
|
||||
|
||||
def comments_with_key(key)
|
||||
Array(review['comments']).filter_map { |comment| comment[key] }
|
||||
end
|
||||
|
||||
def review_id
|
||||
review['reviewId']
|
||||
end
|
||||
|
||||
def review_text
|
||||
@review_text ||= user_comment['text'].to_s.strip
|
||||
end
|
||||
|
||||
def star_rating
|
||||
@star_rating ||= user_comment['starRating'].to_i
|
||||
end
|
||||
|
||||
# A new lastModified timestamp (edited review) yields a new message in the same conversation
|
||||
def user_message_source_id
|
||||
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
# Must match the format `Channel::GooglePlay#reply_to_review` returns so replies sent through
|
||||
# Chatwoot are not duplicated when the review is re-fetched.
|
||||
def developer_message_source_id
|
||||
"#{review_id}::reply::#{developer_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: review_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: {
|
||||
name: review['authorName'].presence || 'Google Play User',
|
||||
additional_attributes: { source_id: "google_play:#{review_id}" }
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { source: 'google_play', app_id: channel.app_id }
|
||||
)
|
||||
end
|
||||
|
||||
def build_user_message
|
||||
return if @conversation.messages.exists?(source_id: user_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @conversation.contact,
|
||||
message_type: :incoming,
|
||||
source_id: user_message_source_id,
|
||||
content: message_content,
|
||||
content_attributes: review_metadata
|
||||
)
|
||||
end
|
||||
|
||||
def build_developer_message
|
||||
text = developer_comment['text'].to_s.strip
|
||||
return if text.blank?
|
||||
return if @conversation.messages.exists?(source_id: developer_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
source_id: developer_message_source_id,
|
||||
content: text,
|
||||
status: :sent
|
||||
)
|
||||
end
|
||||
|
||||
def message_content
|
||||
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
|
||||
[
|
||||
"#{stars} (#{star_rating}/5)",
|
||||
review_text,
|
||||
review_footer
|
||||
].compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
# A compact line of context shown beneath the review text — device, app version, OS.
|
||||
def review_footer
|
||||
parts = [device_display, app_version_display, os_version_display].compact_blank
|
||||
return nil if parts.empty?
|
||||
|
||||
parts.join(' • ')
|
||||
end
|
||||
|
||||
def device_display
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
metadata['productName'].presence || user_comment['device']
|
||||
end
|
||||
|
||||
def app_version_display
|
||||
version = user_comment['appVersionName']
|
||||
version.present? ? "v#{version}" : nil
|
||||
end
|
||||
|
||||
def os_version_display
|
||||
version = user_comment['androidOsVersion']
|
||||
version.present? ? "Android #{version}" : nil
|
||||
end
|
||||
|
||||
def review_metadata
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
{
|
||||
google_play: {
|
||||
star_rating: star_rating,
|
||||
app_version: user_comment['appVersionName'],
|
||||
device: device_display,
|
||||
manufacturer: metadata['manufacturer'],
|
||||
android_os_version: user_comment['androidOsVersion'],
|
||||
reviewer_language: user_comment['reviewerLanguage']
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class GooglePlay::SendOnGooglePlayService < Base::SendOnChannelService
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::GooglePlay
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
source_id = channel.reply_to_review(message.conversation.contact_inbox.source_id, message.content)
|
||||
message.update!(source_id: source_id) if source_id.present?
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
|
||||
message.update!(status: :failed, external_error: e.message.to_s.truncate(255))
|
||||
end
|
||||
end
|
||||
@@ -58,8 +58,9 @@ class Imap::BaseFetchEmailService
|
||||
|
||||
return if email_already_present?(channel, message_id)
|
||||
|
||||
# Fetch the original mail content using the sequence no
|
||||
mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
|
||||
# Fetch the original mail content using the sequence no.
|
||||
# BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers.
|
||||
mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]']
|
||||
|
||||
if mail_str.blank?
|
||||
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -128,6 +128,10 @@
|
||||
<path d="M18.3 10.2H19.2C19.4387 10.2 19.6676 10.2948 19.8364 10.4636C20.0052 10.6324 20.1 10.8613 20.1 11.1V20.1C20.1 20.3387 20.0052 20.5676 19.8364 20.7364C19.6676 20.9052 19.4387 21 19.2 21H4.80002C4.56133 21 4.33241 20.9052 4.16363 20.7364C3.99485 20.5676 3.90002 20.3387 3.90002 20.1V11.1C3.90002 10.8613 3.99485 10.6324 4.16363 10.4636C4.33241 10.2948 4.56133 10.2 4.80002 10.2H5.70002V9.3C5.70002 8.47267 5.86298 7.65345 6.17958 6.88909C6.49619 6.12474 6.96024 5.43024 7.54525 4.84523C8.13026 4.26022 8.82477 3.79616 9.58912 3.47956C10.3535 3.16295 11.1727 3 12 3C12.8274 3 13.6466 3.16295 14.4109 3.47956C15.1753 3.79616 15.8698 4.26022 16.4548 4.84523C17.0398 5.43024 17.5039 6.12474 17.8205 6.88909C18.1371 7.65345 18.3 8.47267 18.3 9.3V10.2ZM5.70002 12V19.2H18.3V12H5.70002ZM11.1 13.8H12.9V17.4H11.1V13.8ZM16.5 10.2V9.3C16.5 8.10653 16.0259 6.96193 15.182 6.11802C14.3381 5.27411 13.1935 4.8 12 4.8C10.8066 4.8 9.66196 5.27411 8.81804 6.11802C7.97413 6.96193 7.50002 8.10653 7.50002 9.3V10.2H16.5Z" fill="currentColor"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-voice-line" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor"><path d="M12.75 4.50031C14.5402 4.50031 16.2571 5.21146 17.523 6.47733C18.7888 7.7432 19.5 9.46009 19.5 11.2503" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.75 7.5C13.7446 7.5 14.6984 7.89509 15.4017 8.59835C16.1049 9.30161 16.5 10.2554 16.5 11.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.374 15.4263C13.5289 15.4974 13.7034 15.5137 13.8688 15.4724C14.0341 15.4311 14.1805 15.3347 14.2837 15.1991L14.55 14.8503C14.6897 14.664 14.8709 14.5128 15.0792 14.4087C15.2875 14.3045 15.5171 14.2503 15.75 14.2503H18C18.3978 14.2503 18.7794 14.4083 19.0607 14.6896C19.342 14.9709 19.5 15.3525 19.5 15.7503V18.0003C19.5 18.3981 19.342 18.7797 19.0607 19.061C18.7794 19.3423 18.3978 19.5003 18 19.5003C14.4196 19.5003 10.9858 18.078 8.45406 15.5462C5.92232 13.0145 4.5 9.58073 4.5 6.00031C4.5 5.60248 4.65804 5.22095 4.93934 4.93964C5.22064 4.65834 5.60218 4.50031 6 4.50031H8.25C8.64782 4.50031 9.02935 4.65834 9.31066 4.93964C9.59196 5.22095 9.75 5.60248 9.75 6.00031V8.2503C9.75 8.48317 9.69578 8.71284 9.59164 8.92113C9.4875 9.12941 9.33629 9.31058 9.15 9.45031L8.799 9.71355C8.66131 9.81869 8.56426 9.96824 8.52434 10.1368C8.48442 10.3054 8.50409 10.4826 8.58 10.6383C9.60501 12.7202 11.2908 14.4039 13.374 15.4263Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-captain" viewBox="0 0 24 24">
|
||||
<path d="M7.02051 9.50216C7.02051 9.01881 7.41237 8.62695 7.89571 8.62695C8.37909 8.62695 8.77091 9.01881 8.77091 9.50216V11.5248C8.77091 12.0082 8.37909 12.4 7.89571 12.4C7.41237 12.4 7.02051 12.0082 7.02051 11.5248V9.50216Z" fill="currentColor"/>
|
||||
<path d="M9.82117 9.50216C9.82117 9.01881 10.213 8.62695 10.6964 8.62695C11.1798 8.62695 11.5716 9.01881 11.5716 9.50216V11.5248C11.5716 12.0082 11.1798 12.4 10.6964 12.4C10.213 12.4 9.82117 12.0082 9.82117 11.5248V9.50216Z" fill="currentColor"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 46 KiB |
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ module Captain::ChatGenerationRecorder
|
||||
# Create a generation span with model and token info for Langfuse cost calculation.
|
||||
# Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation.
|
||||
tracer.in_span("llm.captain.#{feature_name}.generation") do |span|
|
||||
apply_current_langfuse_attributes(span)
|
||||
set_generation_span_attributes(span, chat, message)
|
||||
end
|
||||
rescue StandardError => e
|
||||
@@ -37,11 +38,23 @@ module Captain::ChatGenerationRecorder
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens,
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat),
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil,
|
||||
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
|
||||
}
|
||||
end
|
||||
|
||||
def format_input_messages(chat)
|
||||
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
|
||||
end
|
||||
|
||||
def generation_stage(message)
|
||||
message_has_tool_calls?(message) ? 'tool_call' : 'final_response'
|
||||
end
|
||||
|
||||
def message_has_tool_calls?(message)
|
||||
return false unless message.respond_to?(:tool_calls)
|
||||
|
||||
tool_calls = message.tool_calls
|
||||
tool_calls.respond_to?(:any?) && tool_calls.any?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
BASE_URL = 'https://api.firecrawl.dev/v2'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def self.configured?
|
||||
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
|
||||
def crawl_payload(url, webhook_url, crawl_limit)
|
||||
{
|
||||
url: url,
|
||||
maxDepth: 50,
|
||||
ignoreSitemap: false,
|
||||
maxDiscoveryDepth: 50,
|
||||
sitemap: 'include',
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
webhook: { url: webhook_url },
|
||||
scrapeOptions: scrape_options
|
||||
}.to_json
|
||||
end
|
||||
@@ -51,7 +51,8 @@ class Captain::Tools::FirecrawlService
|
||||
{
|
||||
onlyMainContent: true,
|
||||
formats: ['markdown'],
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS,
|
||||
maxAge: 0
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineConfig } from 'histoire';
|
||||
import { HstVue } from '@histoire/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
setupFile: './histoire.setup.ts',
|
||||
plugins: [HstVue()],
|
||||
collectMaxThreads: 4,
|
||||
vite: {
|
||||
server: {
|
||||
port: 6179,
|
||||
},
|
||||
},
|
||||
viteIgnorePlugins: ['vite-plugin-ruby'],
|
||||
theme: {
|
||||
darkClass: 'dark',
|
||||
title: '@chatwoot/design',
|
||||
logo: {
|
||||
square: './design-system/images/logo-thumbnail.svg',
|
||||
light: './design-system/images/logo.png',
|
||||
dark: './design-system/images/logo-dark.png',
|
||||
},
|
||||
},
|
||||
defaultStoryProps: {
|
||||
icon: 'carbon:cube',
|
||||
iconColor: '#1F93FF',
|
||||
layout: {
|
||||
type: 'grid',
|
||||
width: '80%',
|
||||
},
|
||||
},
|
||||
tree: {
|
||||
groups: [
|
||||
{
|
||||
id: 'top',
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
id: 'components',
|
||||
title: 'Components',
|
||||
include: () => true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
def image(node)
|
||||
src, title = extract_img_attributes(node)
|
||||
height = extract_image_height(src)
|
||||
sizing_style = extract_image_sizing_style(src)
|
||||
|
||||
render_img_tag(src, title, height)
|
||||
render_img_tag(src, title, sizing_style)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
]
|
||||
end
|
||||
|
||||
def extract_image_height(src)
|
||||
# Drag-resize from the reply editor encodes the chosen width as cw_image_width
|
||||
# on the URL; the older message-signature picker uses cw_image_height. Width
|
||||
# wins when both are set so the agent's most recent intent is honored.
|
||||
def extract_image_sizing_style(src)
|
||||
query_params = parse_query_params(src)
|
||||
query_params['cw_image_height']&.first
|
||||
width = sanitize_pixel_value(query_params['cw_image_width']&.first)
|
||||
return "width: #{width}; max-width: 100%; height: auto;" if width
|
||||
|
||||
height = sanitize_pixel_value(query_params['cw_image_height']&.first)
|
||||
height ? "height: #{height};" : nil
|
||||
end
|
||||
|
||||
# Only allow a bounded `<digits>px` value so the decoded query param can't
|
||||
# break out of the inline style attribute (HTML attribute injection).
|
||||
def sanitize_pixel_value(raw)
|
||||
return unless raw =~ /\A(\d+)px\z/
|
||||
|
||||
px = Regexp.last_match(1).to_i
|
||||
"#{px}px" if px.between?(1, 2000)
|
||||
end
|
||||
|
||||
def parse_query_params(url)
|
||||
@@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
|
||||
{}
|
||||
end
|
||||
|
||||
def render_img_tag(src, title, height = nil)
|
||||
def render_img_tag(src, title, sizing_style = nil)
|
||||
title_attribute = title.present? ? " title=\"#{title}\"" : ''
|
||||
# Use inline style instead of the HTML height attribute: email clients and
|
||||
# the in-app Letter view both run images through CSS (e.g. prose /
|
||||
# Use inline style instead of HTML width/height attributes: email clients
|
||||
# and the in-app Letter view both run images through CSS (e.g. prose /
|
||||
# lettersanitizer's `img { height: auto }`) which overrides presentational
|
||||
# attributes. Inline style has higher specificity and survives.
|
||||
style_attribute = height ? " style=\"height: #{height};\"" : ''
|
||||
style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : ''
|
||||
|
||||
plain do
|
||||
# plain ensures that the content is not wrapped in a paragraph tag
|
||||
|
||||
@@ -10,12 +10,14 @@ module Captain::ToolInstrumentation
|
||||
|
||||
response = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_tool_session_attributes(span, params)
|
||||
response = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
|
||||
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
|
||||
with_propagated_langfuse_attributes(params) do
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_tool_session_attributes(span, params)
|
||||
response = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
|
||||
set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
response
|
||||
rescue StandardError => e
|
||||
@@ -24,9 +26,7 @@ module Captain::ToolInstrumentation
|
||||
end
|
||||
|
||||
def set_tool_session_attributes(span, params)
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
set_metadata_attributes(span, params)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
end
|
||||
|
||||
@@ -43,6 +43,7 @@ module Captain::ToolInstrumentation
|
||||
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
|
||||
|
||||
tracer.in_span("llm.#{event_name}.generation") do |span|
|
||||
apply_current_langfuse_attributes(span)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
|
||||
|
||||
@@ -29,16 +29,18 @@ module Integrations::LlmInstrumentation
|
||||
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
with_propagated_langfuse_attributes(params) do
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
|
||||
# By default, the input and output of a trace are set from the root observation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
result = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
# By default, the input and output of a trace are set from the root observation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
result = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
set_error_attributes(span, result) if result.is_a?(Hash)
|
||||
result
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
@@ -51,6 +53,7 @@ module Integrations::LlmInstrumentation
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
||||
apply_current_langfuse_attributes(span)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
||||
result = yield
|
||||
@@ -96,23 +99,6 @@ module Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_with_span(span_name, params, &)
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(span_name) do |span|
|
||||
track_result = lambda do |r|
|
||||
executed = true
|
||||
result = r
|
||||
end
|
||||
yield(span, track_result)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
raise unless executed
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_account(params)
|
||||
|
||||
@@ -10,7 +10,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_audio_transcription_span_attributes(span, params)
|
||||
@@ -18,7 +17,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
|
||||
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_moderation_span_attributes(span, params)
|
||||
@@ -26,12 +24,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
|
||||
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_common_span_metadata(span, params)
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
|
||||
end
|
||||
|
||||
def set_embedding_result_attributes(span, result)
|
||||
|
||||
@@ -29,4 +29,5 @@ module Integrations::LlmInstrumentationConstants
|
||||
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
||||
ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationContext
|
||||
LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes
|
||||
LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes
|
||||
|
||||
private
|
||||
|
||||
def with_propagated_langfuse_attributes(params)
|
||||
previous_attributes = current_langfuse_attributes
|
||||
previous_observation_metadata_attributes = current_observation_metadata_attributes
|
||||
self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params))
|
||||
self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))
|
||||
|
||||
yield
|
||||
ensure
|
||||
self.current_langfuse_attributes = previous_attributes
|
||||
self.current_observation_metadata_attributes = previous_observation_metadata_attributes
|
||||
end
|
||||
|
||||
def apply_current_langfuse_attributes(span)
|
||||
set_langfuse_attributes(span, current_langfuse_attributes)
|
||||
set_langfuse_attributes(span, current_observation_metadata_attributes)
|
||||
end
|
||||
|
||||
def current_langfuse_attributes
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {}
|
||||
end
|
||||
|
||||
def current_langfuse_attributes=(attrs)
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs
|
||||
end
|
||||
|
||||
def current_observation_metadata_attributes
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {}
|
||||
end
|
||||
|
||||
def current_observation_metadata_attributes=(attrs)
|
||||
ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
module Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationContext
|
||||
include Integrations::LlmInstrumentationCompletionHelpers
|
||||
|
||||
def determine_provider(model_name)
|
||||
@@ -51,15 +52,55 @@ module Integrations::LlmInstrumentationHelpers
|
||||
end
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params)))
|
||||
set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)))
|
||||
end
|
||||
|
||||
return unless params[:metadata].is_a?(Hash)
|
||||
def propagated_langfuse_attributes(params)
|
||||
attrs = {}
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
|
||||
attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id]
|
||||
attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present?
|
||||
attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present?
|
||||
|
||||
return attrs unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def propagated_observation_metadata_attributes(params)
|
||||
attrs = {}
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
|
||||
add_observation_metadata(attrs, 'user_id', params[:account_id])
|
||||
add_observation_metadata(attrs, 'account_id', params[:account_id])
|
||||
add_observation_metadata(attrs, 'session_id', session_id)
|
||||
add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json)
|
||||
add_observation_metadata(attrs, 'feature_name', params[:feature_name])
|
||||
|
||||
return attrs unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
add_observation_metadata(attrs, key, value)
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def add_observation_metadata(attrs, key, value)
|
||||
return if value.blank?
|
||||
|
||||
attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s
|
||||
end
|
||||
|
||||
def set_langfuse_attributes(span, attrs)
|
||||
attrs.each do |key, value|
|
||||
span.set_attribute(key, value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
||||
apply_current_langfuse_attributes(span)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
||||
|
||||
@@ -61,6 +62,24 @@ module Integrations::LlmInstrumentationSpans
|
||||
Rails.logger.warn "Failed to end tool span: #{e.message}"
|
||||
end
|
||||
|
||||
def instrument_with_span(span_name, params, &)
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(span_name) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
track_result = lambda do |r|
|
||||
executed = true
|
||||
result = r
|
||||
end
|
||||
yield(span, track_result)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
raise unless executed
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_llm_turn_request_attributes(span, params)
|
||||
|
||||
@@ -72,7 +72,10 @@ module OpentelemetryConfig
|
||||
|
||||
config = {
|
||||
endpoint: traces_endpoint,
|
||||
headers: { 'Authorization' => "Basic #{auth_header}" }
|
||||
headers: {
|
||||
'Authorization' => "Basic #{auth_header}",
|
||||
'x-langfuse-ingestion-version' => '4'
|
||||
}
|
||||
}
|
||||
|
||||
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
|
||||
|
||||
+6
-4
@@ -15,9 +15,9 @@
|
||||
"build:sdk": "vite build --config vite.lib.config.ts",
|
||||
"prepare": "husky install",
|
||||
"size": "size-limit",
|
||||
"story:dev": "vite --config vite.stories.config.ts",
|
||||
"story:build": "vite build --config vite.stories.config.ts",
|
||||
"story:preview": "vite preview --config vite.stories.config.ts",
|
||||
"story:dev": "histoire dev",
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview",
|
||||
"sync:i18n": "bin/sync_i18n_file_change"
|
||||
},
|
||||
"size-limit": [
|
||||
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.13",
|
||||
"@chatwoot/prosemirror-schema": "1.3.17",
|
||||
"@chatwoot/utils": "^0.0.55",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -114,6 +114,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@egoist/tailwindcss-icons": "^1.9.2",
|
||||
"@histoire/plugin-vue": "0.17.15",
|
||||
"@iconify-json/logos": "^1.2.10",
|
||||
"@iconify-json/lucide": "^1.2.82",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
@@ -135,6 +136,7 @@
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"histoire": "0.17.15",
|
||||
"husky": "^7.0.0",
|
||||
"jsdom": "^27.2.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
|
||||
Generated
+1071
-31
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||
@@ -39,6 +39,30 @@ RSpec.describe Captain::Llm::AssistantChatService do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
|
||||
end
|
||||
|
||||
it 'marks final response generations for observation-level evaluators' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
message = instance_double(RubyLLM::Message, content: 'Final answer', input_tokens: 10, output_tokens: 20, tool_calls: {})
|
||||
|
||||
attributes = service.send(:generation_attributes, mock_chat, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
|
||||
end
|
||||
|
||||
it 'marks tool call generations separately from final responses' do
|
||||
service = described_class.new(assistant: assistant, conversation: conversation)
|
||||
message = instance_double(
|
||||
RubyLLM::Message,
|
||||
content: '',
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
tool_calls: { 'call_1' => instance_double(RubyLLM::ToolCall) }
|
||||
)
|
||||
|
||||
attributes = service.send(:generation_attributes, mock_chat, message)
|
||||
|
||||
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'image analysis' do
|
||||
|
||||
@@ -53,14 +53,15 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
let(:expected_payload) do
|
||||
{
|
||||
url: url,
|
||||
maxDepth: 50,
|
||||
ignoreSitemap: false,
|
||||
maxDiscoveryDepth: 50,
|
||||
sitemap: 'include',
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
webhook: { url: webhook_url },
|
||||
scrapeOptions: {
|
||||
onlyMainContent: true,
|
||||
formats: ['markdown'],
|
||||
excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
|
||||
excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS,
|
||||
maxAge: 0
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
@@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when the API call is successful' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
@@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
it 'makes a POST request with correct parameters' do
|
||||
service.perform(url, webhook_url, crawl_limit)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
@@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
it 'uses default crawl limit when not specified' do
|
||||
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
|
||||
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.with(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
@@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
service.perform(url, webhook_url)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.with(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
@@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when the API call fails' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.to_raise(StandardError.new('Connection failed'))
|
||||
end
|
||||
|
||||
@@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when the API returns an error response' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}')
|
||||
end
|
||||
|
||||
it 'makes the request but does not raise an error' do
|
||||
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
|
||||
@@ -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
|
||||
@@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do
|
||||
describe '#image' do
|
||||
context 'when image has a height' do
|
||||
it 'renders the img tag with the correct attributes' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg?cw_image_height=100" style="height: 100;" />')
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg?cw_image_height=100px" style="height: 100px;" />')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image has a width' do
|
||||
it 'renders the img tag with the correct attributes' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include(
|
||||
'<img src="https://example.com/image.jpg?cw_image_width=200px" style="width: 200px; max-width: 100%; height: auto;" />'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the sizing param contains an attribute-injection payload' do
|
||||
it 'drops the malicious height value' do
|
||||
markdown = ')'
|
||||
rendered = render_markdown(markdown)
|
||||
expect(rendered).not_to include('style=')
|
||||
expect(rendered).not_to include('onmouseover="')
|
||||
end
|
||||
|
||||
it 'drops the malicious width value' do
|
||||
markdown = ')'
|
||||
rendered = render_markdown(markdown)
|
||||
expect(rendered).not_to include('style=')
|
||||
expect(rendered).not_to include('onmouseover="')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -144,7 +144,10 @@ RSpec.describe Integrations::LlmInstrumentation do
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['reply_suggestion'])
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
|
||||
end
|
||||
|
||||
it 'sets completion message attributes when result contains message' do
|
||||
@@ -253,6 +256,76 @@ RSpec.describe Integrations::LlmInstrumentation do
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
||||
end
|
||||
|
||||
it 'propagates trace attributes as observation metadata to child tool spans' do
|
||||
root_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
tool_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
tool_instance = test_class.new
|
||||
allow(root_span).to receive(:set_attribute)
|
||||
allow(tool_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(tool_instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
|
||||
allow(mock_tracer).to receive(:in_span).with('tool.search').and_yield(tool_span)
|
||||
|
||||
instance.instrument_agent_session(params) do
|
||||
tool_instance.instrument_tool_call('search', { query: 'test' }) { 'tool result' }
|
||||
end
|
||||
|
||||
expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
|
||||
expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
|
||||
expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
|
||||
end
|
||||
|
||||
it 'keeps inherited session metadata for nested service spans with their own feature tag' do
|
||||
root_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
nested_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
nested_instance = test_class.new
|
||||
nested_params = params.merge(span_name: 'llm.translate_query', conversation_id: nil, feature_name: 'translate_query')
|
||||
allow(root_span).to receive(:set_attribute)
|
||||
allow(nested_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(nested_instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
|
||||
allow(mock_tracer).to receive(:in_span).with('llm.translate_query').and_yield(nested_span)
|
||||
|
||||
instance.instrument_agent_session(params) do
|
||||
nested_instance.instrument_llm_call(nested_params) { 'translated query' }
|
||||
end
|
||||
|
||||
expect(nested_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(nested_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['translate_query'])
|
||||
expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
|
||||
expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'translate_query')
|
||||
end
|
||||
|
||||
it 'propagates session metadata to nested embedding spans' do
|
||||
root_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
embedding_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
embedding_instance = test_class.new
|
||||
embedding_params = {
|
||||
span_name: 'llm.captain.embedding',
|
||||
account_id: 123,
|
||||
feature_name: 'embedding',
|
||||
model: 'text-embedding-3-small',
|
||||
input: 'search result'
|
||||
}
|
||||
allow(root_span).to receive(:set_attribute)
|
||||
allow(embedding_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(embedding_instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
|
||||
allow(mock_tracer).to receive(:in_span).with('llm.captain.embedding').and_yield(embedding_span)
|
||||
|
||||
instance.instrument_agent_session(params) do
|
||||
embedding_instance.instrument_embedding_call(embedding_params) { [0.1, 0.2, 0.3] }
|
||||
end
|
||||
|
||||
expect(embedding_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(embedding_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['embedding'])
|
||||
expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
|
||||
expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'embedding')
|
||||
end
|
||||
|
||||
# Regression test for Langfuse double-counting bug.
|
||||
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
|
||||
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
|
||||
|
||||
@@ -462,6 +462,26 @@ RSpec.describe ConversationReplyMailer do
|
||||
expect(mail.delivery_method.settings.empty?).to be false
|
||||
expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com'
|
||||
expect(mail.delivery_method.settings[:port]).to eq 587
|
||||
expect(mail.delivery_method.settings[:open_timeout]).to eq 15
|
||||
expect(mail.delivery_method.settings[:read_timeout]).to eq 30
|
||||
end
|
||||
|
||||
it 'uses configured smtp timeout values' do
|
||||
with_modified_env SMTP_OPEN_TIMEOUT: '10', SMTP_READ_TIMEOUT: '30' do
|
||||
mail = described_class.email_reply(message)
|
||||
|
||||
expect(mail.delivery_method.settings[:open_timeout]).to eq 10
|
||||
expect(mail.delivery_method.settings[:read_timeout]).to eq 30
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses default smtp timeout values when env values are blank' do
|
||||
with_modified_env SMTP_OPEN_TIMEOUT: '', SMTP_READ_TIMEOUT: '' do
|
||||
mail = described_class.email_reply(message)
|
||||
|
||||
expect(mail.delivery_method.settings[:open_timeout]).to eq 15
|
||||
expect(mail.delivery_method.settings[:read_timeout]).to eq 30
|
||||
end
|
||||
end
|
||||
|
||||
it 'renders sender name in the from address' do
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user