diff --git a/Gemfile.lock b/Gemfile.lock
index 6b7848b2f..c728e7b4d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -172,6 +172,8 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
+ childprocess (5.1.0)
+ logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -433,10 +435,12 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
- launchy (2.5.2)
+ launchy (3.1.1)
addressable (~> 2.8)
- letter_opener (1.8.1)
- launchy (>= 2.2, < 3)
+ childprocess (~> 5.0)
+ logger (~> 1.6)
+ letter_opener (1.10.0)
+ launchy (>= 2.2, < 4)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -566,7 +570,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
- public_suffix (6.0.0)
+ public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 61d16b2ca..e7b3b197b 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
- return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
+ return unless allowed_channel_types.include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
+ def allowed_channel_types
+ %w[web_widget api email line telegram whatsapp sms]
+ end
+
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
diff --git a/app/controllers/api/v1/accounts/integrations/notion_controller.rb b/app/controllers/api/v1/accounts/integrations/notion_controller.rb
new file mode 100644
index 000000000..dff6ccece
--- /dev/null
+++ b/app/controllers/api/v1/accounts/integrations/notion_controller.rb
@@ -0,0 +1,14 @@
+class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
+ before_action :fetch_hook, only: [:destroy]
+
+ def destroy
+ @hook.destroy!
+ head :ok
+ end
+
+ private
+
+ def fetch_hook
+ @hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/api/v1/accounts/notion/authorizations_controller.rb b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb
new file mode 100644
index 000000000..bb9b2f858
--- /dev/null
+++ b/app/controllers/api/v1/accounts/notion/authorizations_controller.rb
@@ -0,0 +1,21 @@
+class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
+ include NotionConcern
+
+ def create
+ redirect_url = notion_client.auth_code.authorize_url(
+ {
+ redirect_uri: "#{base_url}/notion/callback",
+ response_type: 'code',
+ owner: 'user',
+ state: state,
+ client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
+ }
+ )
+
+ if redirect_url
+ render json: { success: true, url: redirect_url }
+ else
+ render json: { success: false }, status: :unprocessable_entity
+ end
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/concerns/notion_concern.rb b/app/controllers/concerns/notion_concern.rb
new file mode 100644
index 000000000..2b94fe63b
--- /dev/null
+++ b/app/controllers/concerns/notion_concern.rb
@@ -0,0 +1,21 @@
+module NotionConcern
+ extend ActiveSupport::Concern
+
+ def notion_client
+ app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
+ app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
+
+ ::OAuth2::Client.new(app_id, app_secret, {
+ site: 'https://api.notion.com',
+ authorize_url: 'https://api.notion.com/v1/oauth/authorize',
+ token_url: 'https://api.notion.com/v1/oauth/token',
+ auth_scheme: :basic_auth
+ })
+ end
+
+ private
+
+ def scope
+ ''
+ end
+end
diff --git a/app/controllers/notion/callbacks_controller.rb b/app/controllers/notion/callbacks_controller.rb
new file mode 100644
index 000000000..94030fc8e
--- /dev/null
+++ b/app/controllers/notion/callbacks_controller.rb
@@ -0,0 +1,36 @@
+class Notion::CallbacksController < OauthCallbackController
+ include NotionConcern
+
+ private
+
+ def provider_name
+ 'notion'
+ end
+
+ def oauth_client
+ notion_client
+ end
+
+ def handle_response
+ hook = account.hooks.new(
+ access_token: parsed_body['access_token'],
+ status: 'enabled',
+ app_id: 'notion',
+ settings: {
+ token_type: parsed_body['token_type'],
+ workspace_name: parsed_body['workspace_name'],
+ workspace_id: parsed_body['workspace_id'],
+ workspace_icon: parsed_body['workspace_icon'],
+ bot_id: parsed_body['bot_id'],
+ owner: parsed_body['owner']
+ }
+ )
+
+ hook.save!
+ redirect_to notion_redirect_uri
+ end
+
+ def notion_redirect_uri
+ "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
+ end
+end
\ No newline at end of file
diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index 32a147d34..02023559b 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published.includes(:category, :author)
+
+ @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
+
@articles_count = @articles.count
+
search_articles
order_by_sort_param
limit_results
diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb
index 668114d35..0601beba7 100644
--- a/app/controllers/super_admin/app_configs_controller.rb
+++ b/app/controllers/super_admin/app_configs_controller.rb
@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
- 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
+ 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
+ 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb
index ff16e9386..455828228 100644
--- a/app/controllers/twilio/callback_controller.rb
+++ b/app/controllers/twilio/callback_controller.rb
@@ -27,7 +27,10 @@ class Twilio::CallbackController < ApplicationController
*Array.new(10) { |i| :"MediaUrl#{i}" },
*Array.new(10) { |i| :"MediaContentType#{i}" },
:MessagingServiceSid,
- :NumMedia
+ :NumMedia,
+ :Latitude,
+ :Longitude,
+ :MessageType
)
end
end
diff --git a/app/javascript/dashboard/api/notion_auth.js b/app/javascript/dashboard/api/notion_auth.js
new file mode 100644
index 000000000..8a0027f9b
--- /dev/null
+++ b/app/javascript/dashboard/api/notion_auth.js
@@ -0,0 +1,14 @@
+/* global axios */
+import ApiClient from './ApiClient';
+
+class NotionOAuthClient extends ApiClient {
+ constructor() {
+ super('notion', { accountScoped: true });
+ }
+
+ generateAuthorization() {
+ return axios.post(`${this.url}/authorization`);
+ }
+}
+
+export default new NotionOAuthClient();
diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue
index f00354105..7879411c8 100644
--- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
-
+
diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js
index 9c0a24925..36dd6216e 100644
--- a/app/javascript/dashboard/components-next/icon/provider.js
+++ b/app/javascript/dashboard/components-next/icon/provider.js
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
+ 'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
index df30d7138..5860e30ea 100644
--- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
+++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
+ it('returns correct icon for Voice channel', () => {
+ const inbox = { channel_type: 'Channel::Voice' };
+ const { value: icon } = useChannelIcon(inbox);
+ expect(icon).toBe('i-ri-phone-fill');
+ });
+
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue
index ea6eb0417..ed6d7a20b 100644
--- a/app/javascript/dashboard/components-next/input/Input.vue
+++ b/app/javascript/dashboard/components-next/input/Input.vue
@@ -1,51 +1,21 @@
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
index b7b0a2c1d..4ff8b1ab1 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
+ 'Channel::Voice': 'VOICE',
};
const twilioChannelName = () => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue
new file mode 100644
index 000000000..c2d63ad22
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Notion.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
index 2cbeaa0ea..a76e82730 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/integrations.routes.js
@@ -8,6 +8,7 @@ import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
+import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
@@ -90,15 +91,24 @@ export default {
},
props: route => ({ code: route.query.code }),
},
+ {
+ path: 'notion',
+ name: 'settings_integrations_notion',
+ component: Notion,
+ meta: {
+ permissions: ['administrator'],
+ },
+ props: route => ({ code: route.query.code }),
+ },
{
path: 'github',
name: 'settings_integrations_github',
- component: Github,
+ component: Shopify,
meta: {
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
permissions: ['administrator'],
},
- props: route => ({ code: route.query.code }),
+ props: route => ({ error: route.query.error }),
},
{
path: 'shopify',
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 32d91fb8e..aef95d1b1 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -9,29 +9,7 @@ import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
-
-const buildInboxData = inboxParams => {
- const formData = new FormData();
- const { channel = {}, ...inboxProperties } = inboxParams;
- Object.keys(inboxProperties).forEach(key => {
- formData.append(key, inboxProperties[key]);
- });
- const { selectedFeatureFlags, ...channelParams } = channel;
- // selectedFeatureFlags needs to be empty when creating a website channel
- if (selectedFeatureFlags) {
- if (selectedFeatureFlags.length) {
- selectedFeatureFlags.forEach(featureFlag => {
- formData.append(`channel[selected_feature_flags][]`, featureFlag);
- });
- } else {
- formData.append('channel[selected_feature_flags][]', '');
- }
- }
- Object.keys(channelParams).forEach(key => {
- formData.append(`channel[${key}]`, channel[key]);
- });
- return formData;
-};
+import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -220,6 +198,12 @@ export const actions = {
throw new Error(error);
}
},
+ ...channelActions,
+ // TODO: Extract other create channel methods to separate files to reduce file size
+ // - createChannel
+ // - createWebsiteChannel
+ // - createTwilioChannel
+ // - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
diff --git a/app/javascript/dashboard/store/modules/inboxes/channelActions.js b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
new file mode 100644
index 000000000..9975d8d1f
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/inboxes/channelActions.js
@@ -0,0 +1,52 @@
+import * as types from '../../mutation-types';
+import InboxesAPI from '../../../api/inboxes';
+import AnalyticsHelper from '../../../helper/AnalyticsHelper';
+import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
+
+export const buildInboxData = inboxParams => {
+ const formData = new FormData();
+ const { channel = {}, ...inboxProperties } = inboxParams;
+ Object.keys(inboxProperties).forEach(key => {
+ formData.append(key, inboxProperties[key]);
+ });
+ const { selectedFeatureFlags, ...channelParams } = channel;
+ // selectedFeatureFlags needs to be empty when creating a website channel
+ if (selectedFeatureFlags) {
+ if (selectedFeatureFlags.length) {
+ selectedFeatureFlags.forEach(featureFlag => {
+ formData.append(`channel[selected_feature_flags][]`, featureFlag);
+ });
+ } else {
+ formData.append('channel[selected_feature_flags][]', '');
+ }
+ }
+ Object.keys(channelParams).forEach(key => {
+ formData.append(`channel[${key}]`, channel[key]);
+ });
+ return formData;
+};
+
+const sendAnalyticsEvent = channelType => {
+ AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
+ channelType,
+ });
+};
+
+export const channelActions = {
+ createVoiceChannel: async ({ commit }, params) => {
+ try {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
+ const response = await InboxesAPI.create({
+ name: params.name,
+ channel: { ...params.voice, type: 'voice' },
+ });
+ commit(types.default.ADD_INBOXES, response.data);
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ sendAnalyticsEvent('voice');
+ return response.data;
+ } catch (error) {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
+ throw error;
+ }
+ },
+};
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
index 8e917467a..a91addaa3 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/actions.spec.js
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
+ describe('#createVoiceChannel', () => {
+ it('sends correct actions if API is success', async () => {
+ axios.post.mockResolvedValue({ data: inboxList[0] });
+ await actions.createVoiceChannel({ commit }, inboxList[0]);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.ADD_INBOXES, inboxList[0]],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
+ Error
+ );
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
+ [types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
+ ]);
+ });
+ });
+
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 35e54d154..1fdc51276 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -112,11 +112,14 @@ export const InitializationHelpers = {
},
setDirectionAttribute: () => {
- const portalElement = document.getElementById('portal');
- if (!portalElement) return;
+ const htmlElement = document.querySelector('html');
+ // If direction is already applied through props, do not apply again (iframe case)
+ const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
+ if (!htmlElement || hasDirApplied) return;
- const locale = document.querySelector('.locale-switcher')?.value;
- portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
+ const localeFromHtml = htmlElement.lang;
+ htmlElement.dir =
+ localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
diff --git a/app/javascript/shared/components/IframeLoader.vue b/app/javascript/shared/components/IframeLoader.vue
index b3c91b8f0..0a99816b4 100644
--- a/app/javascript/shared/components/IframeLoader.vue
+++ b/app/javascript/shared/components/IframeLoader.vue
@@ -1,64 +1,56 @@
-
@@ -66,6 +58,7 @@ export default {
diff --git a/app/javascript/shared/helpers/portalHelper.js b/app/javascript/shared/helpers/portalHelper.js
new file mode 100644
index 000000000..a9e92a92a
--- /dev/null
+++ b/app/javascript/shared/helpers/portalHelper.js
@@ -0,0 +1,40 @@
+/**
+ * Determine the best-matching locale from the list of locales allowed by the portal.
+ *
+ * The matching happens in the following order:
+ * 1. Exact match – the visitor-selected locale equals one in the `allowedLocales` list
+ * (e.g., `fr` ➜ `fr`).
+ * 2. Base language match – the base part of a compound locale (before the underscore)
+ * matches (e.g., `fr_CA` ➜ `fr`).
+ * 3. Variant match – when the base language is selected but a regional variant exists
+ * in the portal list (e.g., `fr` ➜ `fr_BE`).
+ *
+ * If none of these rules find a match, the function returns `null`,
+ * Don't show popular articles if locale doesn't match with allowed locales
+ *
+ * @export
+ * @param {string} selectedLocale The locale selected by the visitor (e.g., `fr_CA`).
+ * @param {string[]} allowedLocales Array of locales enabled for the portal.
+ * @returns {(string|null)} A locale string that should be used, or `null` if no suitable match.
+ */
+export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
+ // Ensure inputs are valid
+ if (
+ !selectedLocale ||
+ !Array.isArray(allowedLocales) ||
+ !allowedLocales.length
+ ) {
+ return null;
+ }
+
+ const [lang] = selectedLocale.split('_');
+
+ const priorityMatches = [
+ selectedLocale, // exact match
+ lang, // base language match
+ allowedLocales.find(l => l.startsWith(`${lang}_`)), // first variant match
+ ];
+
+ // Return the first match that exists in the allowed list, or null
+ return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
+};
diff --git a/app/javascript/shared/helpers/specs/portalHelper.spec.js b/app/javascript/shared/helpers/specs/portalHelper.spec.js
new file mode 100644
index 000000000..e79c9a3d9
--- /dev/null
+++ b/app/javascript/shared/helpers/specs/portalHelper.spec.js
@@ -0,0 +1,28 @@
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
+
+describe('portalHelper - getMatchingLocale', () => {
+ it('returns exact match when present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns base language match when exact variant not present', () => {
+ const result = getMatchingLocale('fr_CA', ['en', 'fr']);
+ expect(result).toBe('fr');
+ });
+
+ it('returns variant match when base language not present', () => {
+ const result = getMatchingLocale('fr', ['en', 'fr_BE']);
+ expect(result).toBe('fr_BE');
+ });
+
+ it('returns null when no match found', () => {
+ const result = getMatchingLocale('de', ['en', 'fr']);
+ expect(result).toBeNull();
+ });
+
+ it('returns null for invalid inputs', () => {
+ expect(getMatchingLocale('', [])).toBeNull();
+ expect(getMatchingLocale(null, null)).toBeNull();
+ });
+});
diff --git a/app/javascript/shared/mixins/inboxMixin.js b/app/javascript/shared/mixins/inboxMixin.js
index 273e9f8b4..bf8ec492b 100644
--- a/app/javascript/shared/mixins/inboxMixin.js
+++ b/app/javascript/shared/mixins/inboxMixin.js
@@ -63,6 +63,9 @@ export default {
isATelegramChannel() {
return this.channelType === INBOX_TYPES.TELEGRAM;
},
+ isAVoiceChannel() {
+ return this.channelType === INBOX_TYPES.VOICE;
+ },
isATwilioSMSChannel() {
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
diff --git a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
index 1d5fdc99d..e093d4316 100644
--- a/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
+++ b/app/javascript/widget/components/pageComponents/Home/Article/ArticleContainer.vue
@@ -7,6 +7,7 @@ import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useDarkMode } from 'widget/composables/useDarkMode';
+import { getMatchingLocale } from 'shared/helpers/portalHelper';
const store = useStore();
const router = useRouter();
@@ -20,17 +21,8 @@ const articleUiFlags = useMapGetter('article/uiFlags');
const locale = computed(() => {
const { locale: selectedLocale } = i18n;
- const {
- allowed_locales: allowedLocales,
- default_locale: defaultLocale = 'en',
- } = portal.value.config;
- // IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
- // If the exact match of a locale is available in the list of portal locales, return it
- // Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
- if (allowedLocales.includes(selectedLocale)) {
- return locale;
- }
- return defaultLocale;
+ const { allowed_locales: allowedLocales } = portal.value.config;
+ return getMatchingLocale(selectedLocale.value, allowedLocales);
});
const fetchArticles = () => {
@@ -46,6 +38,7 @@ const openArticleInArticleViewer = link => {
const params = new URLSearchParams({
show_plain_layout: 'true',
theme: prefersDarkMode.value ? 'dark' : 'light',
+ ...(locale.value && { locale: locale.value }),
});
// Combine link with query parameters
@@ -64,7 +57,8 @@ const hasArticles = computed(
() =>
!articleUiFlags.value.isFetching &&
!articleUiFlags.value.isError &&
- !!popularArticles.value.length
+ !!popularArticles.value.length &&
+ !!locale.value
);
onMounted(() => fetchArticles());
diff --git a/app/javascript/widget/store/modules/articles.js b/app/javascript/widget/store/modules/articles.js
index 27faa297b..2ad6ae1dd 100644
--- a/app/javascript/widget/store/modules/articles.js
+++ b/app/javascript/widget/store/modules/articles.js
@@ -23,6 +23,7 @@ export const actions = {
commit('setError', false);
try {
+ if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue
index d3e1f9437..9289d0546 100644
--- a/app/javascript/widget/views/ArticleViewer.vue
+++ b/app/javascript/widget/views/ArticleViewer.vue
@@ -1,24 +1,16 @@
-
+
diff --git a/app/jobs/webhooks/twilio_events_job.rb b/app/jobs/webhooks/twilio_events_job.rb
index 5f44d981b..87fdfadfb 100644
--- a/app/jobs/webhooks/twilio_events_job.rb
+++ b/app/jobs/webhooks/twilio_events_job.rb
@@ -2,10 +2,16 @@ class Webhooks::TwilioEventsJob < ApplicationJob
queue_as :low
def perform(params = {})
- # Skip processing if Body parameter or MediaUrl0 is not present
+ # Skip processing if Body parameter, MediaUrl0, or location data is not present
# This is to skip processing delivery events being delivered to this endpoint
- return if params[:Body].blank? && params[:MediaUrl0].blank?
+ return if params[:Body].blank? && params[:MediaUrl0].blank? && !valid_location_message?(params)
::Twilio::IncomingMessageService.new(params: params).perform
end
+
+ private
+
+ def valid_location_message?(params)
+ params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
+ end
end
diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb
index b31d899bb..9f22fe8de 100644
--- a/app/listeners/reporting_event_listener.rb
+++ b/app/listeners/reporting_event_listener.rb
@@ -47,6 +47,10 @@ class ReportingEventListener < BaseListener
message = extract_message_and_account(event)[0]
conversation = message.conversation
waiting_since = event.data[:waiting_since]
+
+ return if waiting_since.blank?
+
+ # When waiting_since is nil, set reply_time to 0
reply_time = message.created_at.to_i - waiting_since.to_i
reporting_event = ReportingEvent.new(
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 922118b09..d6c5d0e4a 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -198,11 +198,21 @@ class Conversation < ApplicationRecord
private
def execute_after_update_commit_callbacks
+ handle_resolved_status_change
notify_status_change
create_activity
notify_conversation_updation
end
+ def handle_resolved_status_change
+ # When conversation is resolved, clear waiting_since using update_column to avoid callbacks
+ return unless saved_change_to_status? && status == 'resolved'
+
+ # rubocop:disable Rails/SkipsModelValidations
+ update_column(:waiting_since, nil)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
diff --git a/app/models/integrations/app.rb b/app/models/integrations/app.rb
index 89c9691d5..94dee79ce 100644
--- a/app/models/integrations/app.rb
+++ b/app/models/integrations/app.rb
@@ -3,14 +3,6 @@ class Integrations::App
include Github::IntegrationHelper
attr_accessor :params
- INTEGRATION_CONFIGS = {
- 'slack' => { config_key: 'SLACK_CLIENT_SECRET' },
- 'linear' => { config_key: 'LINEAR_CLIENT_ID' },
- 'shopify' => { feature_flag: 'shopify_integration', config_key: 'SHOPIFY_CLIENT_ID' },
- 'leadsquared' => { feature_flag: 'crm_integration' },
- 'github' => { feature_flag: 'github_integration', config_key: 'GITHUB_CLIENT_ID' }
- }.freeze
-
def initialize(params)
@params = params
end
@@ -65,13 +57,20 @@ class Integrations::App
end
def active?(account)
- config = INTEGRATION_CONFIGS[params[:id]]
- return true unless config
-
- feature_enabled = config[:feature_flag].nil? || account.feature_enabled?(config[:feature_flag])
- config_present = config[:config_key].nil? || GlobalConfigService.load(config[:config_key], nil).present?
-
- feature_enabled && config_present
+ case params[:id]
+ when 'slack'
+ GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
+ when 'linear'
+ GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
+ when 'shopify'
+ shopify_enabled?(account)
+ when 'leadsquared'
+ account.feature_enabled?('crm_integration')
+ when 'notion'
+ notion_enabled?(account)
+ else
+ true
+ end
end
def build_linear_action
@@ -139,4 +138,14 @@ class Integrations::App
all.detect { |app| app.id == params[:id] }
end
end
+
+ private
+
+ def shopify_enabled?(account)
+ account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
+ end
+
+ def notion_enabled?(account)
+ account.feature_enabled?('notion_integration') && GlobalConfigService.load('NOTION_CLIENT_ID', nil).present?
+ end
end
diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb
index e7300b525..ca77fa13d 100644
--- a/app/models/integrations/hook.rb
+++ b/app/models/integrations/hook.rb
@@ -53,6 +53,10 @@ class Integrations::Hook < ApplicationRecord
app_id == 'dialogflow'
end
+ def notion?
+ app_id == 'notion'
+ end
+
def disable
update(status: 'disabled')
end
diff --git a/app/models/message.rb b/app/models/message.rb
index e7c4e9b6c..d4036416e 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -24,6 +24,7 @@
#
# Indexes
#
+# idx_messages_account_content_created (account_id,content_type,created_at)
# index_messages_on_account_created_type (account_id,created_at,message_type)
# index_messages_on_account_id (account_id)
# index_messages_on_account_id_and_inbox_id (account_id,inbox_id)
diff --git a/app/services/llm_formatter/conversation_llm_formatter.rb b/app/services/llm_formatter/conversation_llm_formatter.rb
index a43471e5b..8654e0adf 100644
--- a/app/services/llm_formatter/conversation_llm_formatter.rb
+++ b/app/services/llm_formatter/conversation_llm_formatter.rb
@@ -5,7 +5,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
- build_messages
+ build_messages(config)
else
'No messages in this conversation'
end
@@ -23,11 +23,16 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
private
- def build_messages
+ def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
- @record.messages.chat.order(created_at: :asc).each do |message|
+ messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
+
+ messages.each do |message|
+ # Skip private messages unless explicitly included in config
+ next if message.private? && !config[:include_private_messages]
+
message_text << format_message(message)
end
message_text
@@ -35,6 +40,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
+ sender = "[Private Note] #{sender}" if message.private?
"#{sender}: #{message.content}\n"
end
diff --git a/app/services/twilio/incoming_message_service.rb b/app/services/twilio/incoming_message_service.rb
index c38577599..a11b99744 100644
--- a/app/services/twilio/incoming_message_service.rb
+++ b/app/services/twilio/incoming_message_service.rb
@@ -17,6 +17,7 @@ class Twilio::IncomingMessageService
source_id: params[:SmsSid]
)
attach_files
+ attach_location if location_message?
@message.save!
end
@@ -155,4 +156,17 @@ class Twilio::IncomingMessageService
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
nil
end
+
+ def location_message?
+ params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
+ end
+
+ def attach_location
+ @message.attachments.new(
+ account_id: @message.account_id,
+ file_type: :location,
+ coordinates_lat: params[:Latitude].to_f,
+ coordinates_long: params[:Longitude].to_f
+ )
+ end
end
diff --git a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
index 319ba4b4e..feb5dff96 100644
--- a/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
+++ b/app/views/mailers/conversation_reply_mailer/email_reply.html.erb
@@ -1,5 +1,5 @@
<% if @message.content %>
- <%= ChatwootMarkdownRenderer.new(@message.content).render_message %>
+ <%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
<% end %>
<% if @large_attachments.present? %>
Attachments:
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index 1aaff5dbb..e326de04b 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -156,12 +156,18 @@
+
+
+
+
+
+
diff --git a/config/features.yml b/config/features.yml
index 9cf767e68..c971ebb09 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -169,6 +169,13 @@
- name: crm_integration
display_name: CRM Integration
enabled: false
+- name: channel_voice
+ display_name: Voice Channel
+ enabled: false
+ chatwoot_internal: true
+- name: notion_integration
+ display_name: Notion Integration
+ enabled: false
- name: github_integration
display_name: Github Integration
- enabled: true
\ No newline at end of file
+ enabled: false
\ No newline at end of file
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 5f9f0e6c7..82a639588 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -288,6 +288,25 @@
type: secret
## ------ End of Configs added for Linear ------ ##
+## ------ Configs added for Notion ------ ##
+- name: NOTION_CLIENT_ID
+ display_title: 'Notion Client ID'
+ value:
+ locked: false
+ description: 'Notion client ID'
+- name: NOTION_CLIENT_SECRET
+ display_title: 'Notion Client Secret'
+ value:
+ locked: false
+ description: 'Notion client secret'
+ type: secret
+- name: NOTION_VERSION
+ display_title: 'Notion Version'
+ value: '2022-06-28'
+ locked: false
+ description: 'Notion version'
+## ------ End of Configs added for Notion ------ ##
+
## ------ Configs added for Slack ------ ##
- name: SLACK_CLIENT_ID
display_title: 'Slack Client ID'
diff --git a/config/integration/apps.yml b/config/integration/apps.yml
index c71503929..739590a6b 100644
--- a/config/integration/apps.yml
+++ b/config/integration/apps.yml
@@ -63,6 +63,12 @@ linear:
action: https://linear.app/oauth/authorize
hook_type: account
allow_multiple_hooks: false
+notion:
+ id: notion
+ logo: notion.png
+ i18n_key: notion
+ hook_type: account
+ allow_multiple_hooks: false
slack:
id: slack
logo: slack.png
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 9b1f383b1..785bf7696 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -257,6 +257,10 @@ en:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
+ notion:
+ name: 'Notion'
+ short_description: 'Integrate databases, documents and pages directly with Captain.'
+ description: 'Connect your Notion workspace to enable Captain to access and generate intelligent responses using content from your databases, documents, and pages to provide more contextual customer support.'
shopify:
name: 'Shopify'
short_description: 'Access order details and customer data from your Shopify store.'
diff --git a/config/routes.rb b/config/routes.rb
index 818cac127..bb094d9fb 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -228,6 +228,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
+ namespace :notion do
+ resource :authorization, only: [:create]
+ end
+
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -265,11 +269,14 @@ Rails.application.routes.draw do
get :linked_issues
end
end
- resource :github, controller: 'github', only: [] do
+ resource :notion, controller: 'notion', only: [] do
collection do
delete :destroy
end
end
+ namespace :integrations do
+ resource :github, only: [:show]
+ end
end
resources :working_hours, only: [:update]
@@ -502,6 +509,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
+ get 'notion/callback', to: 'notion/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
diff --git a/db/migrate/20250620120000_create_channel_voice.rb b/db/migrate/20250620120000_create_channel_voice.rb
new file mode 100644
index 000000000..9e2a25723
--- /dev/null
+++ b/db/migrate/20250620120000_create_channel_voice.rb
@@ -0,0 +1,16 @@
+class CreateChannelVoice < ActiveRecord::Migration[7.0]
+ def change
+ create_table :channel_voice do |t|
+ t.string :phone_number, null: false
+ t.string :provider, null: false, default: 'twilio'
+ t.jsonb :provider_config, null: false
+ t.integer :account_id, null: false
+ t.jsonb :additional_attributes, default: {}
+
+ t.timestamps
+ end
+
+ add_index :channel_voice, :phone_number, unique: true
+ add_index :channel_voice, :account_id
+ end
+end
\ No newline at end of file
diff --git a/db/migrate/20250627195529_add_index_to_messages.rb b/db/migrate/20250627195529_add_index_to_messages.rb
new file mode 100644
index 000000000..eb6b95cdd
--- /dev/null
+++ b/db/migrate/20250627195529_add_index_to_messages.rb
@@ -0,0 +1,22 @@
+class AddIndexToMessages < ActiveRecord::Migration[7.0]
+ disable_ddl_transaction!
+
+ def change
+ # This index is added as a temporary fix for performance issues in the CSAT
+ # responses controller where we query messages with account_id, content_type
+ # and created_at. The current implementation (account.message.input_csat.count)
+ # times out with millions of messages.
+ #
+ # TODO: Create a dedicated csat_survey table and add entries when surveys are
+ # sent, then query this table instead of the entire messages table for better
+ # performance.
+ return if index_exists?(
+ :messages,
+ [:account_id, :content_type, :created_at],
+ name: 'idx_messages_account_content_created'
+ )
+
+ add_index :messages, [:account_id, :content_type, :created_at],
+ name: 'idx_messages_account_content_created', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d065a8ff4..af0c89086 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
+ActiveRecord::Schema[7.1].define(version: 2025_06_27_195529) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -443,6 +443,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
+ create_table "channel_voice", force: :cascade do |t|
+ t.string "phone_number", null: false
+ t.string "provider", default: "twilio", null: false
+ t.jsonb "provider_config", null: false
+ t.integer "account_id", null: false
+ t.jsonb "additional_attributes", default: {}
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_channel_voice_on_account_id"
+ t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
+ end
+
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -813,6 +825,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
+ t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
@@ -907,7 +920,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "header_text"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.jsonb "config", default: {"allowed_locales"=>["en"]}
+ t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index e5a055836..ec8e8e653 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- params[:message_content],
- message_history
+ additional_message: params[:message_content],
+ message_history: message_history
)
render json: response
diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
index b39db609d..396c3a91d 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts/inboxes_controller.rb
@@ -6,4 +6,28 @@ module Enterprise::Api::V1::Accounts::InboxesController
def ee_inbox_attributes
[auto_assignment_config: [:max_assignment_limit]]
end
+
+ private
+
+ def allowed_channel_types
+ super + ['voice']
+ end
+
+ def channel_type_from_params
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Channel::Voice
+ else
+ super
+ end
+ end
+
+ def account_channels_method
+ case permitted_params[:channel][:type]
+ when 'voice'
+ Current.account.voice_channels
+ else
+ super
+ end
+ end
end
diff --git a/enterprise/app/helpers/super_admin/features.yml b/enterprise/app/helpers/super_admin/features.yml
index 09eb27893..0eecb510d 100644
--- a/enterprise/app/helpers/super_admin/features.yml
+++ b/enterprise/app/helpers/super_admin/features.yml
@@ -91,6 +91,12 @@ linear:
enabled: true
icon: 'icon-linear'
config_key: 'linear'
+notion:
+ name: 'Notion'
+ description: 'Configuration for setting up Notion Integration'
+ enabled: true
+ icon: 'icon-notion'
+ config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index f341a6e98..431945896 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -26,8 +26,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
- @conversation.messages.incoming.last.content,
- collect_previous_messages
+ message_history: collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -43,33 +42,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
- {
- content: message_content(message),
- role: determine_role(message)
- }
- end
- end
-
- def message_content(message)
- return message.content if message.content.present?
- return 'User has shared a message without content' unless message.attachments.any?
-
- audio_transcriptions = extract_audio_transcriptions(message.attachments)
- return audio_transcriptions if audio_transcriptions.present?
-
- 'User has shared an attachment'
- end
-
- def extract_audio_transcriptions(attachments)
- audio_attachments = attachments.where(file_type: :audio)
- return '' if audio_attachments.blank?
-
- transcriptions = ''
- audio_attachments.each do |attachment|
- result = Messages::AudioTranscriptionService.new(attachment).perform
- transcriptions += result[:transcriptions] if result[:success]
+ {
+ content: prepare_multimodal_message_content(message),
+ role: determine_role(message)
+ }
end
- transcriptions
end
def determine_role(message)
@@ -78,6 +55,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
message.message_type == 'incoming' ? 'user' : 'system'
end
+ def prepare_multimodal_message_content(message)
+ Captain::OpenAiMessageBuilderService.new(message: message).generate_content
+ end
+
def handoff_requested?
@response['response'] == 'conversation_handoff'
end
diff --git a/enterprise/app/models/channel/voice.rb b/enterprise/app/models/channel/voice.rb
new file mode 100644
index 000000000..a313129e2
--- /dev/null
+++ b/enterprise/app/models/channel/voice.rb
@@ -0,0 +1,64 @@
+# == Schema Information
+#
+# Table name: channel_voice
+#
+# id :bigint not null, primary key
+# additional_attributes :jsonb
+# phone_number :string not null
+# provider :string default("twilio"), not null
+# provider_config :jsonb not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :integer not null
+#
+# Indexes
+#
+# index_channel_voice_on_account_id (account_id)
+# index_channel_voice_on_phone_number (phone_number) UNIQUE
+#
+class Channel::Voice < ApplicationRecord
+ include Channelable
+
+ self.table_name = 'channel_voice'
+
+ validates :phone_number, presence: true, uniqueness: true
+ validates :provider, presence: true
+ validates :provider_config, presence: true
+
+ # Validate phone number format (E.164 format)
+ validates :phone_number, format: { with: /\A\+[1-9]\d{1,14}\z/ }
+
+ # Provider-specific configs stored in JSON
+ validate :validate_provider_config
+
+ EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
+
+ def name
+ "Voice (#{phone_number})"
+ end
+
+ def messaging_window_enabled?
+ false
+ end
+
+ private
+
+ def validate_provider_config
+ return if provider_config.blank?
+
+ case provider
+ when 'twilio'
+ validate_twilio_config
+ end
+ end
+
+ def validate_twilio_config
+ config = provider_config.with_indifferent_access
+ required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
+
+ required_keys.each do |key|
+ errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
+ end
+ end
+end
+
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 4a573a4c4..c31b6c10e 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -11,5 +11,6 @@ module Enterprise::Concerns::Account
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :copilot_threads, dependent: :destroy_async
+ has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
end
end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 569931d44..ca8fafaa0 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
- def generate_response(input, previous_messages = [], role = 'user')
- @messages += previous_messages
- @messages << { role: role, content: input } if input.present?
+ # additional_message: A single message (String) from the user that should be appended to the chat.
+ # It can be an empty String or nil when you only want to supply historical messages.
+ # message_history: An Array of already formatted messages that provide the previous context.
+ # role: The role for the additional_message (defaults to `user`).
+ #
+ # NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
+ # positional ordering.
+ def generate_response(additional_message: nil, message_history: [], role: 'user')
+ @messages += message_history
+ @messages << { role: role, content: additional_message } if additional_message.present?
request_chat_completion
end
diff --git a/enterprise/app/services/captain/open_ai_message_builder_service.rb b/enterprise/app/services/captain/open_ai_message_builder_service.rb
new file mode 100644
index 000000000..3320ad537
--- /dev/null
+++ b/enterprise/app/services/captain/open_ai_message_builder_service.rb
@@ -0,0 +1,59 @@
+class Captain::OpenAiMessageBuilderService
+ pattr_initialize [:message!]
+
+ def generate_content
+ parts = []
+ parts << text_part(@message.content) if @message.content.present?
+ parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
+
+ return 'Message without content' if parts.blank?
+ return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
+
+ parts
+ end
+
+ private
+
+ def text_part(text)
+ { type: 'text', text: text }
+ end
+
+ def image_part(image_url)
+ { type: 'image_url', image_url: { url: image_url } }
+ end
+
+ def attachment_parts(attachments)
+ image_attachments = attachments.where(file_type: :image)
+ image_content = image_parts(image_attachments)
+
+ transcription = extract_audio_transcriptions(attachments)
+ transcription_part = text_part(transcription) if transcription.present?
+
+ attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
+
+ [image_content, transcription_part, attachment_part].flatten.compact
+ end
+
+ def image_parts(image_attachments)
+ image_attachments.each_with_object([]) do |attachment, parts|
+ url = get_attachment_url(attachment)
+ parts << image_part(url) if url.present?
+ end
+ end
+
+ def get_attachment_url(attachment)
+ return attachment.external_url if attachment.external_url.present?
+
+ attachment.file.attached? ? attachment.file_url : nil
+ end
+
+ def extract_audio_transcriptions(attachments)
+ audio_attachments = attachments.where(file_type: :audio)
+ return '' if audio_attachments.blank?
+
+ audio_attachments.map do |attachment|
+ result = Messages::AudioTranscriptionService.new(attachment).perform
+ result[:success] ? result[:transcriptions] : ''
+ end.join
+ end
+end
\ No newline at end of file
diff --git a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
index 64b52d012..6f942ee8e 100644
--- a/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
+++ b/enterprise/app/services/captain/tools/copilot/get_conversation_service.rb
@@ -30,7 +30,7 @@ class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseServ
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
return 'Conversation not found' if conversation.blank?
- conversation.to_llm_text
+ conversation.to_llm_text(include_private_messages: true)
end
def active?
diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
index 76c09a27a..e4df1050b 100644
--- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
+++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
@@ -4,6 +4,8 @@ class Enterprise::Billing::CreateStripeCustomerService
DEFAULT_QUANTITY = 2
def perform
+ return if existing_subscription?
+
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
@@ -50,4 +52,18 @@ class Enterprise::Billing::CreateStripeCustomerService
price_ids = default_plan['price_ids']
price_ids.first
end
+
+ def existing_subscription?
+ stripe_customer_id = account.custom_attributes['stripe_customer_id']
+ return false if stripe_customer_id.blank?
+
+ subscriptions = Stripe::Subscription.list(
+ {
+ customer: stripe_customer_id,
+ status: 'active',
+ limit: 1
+ }
+ )
+ subscriptions.data.present?
+ end
end
diff --git a/public/assets/images/dashboard/channels/voice.png b/public/assets/images/dashboard/channels/voice.png
new file mode 100644
index 000000000..7c9481faf
Binary files /dev/null and b/public/assets/images/dashboard/channels/voice.png differ
diff --git a/public/dashboard/images/integrations/notion-dark.png b/public/dashboard/images/integrations/notion-dark.png
new file mode 100644
index 000000000..7d15c715e
Binary files /dev/null and b/public/dashboard/images/integrations/notion-dark.png differ
diff --git a/public/dashboard/images/integrations/notion.png b/public/dashboard/images/integrations/notion.png
new file mode 100644
index 000000000..a358e8a51
Binary files /dev/null and b/public/dashboard/images/integrations/notion.png differ
diff --git a/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
new file mode 100644
index 000000000..ac4bc2841
--- /dev/null
+++ b/spec/controllers/api/v1/accounts/notion/authorization_controller_spec.rb
@@ -0,0 +1,53 @@
+require 'rails_helper'
+
+RSpec.describe 'Notion Authorization API', type: :request do
+ let(:account) { create(:account) }
+
+ describe 'POST /api/v1/accounts/{account.id}/notion/authorization' do
+ context 'when it is an unauthenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization"
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an authenticated user' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:administrator) { create(:user, account: account, role: :administrator) }
+
+ it 'returns unauthorized for agent' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization",
+ headers: agent.create_new_auth_token,
+ params: { email: administrator.email },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'creates a new authorization and returns the redirect url' do
+ post "/api/v1/accounts/#{account.id}/notion/authorization",
+ headers: administrator.create_new_auth_token,
+ params: { email: administrator.email },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ # Validate URL components
+ url = response.parsed_body['url']
+ uri = URI.parse(url)
+ params = CGI.parse(uri.query)
+
+ expect(url).to start_with('https://api.notion.com/v1/oauth/authorize')
+ expect(params['response_type']).to eq(['code'])
+ expect(params['owner']).to eq(['user'])
+ expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/notion/callback"])
+
+ # Validate state parameter exists and can be decoded back to the account
+ expect(params['state']).to be_present
+ decoded_account = GlobalID::Locator.locate_signed(params['state'].first, for: 'default')
+ expect(decoded_account).to eq(account)
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/controllers/concerns/notion_concern_spec.rb b/spec/controllers/concerns/notion_concern_spec.rb
new file mode 100644
index 000000000..7ae11b17d
--- /dev/null
+++ b/spec/controllers/concerns/notion_concern_spec.rb
@@ -0,0 +1,56 @@
+require 'rails_helper'
+
+RSpec.describe NotionConcern, type: :concern do
+ let(:controller_class) do
+ Class.new do
+ include NotionConcern
+ end
+ end
+
+ let(:controller) { controller_class.new }
+
+ describe '#notion_client' do
+ let(:client_id) { 'test_notion_client_id' }
+ let(:client_secret) { 'test_notion_client_secret' }
+
+ before do
+ allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil).and_return(client_id)
+ allow(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil).and_return(client_secret)
+ end
+
+ it 'creates OAuth2 client with correct configuration' do
+ expect(OAuth2::Client).to receive(:new).with(
+ client_id,
+ client_secret,
+ {
+ site: 'https://api.notion.com',
+ authorize_url: 'https://api.notion.com/v1/oauth/authorize',
+ token_url: 'https://api.notion.com/v1/oauth/token',
+ auth_scheme: :basic_auth
+ }
+ )
+
+ controller.notion_client
+ end
+
+ it 'loads client credentials from GlobalConfigService' do
+ expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_ID', nil)
+ expect(GlobalConfigService).to receive(:load).with('NOTION_CLIENT_SECRET', nil)
+
+ controller.notion_client
+ end
+
+ it 'returns OAuth2::Client instance' do
+ client = controller.notion_client
+ expect(client).to be_an_instance_of(OAuth2::Client)
+ end
+
+ it 'configures client with Notion-specific endpoints' do
+ client = controller.notion_client
+ expect(client.site).to eq('https://api.notion.com')
+ expect(client.options[:authorize_url]).to eq('https://api.notion.com/v1/oauth/authorize')
+ expect(client.options[:token_url]).to eq('https://api.notion.com/v1/oauth/token')
+ expect(client.options[:auth_scheme]).to eq(:basic_auth)
+ end
+ end
+end
diff --git a/spec/controllers/notion/callbacks_controller_spec.rb b/spec/controllers/notion/callbacks_controller_spec.rb
new file mode 100644
index 000000000..045bbe396
--- /dev/null
+++ b/spec/controllers/notion/callbacks_controller_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+
+RSpec.describe Notion::CallbacksController, type: :request do
+ let(:account) { create(:account) }
+ let(:state) { account.to_sgid.to_s }
+ let(:oauth_code) { 'test_oauth_code' }
+ let(:notion_redirect_uri) { "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/integrations/notion" }
+
+ let(:notion_response_body) do
+ {
+ 'access_token' => 'notion_access_token_123',
+ 'token_type' => 'bearer',
+ 'workspace_name' => 'Test Workspace',
+ 'workspace_id' => 'workspace_123',
+ 'workspace_icon' => 'https://notion.so/icon.png',
+ 'bot_id' => 'bot_123',
+ 'owner' => {
+ 'type' => 'user',
+ 'user' => {
+ 'id' => 'user_123',
+ 'name' => 'Test User'
+ }
+ }
+ }
+ end
+
+ describe 'GET /notion/callback' do
+ before do
+ account.enable_features('notion_integration')
+ stub_const('ENV', ENV.to_hash.merge(
+ 'FRONTEND_URL' => 'http://localhost:3000',
+ 'NOTION_CLIENT_ID' => 'test_client_id',
+ 'NOTION_CLIENT_SECRET' => 'test_client_secret'
+ ))
+
+ controller = described_class.new
+ allow(controller).to receive(:account).and_return(account)
+ allow(controller).to receive(:notion_redirect_uri).and_return(notion_redirect_uri)
+ allow(described_class).to receive(:new).and_return(controller)
+ end
+
+ context 'when OAuth callback is successful' do
+ before do
+ stub_request(:post, 'https://api.notion.com/v1/oauth/token')
+ .to_return(
+ status: 200,
+ body: notion_response_body.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'creates a new integration hook' do
+ expect do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+ end.to change(Integrations::Hook, :count).by(1)
+
+ hook = Integrations::Hook.last
+ expect(hook.access_token).to eq('notion_access_token_123')
+ expect(hook.app_id).to eq('notion')
+ expect(hook.status).to eq('enabled')
+ end
+
+ it 'sets correct hook attributes' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ hook = Integrations::Hook.last
+ expect(hook.account).to eq(account)
+ expect(hook.app_id).to eq('notion')
+ expect(hook.access_token).to eq('notion_access_token_123')
+ expect(hook.status).to eq('enabled')
+ end
+
+ it 'stores notion workspace data in settings' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ hook = Integrations::Hook.last
+ expect(hook.settings['token_type']).to eq('bearer')
+ expect(hook.settings['workspace_name']).to eq('Test Workspace')
+ expect(hook.settings['workspace_id']).to eq('workspace_123')
+ expect(hook.settings['workspace_icon']).to eq('https://notion.so/icon.png')
+ expect(hook.settings['bot_id']).to eq('bot_123')
+ expect(hook.settings['owner']).to eq(notion_response_body['owner'])
+ end
+
+ it 'handles successful callback and creates hook' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ # Due to controller mocking limitations in test,
+ # the redirect URL construction fails but hook creation succeeds
+ expect(Integrations::Hook.last.app_id).to eq('notion')
+ expect(response).to be_redirect
+ end
+ end
+
+ context 'when OAuth token request fails' do
+ before do
+ stub_request(:post, 'https://api.notion.com/v1/oauth/token')
+ .to_return(
+ status: 400,
+ body: { error: 'invalid_grant' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'redirects to home page on error' do
+ get '/notion/callback', params: { code: oauth_code, state: state }
+
+ expect(response).to redirect_to('/')
+ end
+ end
+ end
+end
diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
index e6429a1de..2d8c5fcd5 100644
--- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb
@@ -65,6 +65,14 @@ RSpec.describe 'Public Articles API', type: :request do
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.length).to eq(2)
+ # Only count articles in the current locale (category.locale is 'en')
+ expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(3)
+ end
+
+ it 'returns articles count from all locales when locale parameter is not present' do
+ get "/hc/#{portal.slug}/articles.json"
+
+ expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:meta][:articles_count]).to eq(5)
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index 1f6d83d80..80be6f30f 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- valid_params[:message_content],
- valid_params[:message_history]
+ additional_message: valid_params[:message_content],
+ message_history: valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
- params_without_history[:message_content],
- []
+ additional_message: params_without_history[:message_content],
+ message_history: []
)
end
end
diff --git a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
index 724a7b0cb..498c42abd 100644
--- a/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb
@@ -22,6 +22,22 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
end
+
+ it 'creates a voice inbox when administrator' do
+ post "/api/v1/accounts/#{account.id}/inboxes",
+ headers: admin.create_new_auth_token,
+ params: { name: 'Voice Inbox',
+ channel: { type: 'voice', phone_number: '+15551234567',
+ provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16) } } },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Voice Inbox')
+ expect(response.body).to include('+15551234567')
+ end
end
end
diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
index 1e4a6e824..ca8d4a6c0 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -30,5 +30,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
+
+ context 'when message contains an image' do
+ let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
+ let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
+
+ before do
+ image_attachment
+ end
+
+ it 'includes image URL directly in the message content for OpenAI vision analysis' do
+ # Expect the generate_response to receive multimodal content with image URL
+ expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
+ history = kwargs[:message_history]
+ last_entry = history.last
+ expect(last_entry[:content]).to be_an(Array)
+ expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
+ expect(last_entry[:content].any? do |part|
+ part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
+ end).to be true
+ { 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
+ end
+
+ described_class.perform_now(conversation, assistant)
+ end
+ end
end
end
diff --git a/spec/enterprise/models/channel/voice_spec.rb b/spec/enterprise/models/channel/voice_spec.rb
new file mode 100644
index 000000000..2e52807b0
--- /dev/null
+++ b/spec/enterprise/models/channel/voice_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Channel::Voice do
+ let(:channel) { create(:channel_voice) }
+
+ it 'has a valid factory' do
+ expect(channel).to be_valid
+ end
+
+ describe 'validations' do
+ it 'validates presence of provider_config' do
+ channel.provider_config = nil
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include("can't be blank")
+ end
+
+ it 'validates presence of account_sid in provider_config' do
+ channel.provider_config = { auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of auth_token in provider_config' do
+ channel.provider_config = { account_sid: 'sid' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_sid in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
+ end
+
+ it 'validates presence of api_key_secret in provider_config' do
+ channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
+ expect(channel).not_to be_valid
+ expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
+ end
+
+ it 'is valid with all required provider_config fields' do
+ channel.provider_config = {
+ account_sid: 'test_sid',
+ auth_token: 'test_token',
+ api_key_sid: 'test_key',
+ api_key_secret: 'test_secret'
+ }
+ expect(channel).to be_valid
+ end
+ end
+
+ describe '#name' do
+ it 'returns Voice with phone number' do
+ expect(channel.name).to include('Voice')
+ expect(channel.name).to include(channel.phone_number)
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
new file mode 100644
index 000000000..13c29f756
--- /dev/null
+++ b/spec/enterprise/services/captain/open_ai_message_builder_service_spec.rb
@@ -0,0 +1,309 @@
+require 'rails_helper'
+
+RSpec.describe Captain::OpenAiMessageBuilderService do
+ subject(:service) { described_class.new(message: message) }
+
+ let(:message) { create(:message, content: 'Hello world') }
+
+ describe '#generate_content' do
+ context 'when message has only text content' do
+ it 'returns the text content directly' do
+ expect(service.generate_content).to eq('Hello world')
+ end
+ end
+
+ context 'when message has no content and no attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ it 'returns default message' do
+ expect(service.generate_content).to eq('Message without content')
+ end
+ end
+
+ context 'when message has text content and attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'text', text: 'Hello world' })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'when message has only non-text attachments' do
+ let(:message) { create(:message, content: nil) }
+
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'returns an array of content parts without text' do
+ result = service.generate_content
+ expect(result).to be_an(Array)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
+ end
+ end
+ end
+
+ describe '#attachment_parts' do
+ let(:message) { create(:message, content: nil) }
+ let(:attachments) { message.attachments }
+
+ context 'with image attachments' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ end
+
+ it 'includes image parts' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+
+ context 'with audio attachments' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
+ )
+ end
+
+ it 'includes transcription text part' do
+ audio_attachment # trigger creation
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'Audio transcription text' })
+ end
+ end
+
+ context 'with other file types' do
+ before do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ end
+
+ it 'includes generic attachment message' do
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+
+ context 'with mixed attachment types' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:document_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
+ )
+ end
+
+ it 'includes all relevant parts' do
+ image_attachment # trigger creation
+ audio_attachment # trigger creation
+ document_attachment # trigger creation
+
+ result = service.send(:attachment_parts, attachments)
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ expect(result).to include({ type: 'text', text: 'Audio text' })
+ expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
+ end
+ end
+ end
+
+ describe '#image_parts' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with valid image attachments' do
+ let(:image1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
+ attachment.save!
+ attachment
+ end
+
+ let(:image2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
+ attachment.save!
+ attachment
+ end
+
+ it 'returns image parts for all valid images' do
+ image1 # trigger creation
+ image2 # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
+ expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
+ end
+ end
+
+ context 'with image attachments without URLs' do
+ let(:image_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'skips images without valid URLs' do
+ image_attachment # trigger creation
+
+ image_attachments = message.attachments.where(file_type: :image)
+ result = service.send(:image_parts, image_attachments)
+
+ expect(result).to be_empty
+ end
+ end
+ end
+
+ describe '#get_attachment_url' do
+ let(:attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
+ attachment.save!
+ attachment
+ end
+
+ context 'when attachment has external_url' do
+ before { attachment.update(external_url: 'https://example.com/image.jpg') }
+
+ it 'returns external_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
+ end
+ end
+
+ context 'when attachment has attached file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
+ allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
+ end
+
+ it 'returns file_url' do
+ expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
+ end
+ end
+
+ context 'when attachment has no URL or file' do
+ before do
+ attachment.update(external_url: nil)
+ allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
+ end
+
+ it 'returns nil' do
+ expect(service.send(:get_attachment_url, attachment)).to be_nil
+ end
+ end
+ end
+
+ describe '#extract_audio_transcriptions' do
+ let(:message) { create(:message, content: nil) }
+
+ context 'with no audio attachments' do
+ it 'returns empty string' do
+ result = service.send(:extract_audio_transcriptions, message.attachments)
+ expect(result).to eq('')
+ end
+ end
+
+ context 'with successful audio transcriptions' do
+ let(:audio1) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ let(:audio2) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
+ )
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
+ )
+ end
+
+ it 'concatenates all successful transcriptions' do
+ audio1 # trigger creation
+ audio2 # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('First audio text. Second audio text.')
+ end
+ end
+
+ context 'with failed audio transcriptions' do
+ let(:audio_attachment) do
+ attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
+ attachment.save!
+ attachment
+ end
+
+ before do
+ allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
+ instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
+ )
+ end
+
+ it 'returns empty string for failed transcriptions' do
+ audio_attachment # trigger creation
+
+ attachments = message.attachments
+ result = service.send(:extract_audio_transcriptions, attachments)
+ expect(result).to eq('')
+ end
+ end
+ end
+
+ describe 'private helper methods' do
+ describe '#text_part' do
+ it 'returns correct text part format' do
+ result = service.send(:text_part, 'Hello world')
+ expect(result).to eq({ type: 'text', text: 'Hello world' })
+ end
+ end
+
+ describe '#image_part' do
+ it 'returns correct image part format' do
+ result = service.send(:image_part, 'https://example.com/image.jpg')
+ expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
index 4d7f1adc7..b8265bbbf 100644
--- a/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/copilot/get_conversation_service_spec.rb
@@ -128,6 +128,29 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
expect(result).to eq(conversation.to_llm_text)
end
+ it 'includes private messages in the llm text format' do
+ # Create a regular message
+ create(:message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Regular message',
+ private: false)
+
+ # Create a private message
+ create(:message,
+ conversation: conversation,
+ message_type: 'outgoing',
+ content: 'Private note content',
+ private: true)
+
+ result = service.execute({ 'conversation_id' => conversation.display_id })
+
+ # Verify that the result includes both regular and private messages
+ expect(result).to include('Regular message')
+ expect(result).to include('Private note content')
+ expect(result).to include('[Private Note]')
+ end
+
context 'when conversation belongs to different account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
index 95edf73a1..f5b0bbe86 100644
--- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
@@ -6,6 +6,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
+ let(:subscriptions_list) { double }
describe '#perform' do
before do
@@ -19,8 +20,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
-
+ allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
+ allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
@@ -78,4 +80,63 @@ describe Enterprise::Billing::CreateStripeCustomerService do
)
end
end
+
+ describe 'when checking for existing subscriptions' do
+ before do
+ create(
+ :installation_config,
+ { name: 'CHATWOOT_CLOUD_PLANS', value: [
+ { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
+ ] }
+ )
+ end
+
+ context 'when account has no stripe_customer_id' do
+ it 'creates a new subscription' do
+ customer = double
+ allow(Stripe::Customer).to receive(:create).and_return(customer)
+ allow(customer).to receive(:id).and_return('cus_random_number')
+ allow(Stripe::Subscription).to receive(:create).and_return(
+ {
+ plan: { id: 'price_random_number', product: 'prod_random_number' },
+ quantity: 2
+ }.with_indifferent_access
+ )
+
+ create_stripe_customer_service.new(account: account).perform
+
+ expect(Stripe::Customer).to have_received(:create)
+ expect(Stripe::Subscription).to have_received(:create)
+ end
+ end
+
+ context 'when account has stripe_customer_id' do
+ let(:stripe_customer_id) { 'cus_random_number' }
+
+ before do
+ account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
+ end
+
+ context 'when customer has active subscriptions' do
+ before do
+ allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
+ allow(subscriptions_list).to receive(:data).and_return(['subscription'])
+ allow(Stripe::Subscription).to receive(:create)
+ end
+
+ it 'does not create a new subscription' do
+ create_stripe_customer_service.new(account: account).perform
+
+ expect(Stripe::Subscription).not_to have_received(:create)
+ expect(Stripe::Subscription).to have_received(:list).with(
+ {
+ customer: stripe_customer_id,
+ status: 'active',
+ limit: 1
+ }
+ )
+ end
+ end
+ end
+ end
end
diff --git a/spec/factories/channel/channel_voice.rb b/spec/factories/channel/channel_voice.rb
new file mode 100644
index 000000000..33be75f2e
--- /dev/null
+++ b/spec/factories/channel/channel_voice.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :channel_voice, class: 'Channel::Voice' do
+ sequence(:phone_number) { |n| "+155512345#{n.to_s.rjust(2, '0')}" }
+ provider_config do
+ {
+ account_sid: "AC#{SecureRandom.hex(16)}",
+ auth_token: SecureRandom.hex(16),
+ api_key_sid: SecureRandom.hex(8),
+ api_key_secret: SecureRandom.hex(16)
+ }
+ end
+ account
+
+ after(:create) do |channel_voice|
+ create(:inbox, channel: channel_voice, account: channel_voice.account)
+ end
+ end
+end
diff --git a/spec/jobs/webhooks/twilio_events_job_spec.rb b/spec/jobs/webhooks/twilio_events_job_spec.rb
index f42caf675..aad70c68b 100644
--- a/spec/jobs/webhooks/twilio_events_job_spec.rb
+++ b/spec/jobs/webhooks/twilio_events_job_spec.rb
@@ -79,4 +79,25 @@ RSpec.describe Webhooks::TwilioEventsJob do
described_class.perform_now(params_with_media)
end
end
+
+ context 'when location message is present' do
+ let(:params_with_location) do
+ {
+ From: 'whatsapp:+1234567890',
+ To: 'whatsapp:+0987654321',
+ MessageType: 'location',
+ Latitude: '12.160894393921',
+ Longitude: '75.265205383301',
+ AccountSid: 'AC123',
+ SmsSid: 'SM123'
+ }
+ end
+
+ it 'processes the location message' do
+ service = double
+ expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_location).and_return(service)
+ expect(service).to receive(:perform)
+ described_class.perform_now(params_with_location)
+ end
+ end
end
diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb
index 9a6b8d123..0edf79556 100644
--- a/spec/listeners/reporting_event_listener_spec.rb
+++ b/spec/listeners/reporting_event_listener_spec.rb
@@ -66,6 +66,34 @@ describe ReportingEventListener do
end
describe '#reply_created' do
+ let(:contact) { create(:contact, account: account) }
+
+ def create_customer_message(conversation, created_at: Time.current)
+ create(:message,
+ message_type: 'incoming',
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ sender: contact,
+ created_at: created_at)
+ end
+
+ def create_agent_message(conversation, created_at: Time.current, sender: user)
+ create(:message,
+ message_type: 'outgoing',
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ sender: sender,
+ created_at: created_at)
+ end
+
+ def create_reply_event(agent_message, waiting_since, event_time = nil)
+ Events::Base.new('reply.created', event_time || agent_message.created_at,
+ waiting_since: waiting_since,
+ message: agent_message)
+ end
+
it 'creates reply created event' do
event = Events::Base.new('reply.created', Time.zone.now, waiting_since: 2.hours.ago, message: message)
listener.reply_created(event)
@@ -74,6 +102,88 @@ describe ReportingEventListener do
expect(events.length).to be 1
expect(events.first.value).to be_within(1).of(7200)
end
+
+ context 'when conversation is reopened' do
+ let(:resolved_conversation) do
+ create(:conversation, account: account, inbox: inbox, assignee: user,
+ status: 'resolved', contact: contact)
+ end
+
+ context 'when customer sends message after resolution' do
+ it 'calculates reply time from the reopening message' do
+ customer_message_time = 3.hours.ago
+ create_customer_message(resolved_conversation, created_at: customer_message_time)
+
+ resolved_conversation.reload
+ expect(resolved_conversation.status).to eq('open')
+
+ agent_reply_time = 1.hour.ago
+ agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
+
+ event = create_reply_event(agent_message, customer_message_time)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 1
+ expect(events.first.value).to be_within(60).of(7200)
+ end
+ end
+
+ context 'when conversation has multiple reopenings' do
+ it 'tracks reply time correctly for each reopening' do
+ create_customer_message(resolved_conversation, created_at: 5.hours.ago)
+ first_agent_reply = create_agent_message(resolved_conversation, created_at: 4.hours.ago)
+
+ event = create_reply_event(first_agent_reply, 5.hours.ago)
+ listener.reply_created(event)
+
+ resolved_conversation.update!(status: 'resolved')
+
+ create_customer_message(resolved_conversation, created_at: 2.hours.ago)
+ second_agent_reply = create_agent_message(resolved_conversation, created_at: 1.5.hours.ago)
+
+ event = create_reply_event(second_agent_reply, 2.hours.ago)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ .order(created_at: :asc)
+ expect(events.length).to be 2
+ expect(events.first.value).to be_within(60).of(3600)
+ expect(events.second.value).to be_within(60).of(1800)
+ end
+ end
+
+ context 'when conversation is manually reopened' do
+ it 'sets waiting_since when first customer message arrives after manual reopening' do
+ resolved_conversation.update!(status: 'open')
+
+ customer_message_time = 1.hour.ago
+ create_customer_message(resolved_conversation, created_at: customer_message_time)
+
+ agent_reply_time = 15.minutes.ago
+ agent_message = create_agent_message(resolved_conversation, created_at: agent_reply_time)
+
+ event = create_reply_event(agent_message, customer_message_time)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 1
+ expect(events.first.value).to be_within(60).of(2700)
+ end
+ end
+
+ context 'when waiting_since is nil' do
+ it 'does not creates reply time events' do
+ agent_message = create_agent_message(resolved_conversation)
+
+ event = create_reply_event(agent_message, nil)
+ listener.reply_created(event)
+
+ events = account.reporting_events.where(name: 'reply_time', conversation_id: resolved_conversation.id)
+ expect(events.length).to be 0
+ end
+ end
+ end
end
describe '#first_reply_created' do
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 8485fcf7a..2a0d6c8b0 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -154,6 +154,27 @@ RSpec.describe ConversationReplyMailer do
expect(mail.message_id).to eq message.source_id
end
+ context 'when message is a CSAT survey' do
+ let(:csat_message) do
+ create(:message, conversation: conversation, account: account, message_type: 'template',
+ content_type: 'input_csat', content: 'How would you rate our support?', sender: agent)
+ end
+
+ it 'includes CSAT survey URL in outgoing_content' do
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ mail = described_class.email_reply(csat_message).deliver_now
+ expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ end
+ end
+
+ it 'uses outgoing_content for CSAT message body' do
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ mail = described_class.email_reply(csat_message).deliver_now
+ expect(mail.decoded).to include csat_message.outgoing_content
+ end
+ end
+ end
+
context 'with email attachments' do
it 'includes small attachments as email attachments' do
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index aef91603d..a29359528 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -836,4 +836,117 @@ RSpec.describe Conversation do
expect(message_window_service).to have_received(:can_reply?)
end
end
+
+ describe 'reply time calculation flows' do
+ include ActiveJob::TestHelper
+
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:contact) { create(:contact, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
+ let(:conversation_start_time) { 5.hours.ago }
+
+ before do
+ create(:inbox_member, user: agent, inbox: inbox)
+ # rubocop:disable Rails/SkipsModelValidations
+ conversation.update_column(:waiting_since, nil)
+ conversation.update_column(:created_at, conversation_start_time)
+ # rubocop:enable Rails/SkipsModelValidations
+ conversation.messages.destroy_all
+ conversation.reporting_events.destroy_all
+ conversation.reload
+ end
+
+ def create_customer_message(conversation, created_at: Time.current)
+ message = nil
+ perform_enqueued_jobs do
+ message = create(:message,
+ message_type: 'incoming',
+ account: conversation.account,
+ inbox: conversation.inbox,
+ conversation: conversation,
+ sender: conversation.contact,
+ created_at: created_at)
+ end
+ message
+ end
+
+ def create_agent_message(conversation, created_at: Time.current)
+ message = nil
+ perform_enqueued_jobs do
+ message = create(:message,
+ message_type: 'outgoing',
+ account: conversation.account,
+ inbox: conversation.inbox,
+ conversation: conversation,
+ sender: conversation.assignee,
+ created_at: created_at)
+ end
+ message
+ end
+
+ it 'correctly tracks waiting_since and creates first response time events' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+
+ # Agent replies - this should create first response event
+ agent_reply1_time = 4.hours.ago
+ create_agent_message(conversation, created_at: agent_reply1_time)
+
+ first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
+ expect(first_response_events.count).to eq(1)
+ expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
+
+ # the first response should also clear the waiting_since
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+ end
+
+ it 'does not reset waiting_since if customer sends another message' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+
+ create_customer_message(conversation, created_at: 3.hours.ago)
+ conversation.reload
+ expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
+ end
+
+ it 'records the correct reply_time for subsequent messages' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ create_agent_message(conversation, created_at: 4.hours.ago)
+ create_customer_message(conversation, created_at: 3.hours.ago)
+
+ create_agent_message(conversation, created_at: 2.hours.ago)
+ reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
+ expect(reply_events.count).to eq(1)
+ expect(reply_events.first.value).to be_within(1.second).of(1.hour)
+
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+ end
+
+ it 'records zero reply time if an agent sends a message after resolution' do
+ create_customer_message(conversation, created_at: conversation_start_time)
+ create_agent_message(conversation, created_at: 4.hours.ago)
+ create_customer_message(conversation, created_at: 3.hours.ago)
+
+ conversation.toggle_status
+ expect(conversation.status).to eq('resolved')
+
+ conversation.toggle_status
+ expect(conversation.status).to eq('open')
+
+ conversation.reload
+ expect(conversation.waiting_since).to be_nil
+
+ create_agent_message(conversation, created_at: 1.hour.ago)
+ # update_waiting_since will ensure that no events were created since the waiting_since was nil
+ # if the event is created it should log zero value, we have handled that in the reporting_event_listener
+ reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
+ expect(reply_events.count).to eq(0)
+ end
+ end
end
diff --git a/spec/presenters/message_content_presenter_spec.rb b/spec/presenters/message_content_presenter_spec.rb
index b1be37ef2..f85bdb8d1 100644
--- a/spec/presenters/message_content_presenter_spec.rb
+++ b/spec/presenters/message_content_presenter_spec.rb
@@ -34,20 +34,21 @@ RSpec.describe MessageContentPresenter do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
- allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns I18n default message when no CSAT config and dynamically generates survey URL' do
- expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
- allow(I18n).to receive(:t).with('conversations.survey.response', link: expected_url)
- .and_return("Please rate this conversation, #{expected_url}")
- expect(presenter.outgoing_content).to eq("Please rate this conversation, #{expected_url}")
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ expect(presenter.outgoing_content).to include(expected_url)
+ end
end
it 'returns CSAT config message when config exists and dynamically generates survey URL' do
- allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
- expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
- expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
+ with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
+ allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
+ expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
+ expect(presenter.outgoing_content).to eq("Custom CSAT message #{expected_url}")
+ end
end
end
end
diff --git a/spec/services/twilio/incoming_message_service_spec.rb b/spec/services/twilio/incoming_message_service_spec.rb
index c8812e45d..94fc3fdbf 100644
--- a/spec/services/twilio/incoming_message_service_spec.rb
+++ b/spec/services/twilio/incoming_message_service_spec.rb
@@ -260,5 +260,27 @@ describe Twilio::IncomingMessageService do
expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
end
end
+
+ context 'when a location message is received' do
+ let(:params_with_location) do
+ {
+ SmsSid: 'SMxx',
+ From: '+12345',
+ AccountSid: 'ACxxx',
+ MessagingServiceSid: twilio_channel.messaging_service_sid,
+ MessageType: 'location',
+ Latitude: '12.160894393921',
+ Longitude: '75.265205383301'
+ }
+ end
+
+ it 'creates a message with location attachment' do
+ described_class.new(params: params_with_location).perform
+
+ message = conversation.reload.messages.last
+ expect(message.attachments.count).to eq(1)
+ expect(message.attachments.first.file_type).to eq('location')
+ end
+ end
end
end