Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 343466b9c9 Merge branch 'develop' into feat/google-play-reviews 2026-06-04 12:55:57 +04:00
cd9192f7d1 chore(captain): update Firecrawl to use the v2 API (#14624)
## Description

Migrates Firecrawl from the v1 to the v2 API.
`Captain::Tools::FirecrawlService` now targets `api.firecrawl.dev/v2`,
with the request body updated to match the v2 schema.

> Disclosure: I work at Firecrawl.

Fixes # (n/a)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Updated
`spec/enterprise/services/captain/tools/firecrawl_service_spec.rb` to
assert the v2 endpoint and request body.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes

---------

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-06-03 23:47:49 +05:30
Aakash BakhleandGitHub eaffad12e7 feat(langfuse): propagate observation metadata for evals (#14634)
# Pull Request Template

## Description

We need to pass on trace level attributes down to the spans inside them
like tool calls, observations, etc.
This way, we can filter observations based on trace level attributes.


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Attributes added to observation metadata for easy filtering
<img width="1327" height="708" alt="image"
src="https://github.com/user-attachments/assets/8f1d1bf8-cde4-481d-a2c2-7920ad2fc52e"
/>

added a `generation_stage` to differentiate llm_calls that call tools vs
those that generate a `final_response`
<img width="1806" height="968" alt="CleanShot 2026-06-03 at 15 11 09@2x"
src="https://github.com/user-attachments/assets/db1fa8e0-7f2d-404b-a719-27a16d400442"
/>


propagated attributes to tool calls for future use
<img width="903" height="517" alt="image"
src="https://github.com/user-attachments/assets/edc61ce8-93db-465c-a66e-043138e2dc15"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:45:19 +05:30
Sivin VargheseandGitHub 18ef019cd4 fix: improve article editor typography & nested lists (#14572) 2026-06-03 16:11:32 +05:30
Tanmay Deep SharmaandGitHub ea910227ac feat: add Voice Calls feature card to settings showcase (#14635)
## Linear Ticket 

https://linear.app/chatwoot/issue/CW-7250/add-voice-calls-to-self-hosted-super-admin-feature-list

## Description

Adds a **Voice Calls** card to the Super Admin → Settings → Features
showcase so self-hostedadmins can see at a glance whether voice calling
is available on the installation.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## Screenshots?

<img width="1512" height="843" alt="Screenshot 2026-06-03 at 3 15 22 PM"
src="https://github.com/user-attachments/assets/988db14b-fef1-4b72-a55e-c7a8884521dd"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:01:14 +05:30
Tanmay Deep SharmaandGitHub 94ddd98050 chore: update the voice call ringtone (#14636)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7260/update-the-voice-call-ringtone

## Description

Updates the incoming voice call ringtone. The dashboard call widget now
plays the new tone while an inbound call is unanswered, replacing the
previous bell sound.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-06-03 16:00:04 +05:30
Tanmay Deep SharmaandGitHub 0e87519ecd feat: add mute button for Twilio calls (#14637)
## Linear Ticket

https://linear.app/chatwoot/issue/CW-7264/add-mute-button-in-twilio-calls

## Description

Adds mute support for Twilio voice calls. Previously the mute button was
shown only for WhatsApp calls (which toggle the local mic track in the
browser), so Twilio calls had no way to mute. The call widget now routes
mute by provider: WhatsApp continues to toggle the local mic track,
while Twilio uses the Voice SDK connection's native `mute()`. The mute
button is now shown for any active call, and unmute works symmetrically.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-03 15:59:23 +05:30
Tanmay Deep SharmaandGitHub de89391031 fix: use hang-up handset icon for reject/end call button (#14640)
## Linear Ticket
https://linear.app/chatwoot/issue/CW-7261/call-window-theme-alignment

## Description

Updates the call window's reject/end button to use the hang-up handset
icon (the same handset as the accept button, rotated) instead of the
phone-with-X, matching the design.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## Screenshots

<img width="346" height="135" alt="Screenshot 2026-06-03 at 3 41 10 PM"
src="https://github.com/user-attachments/assets/4d507141-332e-436a-a6ec-516964a31013"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
2026-06-03 15:59:12 +05:30
Sojan JoseandGitHub 8e42307bdc fix: improve email inbox IMAP and SMTP compatibility (#14589)
Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to
avoid provider-specific parser failures while preserving unread state.
This also applies the existing SMTP timeout configuration to custom SMTP
email-channel replies, so provider SMTP responses have enough time to
complete.

Fixes: https://github.com/chatwoot/chatwoot/issues/12762

## Why

Some IMAP providers can return responses for `FETCH RFC822` that Ruby
`net-imap` fails to parse with:

`Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)`

We reproduced this with iCloud IMAP. Authentication, `INBOX` selection,
and header fetches worked, but fetching full message content with
`RFC822` failed before Chatwoot received a `Mail::Message`.

The same mailbox successfully returned full message content when fetched
with `BODY.PEEK[]`.

> During end-to-end iCloud validation, inbound fetch worked after the
IMAP change, but outbound replies through the custom SMTP settings could
still fail with a socket read timeout. The OAuth SMTP path already used
explicit SMTP timeout values; the custom SMTP path was relying on mailer
defaults instead.

## What this change does

- Replaces the full message fetch from `RFC822` to `BODY.PEEK[]`
- Reads the returned message content from `BODY[]`, which is how
`net-imap` exposes the response attribute
- Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged
- Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP
email-channel replies
- Defaults custom SMTP reply delivery to `open_timeout: 15` and
`read_timeout: 30`
- Updates IMAP service specs for standard and Microsoft IMAP fetch flows
- Updates mailer specs for custom SMTP timeout settings

`BODY.PEEK[]` is preferable here because it fetches the full message
content without marking messages as read.

## Validation

- Configured a local email inbox against iCloud IMAP and SMTP
- Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError:
unexpected RPAR (expected ATOM or NIL)`
- Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox
successfully
- Confirmed Chatwoot imports iCloud messages after the IMAP change
- Sent two outbound replies from the Chatwoot UI through iCloud SMTP
after applying the timeout settings
- Confirmed both UI-created outbound messages were marked `sent`, had
iCloud SMTP `source_id` values, and had no `external_error`
- Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb
spec/services/imap/microsoft_fetch_email_service_spec.rb`
- Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb`
2026-06-03 15:56:54 +05:30
1beaa284c6 feat: inline images in website and email channels (#14516)
# Pull Request Template

## Description

This PR adds support for inline image uploads in the reply editor for
Email and Website (chat widget) channels.

Agents can now insert images inline between text and resize them
directly in the editor by dragging the bottom corner, similar to the
help center editor experience.

Image sizes are preserved through markdown using the `cw_image_width`
URL param and render correctly in both outgoing emails and chat widget
messages.

Agents can also paste copied images directly into Email or Website
replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image
gets inserted inline at the cursor position and supports resizing just
like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains
unchanged and continues to add images as attachments, so both inline and
attachment flows are supported.


### Prosemirror repo PR:
https://github.com/chatwoot/prosemirror-schema/pull/48

Fixes
https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email

https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screencast



https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf

<img width="414" height="654" alt="image"
src="https://github.com/user-attachments/assets/205e0729-8f2d-4cc5-9c55-7696f032eca4"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-06-03 15:05:17 +05:30
Sivin VargheseandGitHub d028cc1984 fix: prevent list marker overflow in messages (#14618) 2026-06-03 14:18:48 +05:30
Sivin VargheseandGitHub 0d59fb4459 fix: validate portal color format (#14632) 2026-06-03 14:18:19 +05:30
Muhsin 1f0d2a1049 Update provider.js 2026-06-02 21:31:09 +04:00
Muhsin KelothandGitHub ee00f445c0 Merge branch 'develop' into feat/google-play-reviews 2026-06-02 21:18:40 +04:00
Muhsin KelothandGitHub 29ecbc846b Merge branch 'develop' into feat/google-play-reviews 2026-05-22 09:03:26 +04:00
Pranav 1149edbd2d Fix name 2026-05-20 07:06:53 -07:00
Pranav 9edad844a4 fix spec 2026-05-19 17:34:32 -07:00
Pranav 37db80793f Update schema.rb 2026-05-19 17:27:45 -07:00
Pranav fe87933223 Update spec 2026-05-19 17:23:34 -07:00
Pranav 3509a98eb2 revert schema.rb 2026-05-19 17:23:00 -07:00
Pranav 13a9f2591b feat: Google Play Store Reviews 2026-05-19 15:32:49 -07:00
107 changed files with 3130 additions and 975 deletions
-11
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -111,7 +111,8 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels
'sms' => Current.account.sms_channels,
'google_play' => Current.account.google_play_channels
}[permitted_params[:channel][:type]]
end
+6
View File
@@ -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();
@@ -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>
@@ -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,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,
};
+8 -20
View File
@@ -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
+6
View File
@@ -11,6 +11,7 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
GOOGLE_PLAY: 'Channel::GooglePlay',
};
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
@@ -53,6 +54,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -68,6 +70,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
[INBOX_TYPES.GOOGLE_PLAY]: 'i-ri-google-play-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -117,6 +120,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.GOOGLE_PLAY:
return 'google_play';
default:
return 'chat';
}
@@ -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) {
-125
View File
@@ -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>
-39
View File
@@ -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>
-30
View File
@@ -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>
-76
View File
@@ -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>
-41
View File
@@ -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>
-12
View File
@@ -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>
-15
View File
@@ -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');
-61
View File
@@ -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);
}
+15 -4
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ class SendReplyJob < ApplicationJob
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::GooglePlay' => ::GooglePlay::SendOnGooglePlayService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService
}.freeze
@@ -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
+1
View File
@@ -77,6 +77,7 @@ class Account < ApplicationRecord
has_many :data_imports, dependent: :destroy_async
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :google_play_channels, dependent: :destroy_async, class_name: '::Channel::GooglePlay'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
+110
View File
@@ -0,0 +1,110 @@
# == Schema Information
#
# Table name: channel_google_play
#
# id :bigint not null, primary key
# app_id :string not null
# provider_config :jsonb not null
# last_synced_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_channel_google_play_on_account_id_and_app_id (account_id,app_id) UNIQUE
#
class Channel::GooglePlay < ApplicationRecord
include Channelable
self.table_name = 'channel_google_play'
EDITABLE_ATTRS = [:app_id, { provider_config: {} }].freeze
API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3'.freeze
# Google Play caps a developer reply at 350 characters
MAX_REPLY_LENGTH = 350
REVIEWS_PAGE_SIZE = 100
# Safety bound — at 100 per page this covers 5,000 reviews. The API only retains the last 7
# days, so this is far above any realistic volume and just prevents runaway loops.
MAX_REVIEW_PAGES = 50
# Each inbox is synced at most once per this window. The cron runs more frequently
# so a missed window retries on the next tick.
SYNC_INTERVAL = 1.hour
validates :app_id, presence: true, uniqueness: { scope: :account_id }
# Pull reviews on the agent's behalf the moment the inbox is wired up so they're not waiting
# for the next 15-minute poll. Runs after the surrounding transaction commits so the
# associated inbox is guaranteed to be visible.
after_create_commit :enqueue_initial_review_fetch
def name
'Google PlayStore'
end
def sync_due?
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
end
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently.
# Pages through tokenPagination.nextPageToken until the API stops returning a cursor.
def fetch_reviews
reviews = []
page_token = nil
MAX_REVIEW_PAGES.times do
parsed = fetch_reviews_page(page_token)
reviews.concat(parsed['reviews'] || [])
page_token = parsed.dig('tokenPagination', 'nextPageToken')
break if page_token.blank?
end
reviews
end
# Returns a stable source_id for the reply so the outgoing message can be marked as sent.
# Google Play has no separate reply id, so we combine the review id with the developer comment's
# lastEdited timestamp from the response.
def reply_to_review(review_id, reply_text)
response = HTTParty.post(
"#{API_BASE_URL}/applications/#{app_id}/reviews/#{review_id}:reply",
headers: authorization_headers.merge('Content-Type' => 'application/json'),
body: { replyText: reply_text.to_s.truncate(MAX_REPLY_LENGTH) }.to_json
)
raise "Google Play reply failed (#{response.code}): #{response.body}" unless response.success?
last_edited = response.parsed_response.dig('result', 'lastEdited', 'seconds')
"#{review_id}::reply::#{last_edited}"
end
private
def fetch_reviews_page(page_token)
query = { maxResults: REVIEWS_PAGE_SIZE }
query[:token] = page_token if page_token.present?
response = HTTParty.get(
"#{API_BASE_URL}/applications/#{app_id}/reviews",
headers: authorization_headers,
query: query
)
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
response.parsed_response
end
def authorization_headers
{ 'Authorization' => "Bearer #{access_token}" }
end
# Channels are connected through the Google OAuth flow; tokens are refreshed on demand
def access_token
Google::RefreshOauthTokenService.new(channel: self).access_token
end
def enqueue_initial_review_fetch
::Inboxes::FetchGooglePlayReviewsJob.perform_later(self)
end
end
+4
View File
@@ -146,6 +146,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Email'
end
def google_play?
channel_type == 'Channel::GooglePlay'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
+1
View File
@@ -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
+157
View File
@@ -0,0 +1,157 @@
# Builds a contact, conversation and messages from a single Google Play review.
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
# their review, a fresh incoming message is appended to the same conversation.
# Developer replies (whether posted via Chatwoot or directly in Play Console) are mirrored
# as outgoing messages, with a source_id that matches what `SendOnGooglePlayService`
# produces so Chatwoot-originated replies are deduped on the next poll.
class GooglePlay::ReviewBuilder
pattr_initialize [:review!, :channel!]
def perform
return if user_comment.blank? || review_text.blank?
ActiveRecord::Base.transaction do
build_contact_inbox
build_conversation
build_user_message
build_developer_message if developer_comment.present?
end
end
private
def inbox
@inbox ||= channel.inbox
end
def user_comment
@user_comment ||= comments_with_key('userComment').first
end
def developer_comment
@developer_comment ||= comments_with_key('developerComment').first
end
def comments_with_key(key)
Array(review['comments']).filter_map { |comment| comment[key] }
end
def review_id
review['reviewId']
end
def review_text
@review_text ||= user_comment['text'].to_s.strip
end
def star_rating
@star_rating ||= user_comment['starRating'].to_i
end
# A new lastModified timestamp (edited review) yields a new message in the same conversation
def user_message_source_id
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
end
# Must match the format `Channel::GooglePlay#reply_to_review` returns so replies sent through
# Chatwoot are not duplicated when the review is re-fetched.
def developer_message_source_id
"#{review_id}::reply::#{developer_comment.dig('lastModified', 'seconds')}"
end
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: review_id,
inbox: inbox,
contact_attributes: {
name: review['authorName'].presence || 'Google Play User',
additional_attributes: { source_id: "google_play:#{review_id}" }
}
).perform
end
def build_conversation
@conversation = @contact_inbox.conversations.last || ::Conversation.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: { source: 'google_play', app_id: channel.app_id }
)
end
def build_user_message
return if @conversation.messages.exists?(source_id: user_message_source_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
sender: @conversation.contact,
message_type: :incoming,
source_id: user_message_source_id,
content: message_content,
content_attributes: review_metadata
)
end
def build_developer_message
text = developer_comment['text'].to_s.strip
return if text.blank?
return if @conversation.messages.exists?(source_id: developer_message_source_id)
@conversation.messages.create!(
account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: :outgoing,
source_id: developer_message_source_id,
content: text,
status: :sent
)
end
def message_content
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
[
"#{stars} (#{star_rating}/5)",
review_text,
review_footer
].compact_blank.join("\n\n")
end
# A compact line of context shown beneath the review text — device, app version, OS.
def review_footer
parts = [device_display, app_version_display, os_version_display].compact_blank
return nil if parts.empty?
parts.join(' • ')
end
def device_display
metadata = user_comment['deviceMetadata'] || {}
metadata['productName'].presence || user_comment['device']
end
def app_version_display
version = user_comment['appVersionName']
version.present? ? "v#{version}" : nil
end
def os_version_display
version = user_comment['androidOsVersion']
version.present? ? "Android #{version}" : nil
end
def review_metadata
metadata = user_comment['deviceMetadata'] || {}
{
google_play: {
star_rating: star_rating,
app_version: user_comment['appVersionName'],
device: device_display,
manufacturer: metadata['manufacturer'],
android_os_version: user_comment['androidOsVersion'],
reviewer_language: user_comment['reviewerLanguage']
}
}
end
end
@@ -0,0 +1,15 @@
class GooglePlay::SendOnGooglePlayService < Base::SendOnChannelService
private
def channel_class
Channel::GooglePlay
end
def perform_reply
source_id = channel.reply_to_review(message.conversation.contact_inbox.source_id, message.content)
message.update!(source_id: source_id) if source_id.present?
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
message.update!(status: :failed, external_error: e.message.to_s.truncate(255))
end
end
@@ -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

+7
View File
@@ -22,10 +22,12 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/tiktok', to: 'dashboard#index', as: 'app_new_tiktok_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/google_play', to: 'dashboard#index', as: 'app_new_google_play_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_tiktok_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_google_play_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
@@ -323,6 +325,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :google_play do
resource :authorization, only: [:create]
end
namespace :instagram do
resource :authorization, only: [:create]
end
@@ -649,6 +655,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'google_play/callback', to: 'google_play/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'tiktok/callback', to: 'tiktok/callbacks#show'
get 'notion/callback', to: 'notion/callbacks#show'
+6
View File
@@ -26,6 +26,12 @@ trigger_imap_email_inboxes_job:
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# executed every 15 minutes to fetch Google Play Store app reviews
trigger_google_play_review_inboxes_job:
cron: '*/15 * * * *'
class: 'Inboxes::FetchGooglePlayReviewInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
@@ -0,0 +1,13 @@
class CreateChannelGooglePlay < ActiveRecord::Migration[7.1]
def change
create_table :channel_google_play do |t|
t.bigint :account_id, null: false
t.string :app_id, null: false
t.jsonb :provider_config, null: false, default: {}
t.timestamps
end
add_index :channel_google_play, [:account_id, :app_id], unique: true
end
end
@@ -0,0 +1,5 @@
class AddLastSyncedAtToChannelGooglePlay < ActiveRecord::Migration[7.1]
def change
add_column :channel_google_play, :last_synced_at, :datetime
end
end
+10
View File
@@ -491,6 +491,16 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_25_093000) do
t.index ["page_id"], name: "index_channel_facebook_pages_on_page_id"
end
create_table "channel_google_play", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "app_id", null: false
t.jsonb "provider_config", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "last_synced_at"
t.index ["account_id", "app_id"], name: "index_channel_google_play_on_account_id_and_app_id", unique: true
end
create_table "channel_instagram", force: :cascade do |t|
t.string "access_token", null: false
t.datetime "expires_at", null: false
@@ -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
+8 -8
View File
@@ -2,17 +2,17 @@
#
# Table name: companies
#
# id :bigint not null, primary key
# additional_attributes :jsonb
# contacts_count :integer default(0), not null
# custom_attributes :jsonb
# description :text
# domain :string
# last_activity_at :datetime
# id :bigint not null, primary key
# contacts_count :integer
# description :text
# domain :string
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -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
+44
View File
@@ -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,
},
],
},
});
+24 -8
View File
@@ -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 -9
View File
@@ -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)
+12 -26
View File
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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",
+1071 -31
View File
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
+27 -2
View File
@@ -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 = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)'
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg?cw_image_height=100" style="height: 100;" />')
markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)'
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 = '![Sample Title](https://example.com/image.jpg?cw_image_width=200px)'
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 = '![x](https://example.com/image.jpg?cw_image_height=1px%22%20onmouseover%3D%22alert(1))'
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 = '![x](https://example.com/image.jpg?cw_image_width=1px%22%20onmouseover%3D%22alert(1))'
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
+136
View File
@@ -0,0 +1,136 @@
require 'rails_helper'
RSpec.describe Channel::GooglePlay do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:channel) { create(:channel_google_play, account: account) }
describe 'validations' do
it 'requires app_id' do
record = described_class.new(account: account, provider_config: { 'access_token' => 'x' })
expect(record).not_to be_valid
expect(record.errors[:app_id]).to include("can't be blank")
end
it 'requires app_id to be unique per account' do
create(:channel_google_play, account: account, app_id: 'com.dup.app')
duplicate = build(:channel_google_play, account: account, app_id: 'com.dup.app')
expect(duplicate).not_to be_valid
end
it 'allows the same app_id across different accounts' do
create(:channel_google_play, account: account, app_id: 'com.shared.app')
other = build(:channel_google_play, account: create(:account), app_id: 'com.shared.app')
expect(other).to be_valid
end
end
describe '#name' do
it 'returns the human-readable channel name' do
expect(channel.name).to eq 'Google PlayStore'
end
end
describe '#sync_due?' do
it 'is true when last_synced_at is nil' do
channel.update!(last_synced_at: nil)
expect(channel.sync_due?).to be true
end
it 'is true when last_synced_at is older than the sync interval' do
channel.update!(last_synced_at: 2.hours.ago)
expect(channel.sync_due?).to be true
end
it 'is false when last_synced_at is within the sync interval' do
channel.update!(last_synced_at: 10.minutes.ago)
expect(channel.sync_due?).to be false
end
end
describe '#fetch_reviews' do
let(:base_url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews" }
before do
allow_any_instance_of(described_class).to receive(:access_token).and_return('test-token')
end
it 'returns the reviews list for a single page' do
stub_request(:get, base_url)
.with(query: hash_including('maxResults' => '100'))
.to_return(status: 200, body: { reviews: [{ 'reviewId' => 'r-1' }] }.to_json,
headers: { 'Content-Type' => 'application/json' })
expect(channel.fetch_reviews).to eq([{ 'reviewId' => 'r-1' }])
end
it 'follows tokenPagination.nextPageToken across pages' do
stub_request(:get, base_url)
.with(query: hash_including('maxResults' => '100'))
.to_return(
{ status: 200,
body: { reviews: [{ 'reviewId' => 'r-1' }], tokenPagination: { nextPageToken: 'PAGE2' } }.to_json,
headers: { 'Content-Type' => 'application/json' } },
{ status: 200,
body: { reviews: [{ 'reviewId' => 'r-2' }] }.to_json,
headers: { 'Content-Type' => 'application/json' } }
)
expect(channel.fetch_reviews.map { |r| r['reviewId'] }).to eq(%w[r-1 r-2])
end
it 'raises when the API responds with an error' do
stub_request(:get, base_url)
.with(query: hash_including('maxResults' => '100'))
.to_return(status: 403, body: 'forbidden')
expect { channel.fetch_reviews }.to raise_error(/Google Play reviews fetch failed \(403\)/)
end
end
describe '#reply_to_review' do
let(:url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews/REV-1:reply" }
before do
allow_any_instance_of(described_class).to receive(:access_token).and_return('test-token')
end
it 'returns a source_id composed from the review id and lastEdited.seconds' do
stub_request(:post, url).to_return(
status: 200,
body: { result: { replyText: 'thanks', lastEdited: { seconds: '1779000000', nanos: 0 } } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(channel.reply_to_review('REV-1', 'thanks')).to eq('REV-1::reply::1779000000')
end
it 'truncates the reply text to MAX_REPLY_LENGTH' do
stub_request(:post, url).to_return(status: 200,
body: { result: { lastEdited: { seconds: '1' } } }.to_json,
headers: { 'Content-Type' => 'application/json' })
channel.reply_to_review('REV-1', 'a' * 500)
expect(WebMock).to(have_requested(:post, url).with do |req|
JSON.parse(req.body)['replyText'].length <= described_class::MAX_REPLY_LENGTH
end)
end
it 'raises when the API responds with an error' do
stub_request(:post, url).to_return(status: 400, body: 'bad request')
expect { channel.reply_to_review('REV-1', 'x') }
.to raise_error(/Google Play reply failed \(400\)/)
end
end
describe 'after_create_commit' do
it 'enqueues an initial review fetch' do
expect do
create(:channel_google_play, account: account)
end.to have_enqueued_job(Inboxes::FetchGooglePlayReviewsJob)
end
end
end
@@ -625,4 +625,22 @@ RSpec.describe Conversations::MessageWindowService do
expect(service.can_reply?).to be true
end
end
describe 'on Google Play channels' do
let!(:google_play_channel) { create(:channel_google_play) }
let(:inbox) { google_play_channel.inbox }
let(:conversation) { create(:conversation, inbox: inbox, account: google_play_channel.account) }
it 'allows replies when the most recent incoming review is within 7 days' do
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 3.days.ago)
expect(described_class.new(conversation).can_reply?).to be true
end
it 'blocks replies when the most recent incoming review is older than 7 days' do
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 10.days.ago)
expect(described_class.new(conversation).can_reply?).to be false
end
end
end

Some files were not shown because too many files have changed in this diff Show More