diff --git a/Gemfile b/Gemfile
index 680a0738b..7533cf3cf 100644
--- a/Gemfile
+++ b/Gemfile
@@ -76,7 +76,7 @@ gem 'faraday_middleware-aws-sigv4'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman'
-gem 'puma'
+gem 'puma', '~> 7.2', '>= 7.2.1'
gem 'vite_rails'
# metrics on heroku
gem 'barnes'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7151d0ff1..ad2a96921 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -593,7 +593,7 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
- nio4r (2.7.3)
+ nio4r (2.7.5)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -682,7 +682,7 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (7.0.5)
- puma (6.4.3)
+ puma (7.2.1)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
@@ -1132,7 +1132,7 @@ DEPENDENCIES
pgvector
procore-sift
pry-rails
- puma
+ puma (~> 7.2, >= 7.2.1)
pundit
rack-attack (>= 6.7.0)
rack-cors (= 2.0.0)
diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb
index 770018e3c..c74c0ecfc 100644
--- a/app/controllers/api/v1/accounts/portals_controller.rb
+++ b/app/controllers/api/v1/accounts/portals_controller.rb
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
- { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
+ { social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
+ { locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
)
end
+ def locale_translation_keys
+ params.dig(:portal, :config, :locale_translations)&.keys || []
+ end
+
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb
index 7fb27799f..10d0bc7e7 100644
--- a/app/controllers/concerns/access_token_auth_helper.rb
+++ b/app/controllers/concerns/access_token_auth_helper.rb
@@ -1,8 +1,9 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
- 'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
+ 'api/v1/accounts/conversations' => %w[show toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
- 'api/v1/accounts/conversations/assignments' => ['create']
+ 'api/v1/accounts/conversations/assignments' => ['create'],
+ 'api/v1/accounts/conversations/labels' => %w[index create]
}.freeze
def ensure_access_token
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index 63f44b052..57db11aec 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal'
def show
- @og_image_url = helpers.set_og_image_url('', @portal.header_text)
+ @og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
end
def sitemap
diff --git a/app/drops/contact_drop.rb b/app/drops/contact_drop.rb
index 1d450adb7..16240ec06 100644
--- a/app/drops/contact_drop.rb
+++ b/app/drops/contact_drop.rb
@@ -12,7 +12,7 @@ class ContactDrop < BaseDrop
end
def first_name
- @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
+ @obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb
index cf6f1b6a1..83d5e2347 100644
--- a/app/drops/user_drop.rb
+++ b/app/drops/user_drop.rb
@@ -12,7 +12,7 @@ class UserDrop < BaseDrop
end
def first_name
- @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
+ @obj.try(:name).try(:split).try(:first).try(:capitalize)
end
def last_name
diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index f21a97f78..6489d0194 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -34,6 +34,12 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
+voice_calls:
+ name: 'Voice Calls'
+ description: 'Enable voice calling capabilities for your agents and customers.'
+ enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
+ icon: 'icon-voice-line'
+ enterprise: true
# ------- Product Features ------- #
help_center:
diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
index 13f61a16c..14dd56ec9 100644
--- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
+++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
+ setMuted(shouldMute) {
+ if (!this.activeConnection) return false;
+ this.activeConnection.mute(shouldMute);
+ return shouldMute;
+ }
+
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
index fe4385bbb..d81b997c5 100644
--- a/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
+++ b/app/javascript/dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue
@@ -46,9 +46,8 @@ const formattedLastActivityAt = computed(() => {
:src="avatarSource"
class="shrink-0"
:name="name"
- :size="48"
+ :size="42"
hide-offline-status
- rounded-full
/>
diff --git a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue
index 1ffbf0c18..7615328ca 100644
--- a/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue
+++ b/app/javascript/dashboard/components-next/Companies/CompanyDetail/CompanyProfileCard.vue
@@ -141,7 +141,6 @@ const handleUpdateCompany = async () => {
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
- rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
index ba887b46f..50af6f0c0 100644
--- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
+++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue
@@ -124,10 +124,9 @@ const handleAvatarHover = isHovered => {
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue
new file mode 100644
index 000000000..11d38f2a2
--- /dev/null
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
index 62c644655..66d389ead 100644
--- a/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
+++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue
@@ -1,5 +1,7 @@
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
index b0dab9774..bf6f01f82 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue
@@ -48,7 +48,6 @@ const updateSignature = () => {
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
channel-type="Context::MessageSignature"
:enable-suggestions="false"
- show-image-resize-toolbar
/>
{
return conversation.display_id || conversation.id;
case 'assignee_id':
return conversation.meta?.assignee?.id;
+ case 'contact_id':
+ return (
+ conversation.meta?.sender?.id ||
+ conversation.contact?.id ||
+ conversation.contact_id
+ );
case 'inbox_id':
return conversation.inbox_id;
case 'team_id':
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
index adcf5c96f..7b20b94e1 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
@@ -244,6 +244,32 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
+ it('should match conversation with equal_to operator for contact_id', () => {
+ const conversation = { meta: { sender: { id: 42 } } };
+ const filters = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: { id: 42, name: 'Jane Doe' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with saved contact_id filter values', () => {
+ const conversation = { meta: { sender: { id: 42 } } };
+ const filters = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [42],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
// Standard attribute tests - priority
it('should match conversation with equal_to operator for priority', () => {
const conversation = { priority: 'urgent' };
diff --git a/app/javascript/dashboard/store/modules/customViews.js b/app/javascript/dashboard/store/modules/customViews.js
index a69bc2c17..388388e0f 100644
--- a/app/javascript/dashboard/store/modules/customViews.js
+++ b/app/javascript/dashboard/store/modules/customViews.js
@@ -15,6 +15,12 @@ const FILTER_KEYS = {
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
};
+// a folder's contact_id filter stores only the id, extract it so the
+// contact can be fetched and its name shown in the edit folder modal
+const getFolderContactId = folder =>
+ folder?.query?.payload?.find(filter => filter.attribute_key === 'contact_id')
+ ?.values?.[0];
+
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
@@ -47,6 +53,9 @@ export const getters = {
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
},
+ getActiveFolderContactId(_state) {
+ return getFolderContactId(_state.activeConversationFolder);
+ },
};
export const actions = {
@@ -104,8 +113,11 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
}
},
- setActiveConversationFolder({ commit }, data) {
+ setActiveConversationFolder({ commit, dispatch }, data) {
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
+ // prefetch the contact of a contact filter so the UI can show its name
+ const contactId = getFolderContactId(data);
+ if (contactId) dispatch('contacts/show', { id: contactId }, { root: true });
},
};
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
index f303eb5b6..a09dda17f 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/actions.spec.js
@@ -1,7 +1,11 @@
import axios from 'axios';
-import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
-import { customViewList, updateCustomViewList } from './fixtures';
+import { actions } from '../../customViews';
+import {
+ contactFilterView,
+ customViewList,
+ updateCustomViewList,
+} from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -106,5 +110,27 @@ describe('#actions', () => {
[types.default.SET_ACTIVE_CONVERSATION_FOLDER, customViewList[0]],
]);
});
+
+ it('prefetches the contact of a contact filter', async () => {
+ const dispatch = vi.fn();
+ await actions.setActiveConversationFolder(
+ { commit, dispatch },
+ contactFilterView
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ 'contacts/show',
+ { id: 42 },
+ { root: true }
+ );
+ });
+
+ it('does not prefetch without a contact filter', async () => {
+ const dispatch = vi.fn();
+ await actions.setActiveConversationFolder(
+ { commit, dispatch },
+ customViewList[0]
+ );
+ expect(dispatch).not.toHaveBeenCalled();
+ });
});
});
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js b/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
index c244467ed..6b0d06acf 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/fixtures.js
@@ -15,6 +15,21 @@ export const contactViewList = [
},
];
+export const contactFilterView = {
+ name: 'Contact view',
+ filter_type: 0,
+ query: {
+ payload: [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [42],
+ query_operator: null,
+ },
+ ],
+ },
+};
+
export const customViewList = [
{
name: 'Custom view',
diff --git a/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js b/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
index 709abc7a0..9d40ccf28 100644
--- a/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/customViews/getters.spec.js
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
-import { contactViewList, customViewList } from './fixtures';
+import { contactFilterView, contactViewList, customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViewsByFilterType', () => {
@@ -43,4 +43,20 @@ describe('#getters', () => {
customViewList[0]
);
});
+
+ it('getActiveFolderContactId', () => {
+ expect(
+ getters.getActiveFolderContactId({
+ activeConversationFolder: contactFilterView,
+ })
+ ).toEqual(42);
+ });
+
+ it('getActiveFolderContactId returns undefined without a contact filter', () => {
+ expect(
+ getters.getActiveFolderContactId({
+ activeConversationFolder: customViewList[0],
+ })
+ ).toBeUndefined();
+ });
});
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index 825132e13..eb8fecf01 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -1,28 +1,37 @@
+import MarkdownIt from 'markdown-it';
import mila from 'markdown-it-link-attributes';
import mentionPlugin from './markdownIt/link';
-import MarkdownIt from 'markdown-it';
-const setImageHeight = inlineToken => {
+const setImageSizing = inlineToken => {
const imgSrc = inlineToken.attrGet('src');
if (!imgSrc) return;
const url = new URL(imgSrc);
+ const width = url.searchParams.get('cw_image_width');
+ if (width) {
+ inlineToken.attrSet(
+ 'style',
+ `width: ${width}; max-width: 100%; height: auto;`
+ );
+ return;
+ }
const height = url.searchParams.get('cw_image_height');
- if (!height) return;
- inlineToken.attrSet('style', `height: ${height};`);
+ if (height) inlineToken.attrSet('style', `height: ${height};`);
};
const processInlineToken = blockToken => {
blockToken.children.forEach(inlineToken => {
if (inlineToken.type === 'image') {
- setImageHeight(inlineToken);
+ setImageSizing(inlineToken);
}
});
};
const imgResizeManager = md => {
- // Custom rule for image resize in markdown
- // If the image url has a query param cw_image_height, then add a style attribute to the image
- md.core.ruler.after('inline', 'add-image-height', state => {
+ // If the image URL carries a cw_image_width or cw_image_height query param,
+ // add an inline style attribute so the rendered
respects the agent's
+ // resize choice. Width takes precedence (HC drag-resize); height is kept for
+ // legacy messages and the message-signature use case.
+ md.core.ruler.after('inline', 'add-image-sizing', state => {
state.tokens.forEach(blockToken => {
if (blockToken.type === 'inline') {
processInlineToken(blockToken);
diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss
index b47179d29..c42273215 100755
--- a/app/javascript/widget/assets/scss/woot.scss
+++ b/app/javascript/widget/assets/scss/woot.scss
@@ -34,13 +34,24 @@ body {
.message-content {
ul {
- list-style: disc;
- @apply ltr:pl-3 rtl:pr-3;
+ @apply list-disc list-inside;
}
ol {
- list-style: decimal;
- @apply ltr:pl-4 rtl:pr-4;
+ @apply list-decimal list-inside;
+ }
+
+ li {
+ padding-inline-start: 1.5em;
+ text-indent: -1.5em;
+
+ > p:first-child {
+ @apply inline;
+ }
+
+ > * {
+ text-indent: 0;
+ }
}
}
diff --git a/app/jobs/mutex_application_job.rb b/app/jobs/mutex_application_job.rb
index 58c7cbf36..4143eab2a 100644
--- a/app/jobs/mutex_application_job.rb
+++ b/app/jobs/mutex_application_job.rb
@@ -14,6 +14,19 @@
class MutexApplicationJob < ApplicationJob
class LockAcquisitionError < StandardError; end
+ def self.retry_on_lock_conflict(wait:, attempts:, on_exhaustion: :raise)
+ retry_on LockAcquisitionError, wait: wait, attempts: attempts do |job, error|
+ raise error if on_exhaustion == :raise
+
+ job.public_send(on_exhaustion, *job.arguments)
+ end
+ end
+
+ # Redis::LockManager#unlock is not owner-checked. If a job runs past the TTL,
+ # Redis can expire the key, a newer job can acquire it, and the older job can
+ # then delete the newer job's lock on unlock. Current mutex users treat locks as
+ # short race dampeners, so this is acceptable for now. Future iterations should
+ # move Redis::LockManager to token-checked unlocks.
def with_lock(lock_key, timeout = Redis::LockManager::LOCK_TIMEOUT)
lock_manager = Redis::LockManager.new
diff --git a/app/jobs/webhooks/instagram_events_job.rb b/app/jobs/webhooks/instagram_events_job.rb
index 6383daff8..c27363127 100644
--- a/app/jobs/webhooks/instagram_events_job.rb
+++ b/app/jobs/webhooks/instagram_events_job.rb
@@ -1,6 +1,14 @@
class Webhooks::InstagramEventsJob < MutexApplicationJob
queue_as :default
- retry_on LockAcquisitionError, wait: 1.second, attempts: 8
+ # This lock is only a short race dampener for first-message conversation creation.
+ # ContactInbox creation is already protected by a unique index, but conversation
+ # lookup is `find active conversation || create`, so concurrent first messages from
+ # the same IG contact can create duplicate conversations.
+ #
+ # ActiveJob retries are not FIFO, so a longer retry window does not preserve message
+ # order. Use deterministic backoff so the final attempt happens after the 3s lock TTL,
+ # then process without the lock instead of dropping the webhook.
+ retry_on_lock_conflict wait: ->(executions) { executions.seconds }, attempts: 3, on_exhaustion: :process_without_lock
# @return [Array] We will support further events like reaction or seen in future
SUPPORTED_EVENTS = [:message, :read].freeze
@@ -9,11 +17,19 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries = entries
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
- with_lock(key) do
+ # Keep the lock TTL just long enough for the first job to fetch profile data and
+ # create the contact/conversation. A longer TTL would add user-visible latency for
+ # hot contacts without giving us ordering guarantees.
+ with_lock(key, 3.seconds) do
process_entries(entries)
end
end
+ def process_without_lock(entries)
+ Rails.logger.warn("[#{self.class.name}] Processing without lock after lock retry exhaustion")
+ process_entries(entries)
+ end
+
# https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook
def process_entries(entries)
entries.each do |entry|
diff --git a/app/mailboxes/imap/imap_mailbox.rb b/app/mailboxes/imap/imap_mailbox.rb
index 27bc88e06..25b8f4d85 100644
--- a/app/mailboxes/imap/imap_mailbox.rb
+++ b/app/mailboxes/imap/imap_mailbox.rb
@@ -65,11 +65,10 @@ class Imap::ImapMailbox
end
def in_reply_to
- @processed_mail.in_reply_to
+ sanitize_mailbox_value(@processed_mail.in_reply_to)
end
def find_conversation_by_references
- references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
@@ -80,8 +79,6 @@ class Imap::ImapMailbox
def find_message_by_references
message_to_return = nil
- references = Array.wrap(@inbound_mail.references)
-
references.each do |message_id|
message = @inbox.messages.find_by(source_id: message_id)
message_to_return = message if message.present?
@@ -100,7 +97,7 @@ class Imap::ImapMailbox
source: 'email',
in_reply_to: in_reply_to,
auto_reply: @processed_mail.auto_reply?,
- mail_subject: @processed_mail.subject,
+ mail_subject: sanitize_mailbox_value(@processed_mail.subject),
initiated_at: {
timestamp: Time.now.utc
}
@@ -110,7 +107,7 @@ class Imap::ImapMailbox
end
def find_or_create_contact
- @contact = @inbox.contacts.from_email(@processed_mail.original_sender)
+ @contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
@@ -119,6 +116,14 @@ class Imap::ImapMailbox
end
def identify_contact_name
- processed_mail.sender_name || processed_mail.from.first.split('@').first
+ sanitize_mailbox_value(processed_mail.sender_name || processed_mail.from.first.split('@').first)
+ end
+
+ def original_sender_email
+ sanitize_mailbox_value(@processed_mail.original_sender)
+ end
+
+ def references
+ sanitize_mailbox_value(Array.wrap(@inbound_mail.references))
end
end
diff --git a/app/mailboxes/mailbox_helper.rb b/app/mailboxes/mailbox_helper.rb
index edabfd4ce..fd3e0fb16 100644
--- a/app/mailboxes/mailbox_helper.rb
+++ b/app/mailboxes/mailbox_helper.rb
@@ -1,27 +1,16 @@
module MailboxHelper
include MailboxInlineAttachmentHelper
+ include MailboxSanitizer
include ::FileTypeHelper
private
def create_message
Rails.logger.info "[MailboxHelper] Creating message #{processed_mail.message_id}"
- return if @conversation.messages.find_by(source_id: processed_mail.message_id).present?
+ source_id = sanitize_mailbox_value(processed_mail.message_id)
+ return if @conversation.messages.find_by(source_id: source_id).present?
- @message = @conversation.messages.create!(
- account_id: @conversation.account_id,
- sender: @conversation.contact,
- content: mail_content&.truncate(150_000),
- inbox_id: @conversation.inbox_id,
- message_type: 'incoming',
- content_type: 'incoming_email',
- source_id: processed_mail.message_id,
- content_attributes: {
- email: processed_mail.serialized_data,
- cc_email: processed_mail.cc,
- bcc_email: processed_mail.bcc
- }
- )
+ @message = @conversation.messages.create!(sanitized_message_attributes(source_id))
end
def add_attachments_to_message
@@ -101,13 +90,16 @@ module MailboxHelper
end
def create_contact
+ sender_email = sanitize_mailbox_value(processed_mail.original_sender)
+ message_id = sanitize_mailbox_value(processed_mail.message_id)
+
@contact_inbox = ::ContactInboxWithContactBuilder.new(
- source_id: processed_mail.original_sender,
+ source_id: sender_email,
inbox: @inbox,
contact_attributes: {
- name: identify_contact_name,
- email: processed_mail.original_sender,
- additional_attributes: { source_id: "email:#{processed_mail.message_id}" }
+ name: sanitize_mailbox_value(identify_contact_name),
+ email: sender_email,
+ additional_attributes: { source_id: "email:#{message_id}" }
}
).perform
diff --git a/app/mailboxes/mailbox_sanitizer.rb b/app/mailboxes/mailbox_sanitizer.rb
new file mode 100644
index 000000000..b78bb1258
--- /dev/null
+++ b/app/mailboxes/mailbox_sanitizer.rb
@@ -0,0 +1,34 @@
+module MailboxSanitizer
+ NULL_BYTE = "\u0000".freeze
+
+ private
+
+ def sanitized_message_attributes(source_id)
+ {
+ account_id: @conversation.account_id,
+ sender: @conversation.contact,
+ content: sanitize_mailbox_value(mail_content)&.truncate(150_000),
+ inbox_id: @conversation.inbox_id,
+ message_type: 'incoming',
+ content_type: 'incoming_email',
+ source_id: source_id,
+ content_attributes: sanitized_content_attributes
+ }
+ end
+
+ def sanitized_content_attributes
+ sanitize_mailbox_value(
+ email: processed_mail.serialized_data,
+ cc_email: processed_mail.cc,
+ bcc_email: processed_mail.bcc
+ )
+ end
+
+ def sanitize_mailbox_value(value)
+ return value.delete(NULL_BYTE) if value.is_a?(String)
+ return value.map { |item| sanitize_mailbox_value(item) } if value.is_a?(Array)
+ return value.transform_values { |item| sanitize_mailbox_value(item) } if value.is_a?(Hash)
+
+ value
+ end
+end
diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb
index dc3e0c3fd..88266c14d 100644
--- a/app/mailers/conversation_reply_mailer_helper.rb
+++ b/app/mailers/conversation_reply_mailer_helper.rb
@@ -54,8 +54,7 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
- open_timeout: 15,
- read_timeout: 15,
+ **smtp_timeout_settings,
authentication: 'xoauth2'
}
end
@@ -72,6 +71,7 @@ module ConversationReplyMailerHelper
tls: @channel.smtp_enable_ssl_tls,
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
+ **smtp_timeout_settings,
authentication: @channel.smtp_authentication
}
@@ -79,6 +79,13 @@ module ConversationReplyMailerHelper
@options[:delivery_method_options] = smtp_settings
end
+ def smtp_timeout_settings
+ {
+ open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
+ read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
+ }.transform_values(&:to_i)
+ end
+
def email_smtp_enabled?
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
end
diff --git a/app/models/concerns/portal_config_schema.rb b/app/models/concerns/portal_config_schema.rb
new file mode 100644
index 000000000..de338b830
--- /dev/null
+++ b/app/models/concerns/portal_config_schema.rb
@@ -0,0 +1,35 @@
+module PortalConfigSchema
+ extend ActiveSupport::Concern
+
+ # Per-locale overrides for portal level fields. Any locale present in
+ # `allowed_locales` may carry its own `name`, `page_title` and `header_text`.
+ # Missing values fall back to the default locale and finally to the base column.
+ LOCALE_TRANSLATION_SCHEMA = {
+ 'type' => 'object',
+ 'properties' => {
+ 'name' => { 'type' => %w[string null] },
+ 'page_title' => { 'type' => %w[string null] },
+ 'header_text' => { 'type' => %w[string null] }
+ },
+ 'additionalProperties' => false
+ }.freeze
+
+ CONFIG_PARAMS_SCHEMA = {
+ 'type' => 'object',
+ 'properties' => {
+ 'allowed_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
+ 'default_locale' => { 'type' => %w[string null] },
+ 'draft_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
+ 'layout' => { 'type' => %w[string null], 'enum' => ['classic', 'documentation', nil] },
+ # TODO: unused reserved key; remove with a migration that scrubs it from existing portals' config
+ 'website_token' => { 'type' => %w[string null] },
+ 'social_profiles' => { 'type' => %w[object null] },
+ 'locale_translations' => {
+ 'type' => %w[object null],
+ 'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
+ }
+ },
+ 'required' => [],
+ 'additionalProperties' => true
+ }.to_json.freeze
+end
diff --git a/app/models/portal.rb b/app/models/portal.rb
index 41c4d3edd..9d2da6965 100644
--- a/app/models/portal.rb
+++ b/app/models/portal.rb
@@ -26,6 +26,7 @@
#
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
+ include PortalConfigSchema
DEFAULT_COLOR = '#1f93ff'.freeze
@@ -42,11 +43,17 @@ class Portal < ApplicationRecord
validates :name, presence: true
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
- validate :config_json_format
+ validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
+ before_validation :normalize_config
+ validate :validate_config
+ validates_with JsonSchemaValidator,
+ schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
+ attribute_resolver: ->(record) { record.config }
scope :active, -> { where(archived: false) }
- CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
+ # TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
+ CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
def file_base_data
{
@@ -90,8 +97,18 @@ class Portal < ApplicationRecord
self[:color].presence || DEFAULT_COLOR
end
- def display_title
- page_title.presence || name
+ def display_title(locale = default_locale)
+ localized_value('page_title', locale).presence || localized_value('name', locale)
+ end
+
+ # Resolves a portal level field for a locale, falling back to the default
+ # locale's value (its override or the base column) when the locale has no
+ # override of its own.
+ def localized_value(field, locale = default_locale)
+ translations = config_value('locale_translations') || {}
+ translations.dig(locale.to_s, field).presence ||
+ translations.dig(default_locale, field).presence ||
+ self[field]
end
def layout
@@ -104,11 +121,14 @@ class Portal < ApplicationRecord
private
- def config_json_format
+ def normalize_config
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
config['default_locale'] = default_locale
config['draft_locales'] = draft_locale_codes
+ end
+
+ def validate_config
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
diff --git a/app/services/facebook/send_on_facebook_service.rb b/app/services/facebook/send_on_facebook_service.rb
index baf72ef6e..0b2f45590 100644
--- a/app/services/facebook/send_on_facebook_service.rb
+++ b/app/services/facebook/send_on_facebook_service.rb
@@ -45,12 +45,12 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
end
def fb_text_message_params
- {
+ params = {
recipient: { id: contact.get_source_id(inbox.id) },
- message: fb_text_message_payload,
- messaging_type: 'MESSAGE_TAG',
- tag: message_tag
+ message: fb_text_message_payload
}
+
+ merge_human_agent_tag(params)
end
def fb_text_message_payload
@@ -79,7 +79,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
end
def fb_attachment_message_params(attachment)
- {
+ params = {
recipient: { id: contact.get_source_id(inbox.id) },
message: {
attachment: {
@@ -88,14 +88,21 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
url: attachment.download_url
}
}
- },
- messaging_type: 'MESSAGE_TAG',
- tag: message_tag
+ }
}
+
+ merge_human_agent_tag(params)
end
- def message_tag
- @message_tag ||= GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil) ? 'HUMAN_AGENT' : 'ACCOUNT_UPDATE'
+ def merge_human_agent_tag(params)
+ unless GlobalConfigService.load('ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT', nil)
+ params[:messaging_type] = 'RESPONSE'
+ return params
+ end
+
+ params[:messaging_type] = 'MESSAGE_TAG'
+ params[:tag] = 'HUMAN_AGENT'
+ params
end
def attachment_type(attachment)
@@ -104,11 +111,6 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
'file'
end
- def sent_first_outgoing_message_after_24_hours?
- # we can send max 1 message after 24 hour window
- conversation.messages.outgoing.where('id > ?', conversation.last_incoming_message.id).count == 1
- end
-
def handle_facebook_error(exception)
# Refer: https://github.com/jgorset/facebook-messenger/blob/64fe1f5cef4c1e3fca295b205037f64dfebdbcab/lib/facebook/messenger/error.rb
return unless exception.to_s.include?('The session has been invalidated') || exception.to_s.include?('Error validating access token')
diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb
index 5a6d3537c..24dd22589 100644
--- a/app/services/imap/base_fetch_email_service.rb
+++ b/app/services/imap/base_fetch_email_service.rb
@@ -58,8 +58,9 @@ class Imap::BaseFetchEmailService
return if email_already_present?(channel, message_id)
- # Fetch the original mail content using the sequence no
- mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
+ # Fetch the original mail content using the sequence no.
+ # BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers.
+ mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
diff --git a/app/services/mailbox/conversation_finder_strategies/base_strategy.rb b/app/services/mailbox/conversation_finder_strategies/base_strategy.rb
index c1e738805..fd61fd9e0 100644
--- a/app/services/mailbox/conversation_finder_strategies/base_strategy.rb
+++ b/app/services/mailbox/conversation_finder_strategies/base_strategy.rb
@@ -1,4 +1,6 @@
class Mailbox::ConversationFinderStrategies::BaseStrategy
+ include MailboxSanitizer
+
attr_reader :mail
def initialize(mail)
diff --git a/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb b/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb
index b14f851f5..e3d8270f1 100644
--- a/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb
+++ b/app/services/mailbox/conversation_finder_strategies/in_reply_to_strategy.rb
@@ -14,7 +14,7 @@ class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::Conver
def find
return nil if mail.in_reply_to.blank?
- in_reply_to_addresses = Array.wrap(mail.in_reply_to)
+ in_reply_to_addresses = sanitize_mailbox_value(Array.wrap(mail.in_reply_to))
in_reply_to_addresses.each do |in_reply_to|
# Try extracting UUID from patterns
diff --git a/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb b/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb
index a21fcebe5..f9f5c3cf8 100644
--- a/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb
+++ b/app/services/mailbox/conversation_finder_strategies/new_conversation_strategy.rb
@@ -45,11 +45,11 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
end
def original_sender_email
- @processed_mail.original_sender&.downcase
+ sanitize_mailbox_value(@processed_mail.original_sender)&.downcase
end
def identify_contact_name
- @processed_mail.sender_name || @processed_mail.from.first.split('@').first
+ sanitize_mailbox_value(@processed_mail.sender_name || @processed_mail.from.first.split('@').first)
end
def build_conversation
@@ -63,7 +63,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
- mail_subject: @processed_mail.subject,
+ mail_subject: sanitize_mailbox_value(@processed_mail.subject),
initiated_at: {
timestamp: Time.now.utc
}
@@ -72,7 +72,7 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
end
def in_reply_to
- mail['In-Reply-To'].try(:value)
+ sanitize_mailbox_value(mail['In-Reply-To'].try(:value))
end
def find_conversation_by_in_reply_to
diff --git a/app/services/mailbox/conversation_finder_strategies/references_strategy.rb b/app/services/mailbox/conversation_finder_strategies/references_strategy.rb
index 86a0aa3c5..4420c5c7d 100644
--- a/app/services/mailbox/conversation_finder_strategies/references_strategy.rb
+++ b/app/services/mailbox/conversation_finder_strategies/references_strategy.rb
@@ -21,7 +21,7 @@ class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::Conve
return nil if mail.references.blank?
return nil unless @channel # No valid channel found
- references = Array.wrap(mail.references)
+ references = sanitize_mailbox_value(Array.wrap(mail.references))
references.each do |reference|
conversation = find_conversation_from_reference(reference)
diff --git a/app/services/whatsapp/incoming_message_base_service.rb b/app/services/whatsapp/incoming_message_base_service.rb
index 722ac3e4d..9e0720f74 100644
--- a/app/services/whatsapp/incoming_message_base_service.rb
+++ b/app/services/whatsapp/incoming_message_base_service.rb
@@ -147,7 +147,7 @@ class Whatsapp::IncomingMessageBaseService
def attach_location
location = messages_data.first['location']
- location_name = location['name'] ? "#{location['name']}, #{location['address']}" : ''
+ location_name = (location['name'] ? "#{location['name']}, #{location['address']}" : '').first(255)
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
diff --git a/app/views/api/v1/accounts/portals/_portal.json.jbuilder b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
index 08b3e8b36..93626ee36 100644
--- a/app/views/api/v1/accounts/portals/_portal.json.jbuilder
+++ b/app/views/api/v1/accounts/portals/_portal.json.jbuilder
@@ -18,6 +18,7 @@ json.config do
json.default_locale portal.default_locale
json.layout portal.layout
json.social_profiles portal.social_profiles
+ json.locale_translations portal.config['locale_translations'] || {}
end
if portal.channel_web_widget
diff --git a/app/views/layouts/_portal_head.html.erb b/app/views/layouts/_portal_head.html.erb
index 882064d52..64e24a150 100644
--- a/app/views/layouts/_portal_head.html.erb
+++ b/app/views/layouts/_portal_head.html.erb
@@ -16,7 +16,7 @@
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
- <%= @portal.display_title %>
+ <%= @portal.display_title(@locale) %>
<% end %>
<% if @portal.logo.present? %>
diff --git a/app/views/layouts/_portal_scripts.html.erb b/app/views/layouts/_portal_scripts.html.erb
index 1f2b81f12..b1479cace 100644
--- a/app/views/layouts/_portal_scripts.html.erb
+++ b/app/views/layouts/_portal_scripts.html.erb
@@ -89,5 +89,9 @@ html.light {
};
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
+
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb
index 3db8efa59..189371261 100644
--- a/app/views/public/api/v1/portals/_header.html.erb
+++ b/app/views/public/api/v1/portals/_header.html.erb
@@ -5,7 +5,7 @@
<% if @portal.logo.present? %>
<% end %>
- <%= @portal.name %>
+ <%= @portal.localized_value('name', @locale) %>
@@ -106,7 +106,7 @@
<% if @portal.logo.present? %>
<% end %>
- <%= @portal.name %>
+ <%= @portal.localized_value('name', @locale) %>
diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb
index c1ead0e59..1e2bf119c 100644
--- a/app/views/public/api/v1/portals/_hero.html.erb
+++ b/app/views/public/api/v1/portals/_hero.html.erb
@@ -1,7 +1,7 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
- <%= @portal.display_title %>
-
+ <%= @portal.display_title(@locale) %>
+
<% if @og_image_url.present? %>
@@ -13,9 +13,9 @@
-
<%= @portal.name %>
+
<%= @portal.localized_value('name', @locale) %>
- <%= portal.header_text %>
+ <%= portal.localized_value('header_text', @locale) %>
<%= I18n.t('public_portal.hero.sub_title') %>
diff --git a/app/views/public/api/v1/portals/articles/index.html.erb b/app/views/public/api/v1/portals/articles/index.html.erb
index d040bbc24..e4dc9aa87 100644
--- a/app/views/public/api/v1/portals/articles/index.html.erb
+++ b/app/views/public/api/v1/portals/articles/index.html.erb
@@ -6,7 +6,7 @@
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
- <%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
+ <%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
/
/
diff --git a/app/views/public/api/v1/portals/articles/show.html.erb b/app/views/public/api/v1/portals/articles/show.html.erb
index 784817c9c..e35b19009 100644
--- a/app/views/public/api/v1/portals/articles/show.html.erb
+++ b/app/views/public/api/v1/portals/articles/show.html.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= @article.title %> | <%= @portal.display_title %>
+
<%= @article.title %> | <%= @portal.display_title(@locale) %>
<% if @article.meta["title"].present? %>
">
">
diff --git a/app/views/public/api/v1/portals/categories/_hero.html.erb b/app/views/public/api/v1/portals/categories/_hero.html.erb
index 0c179338d..1b481617e 100644
--- a/app/views/public/api/v1/portals/categories/_hero.html.erb
+++ b/app/views/public/api/v1/portals/categories/_hero.html.erb
@@ -1,7 +1,7 @@
-
<%= portal.header_text %>
+
<%= portal.localized_value('header_text', @locale) %>
<%= I18n.t('public_portal.hero.sub_title') %>
diff --git a/app/views/public/api/v1/portals/categories/show.html.erb b/app/views/public/api/v1/portals/categories/show.html.erb
index 6657559d0..e7179db73 100644
--- a/app/views/public/api/v1/portals/categories/show.html.erb
+++ b/app/views/public/api/v1/portals/categories/show.html.erb
@@ -1,6 +1,6 @@
<% content_for :head do %>
-
<%= @category.name %> | <%= @portal.display_title %>
-
+
<%= @category.name %> | <%= @portal.display_title(@locale) %>
+
<% if @category.description.present? %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
index c7df0517d..af80d46ac 100644
--- a/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/_hero.html.erb
@@ -5,7 +5,7 @@
- <%= portal.header_text.presence || 'How can we help?' %>
+ <%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
<%= I18n.t('public_portal.hero.sub_title') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
index 46d6f3558..00f408d70 100644
--- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
@@ -11,7 +11,7 @@
<% if portal.logo.present? %>
<% end %>
- <%= portal.name %>
+ <%= portal.localized_value('name', locale) %>
<%= I18n.t('public_portal.sidebar.help_center') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
index a236722ae..aeed7ff6c 100644
--- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
@@ -1,4 +1,4 @@
-
<%= article.title %> | <%= portal.display_title %>
+
<%= article.title %> | <%= portal.display_title(article.locale) %>
<% if article.meta["title"].present? %>
">
">
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
index 7c8dec8d2..ac36496df 100644
--- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
@@ -1,5 +1,5 @@
-
<%= category.name %> | <%= portal.display_title %>
-
+
<%= category.name %> | <%= portal.display_title(category.locale) %>
+
<% if category.description.present? %>
diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb
index 8577a5f4e..f77517618 100644
--- a/app/views/public/api/v1/portals/search/index.html+documentation.erb
+++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %>
+
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %>
<% end %>
diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb
index 82c29775f..b4a1e77ed 100644
--- a/app/views/public/api/v1/portals/search/index.html.erb
+++ b/app/views/public/api/v1/portals/search/index.html.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %>
+
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index e80d1e164..39253c969 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -128,6 +128,10 @@
+
+
+
+
diff --git a/enterprise/app/helpers/captain/chat_generation_recorder.rb b/enterprise/app/helpers/captain/chat_generation_recorder.rb
index cd631fb16..63bcdc276 100644
--- a/enterprise/app/helpers/captain/chat_generation_recorder.rb
+++ b/enterprise/app/helpers/captain/chat_generation_recorder.rb
@@ -10,6 +10,7 @@ module Captain::ChatGenerationRecorder
# Create a generation span with model and token info for Langfuse cost calculation.
# Note: span duration will be near-zero since we create and end it immediately, but token counts are what Langfuse uses for cost calculation.
tracer.in_span("llm.captain.#{feature_name}.generation") do |span|
+ apply_current_langfuse_attributes(span)
set_generation_span_attributes(span, chat, message)
end
rescue StandardError => e
@@ -37,11 +38,23 @@ module Captain::ChatGenerationRecorder
ATTR_GEN_AI_USAGE_INPUT_TOKENS => message.input_tokens,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS => message.respond_to?(:output_tokens) ? message.output_tokens : nil,
ATTR_LANGFUSE_OBSERVATION_INPUT => format_input_messages(chat),
- ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil
+ ATTR_LANGFUSE_OBSERVATION_OUTPUT => message.respond_to?(:content) ? message.content.to_s : nil,
+ format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
}
end
def format_input_messages(chat)
chat.messages[0...-1].map { |m| { role: m.role.to_s, content: m.content.to_s } }.to_json
end
+
+ def generation_stage(message)
+ message_has_tool_calls?(message) ? 'tool_call' : 'final_response'
+ end
+
+ def message_has_tool_calls?(message)
+ return false unless message.respond_to?(:tool_calls)
+
+ tool_calls = message.tool_calls
+ tool_calls.respond_to?(:any?) && tool_calls.any?
+ end
end
diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb
index 93f68b05b..3e05244d3 100644
--- a/enterprise/app/services/captain/llm/translate_query_service.rb
+++ b/enterprise/app/services/captain/llm/translate_query_service.rb
@@ -32,6 +32,10 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def query_in_target_language?(query)
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
result = detector.find_language(query)
diff --git a/enterprise/app/services/captain/tools/firecrawl_service.rb b/enterprise/app/services/captain/tools/firecrawl_service.rb
index bee7219e8..6797634a2 100644
--- a/enterprise/app/services/captain/tools/firecrawl_service.rb
+++ b/enterprise/app/services/captain/tools/firecrawl_service.rb
@@ -1,5 +1,5 @@
class Captain::Tools::FirecrawlService
- BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
+ BASE_URL = 'https://api.firecrawl.dev/v2'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
def crawl_payload(url, webhook_url, crawl_limit)
{
url: url,
- maxDepth: 50,
- ignoreSitemap: false,
+ maxDiscoveryDepth: 50,
+ sitemap: 'include',
limit: crawl_limit,
- webhook: webhook_url,
+ webhook: { url: webhook_url },
scrapeOptions: scrape_options
}.to_json
end
@@ -51,7 +51,8 @@ class Captain::Tools::FirecrawlService
{
onlyMainContent: true,
formats: ['markdown'],
- excludeTags: FIRECRAWL_EXCLUDE_TAGS
+ excludeTags: FIRECRAWL_EXCLUDE_TAGS,
+ maxAge: 0
}
end
diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb
index aa40e8000..c45559165 100644
--- a/enterprise/lib/captain/conversation_completion_service.rb
+++ b/enterprise/lib/captain/conversation_completion_service.rb
@@ -62,6 +62,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def event_name
'captain.conversation_completion'
end
diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb
index f530e71ee..2329dea6d 100644
--- a/lib/base_markdown_renderer.rb
+++ b/lib/base_markdown_renderer.rb
@@ -1,9 +1,9 @@
class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
def image(node)
src, title = extract_img_attributes(node)
- height = extract_image_height(src)
+ sizing_style = extract_image_sizing_style(src)
- render_img_tag(src, title, height)
+ render_img_tag(src, title, sizing_style)
end
private
@@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
]
end
- def extract_image_height(src)
+ # Drag-resize from the reply editor encodes the chosen width as cw_image_width
+ # on the URL; the older message-signature picker uses cw_image_height. Width
+ # wins when both are set so the agent's most recent intent is honored.
+ def extract_image_sizing_style(src)
query_params = parse_query_params(src)
- query_params['cw_image_height']&.first
+ width = sanitize_pixel_value(query_params['cw_image_width']&.first)
+ return "width: #{width}; max-width: 100%; height: auto;" if width
+
+ height = sanitize_pixel_value(query_params['cw_image_height']&.first)
+ height ? "height: #{height};" : nil
+ end
+
+ # Only allow a bounded `px` value so the decoded query param can't
+ # break out of the inline style attribute (HTML attribute injection).
+ def sanitize_pixel_value(raw)
+ return unless raw =~ /\A(\d+)px\z/
+
+ px = Regexp.last_match(1).to_i
+ "#{px}px" if px.between?(1, 2000)
end
def parse_query_params(url)
@@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer
{}
end
- def render_img_tag(src, title, height = nil)
+ def render_img_tag(src, title, sizing_style = nil)
title_attribute = title.present? ? " title=\"#{title}\"" : ''
- # Use inline style instead of the HTML height attribute: email clients and
- # the in-app Letter view both run images through CSS (e.g. prose /
+ # Use inline style instead of HTML width/height attributes: email clients
+ # and the in-app Letter view both run images through CSS (e.g. prose /
# lettersanitizer's `img { height: auto }`) which overrides presentational
# attributes. Inline style has higher specificity and survives.
- style_attribute = height ? " style=\"height: #{height};\"" : ''
+ style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : ''
plain do
# plain ensures that the content is not wrapped in a paragraph tag
diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb
index a043d38e2..d382204a5 100644
--- a/lib/captain/base_task_service.rb
+++ b/lib/captain/base_task_service.rb
@@ -150,13 +150,12 @@ class Captain::BaseTaskService
end
# Extension point consulted by the Enterprise quota wrapper. Subclasses
- # whose calls run on the operator's key (e.g. internal/onboarding tasks)
- # should override this to return false. When false, the wrapper neither
- # blocks the call on an exhausted captain_responses quota nor decrements
- # it on success — the call participates in the quota system in neither
- # direction.
+ # whose calls should not consume captain_responses should override this to
+ # return false. When false, the wrapper neither blocks the call on an
+ # exhausted captain_responses quota nor decrements it on success — the call
+ # participates in the quota system in neither direction.
def counts_toward_usage?
- true
+ llm_credential&.dig(:source) != :hook
end
def api_key_configured?
@@ -168,7 +167,15 @@ class Captain::BaseTaskService
end
def llm_credential
- @llm_credential ||= hook_llm_credential || system_llm_credential
+ @llm_credential ||= if use_account_openai_hook?
+ hook_llm_credential || system_llm_credential
+ else
+ system_llm_credential
+ end
+ end
+
+ def use_account_openai_hook?
+ false
end
def hook_llm_credential
diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb
index e04a98a7f..7aab18e6c 100644
--- a/lib/captain/csat_utility_analysis_service.rb
+++ b/lib/captain/csat_utility_analysis_service.rb
@@ -63,4 +63,8 @@ class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
def event_name
'csat_utility_analysis'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/follow_up_service.rb b/lib/captain/follow_up_service.rb
index f02ba9408..c4c1225be 100644
--- a/lib/captain/follow_up_service.rb
+++ b/lib/captain/follow_up_service.rb
@@ -103,4 +103,8 @@ class Captain::FollowUpService < Captain::BaseTaskService
def event_name
'follow_up'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb
index 02f8bd89a..a0e030963 100644
--- a/lib/captain/label_suggestion_service.rb
+++ b/lib/captain/label_suggestion_service.rb
@@ -87,6 +87,10 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
'label_suggestion'
end
+ def use_account_openai_hook?
+ true
+ end
+
def build_follow_up_context?
false
end
diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb
index 2daf0615c..039bdcf26 100644
--- a/lib/captain/reply_suggestion_service.rb
+++ b/lib/captain/reply_suggestion_service.rb
@@ -37,6 +37,10 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
def event_name
'reply_suggestion'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
diff --git a/lib/captain/rewrite_service.rb b/lib/captain/rewrite_service.rb
index 3a217d3c6..6f880e775 100644
--- a/lib/captain/rewrite_service.rb
+++ b/lib/captain/rewrite_service.rb
@@ -56,4 +56,8 @@ class Captain::RewriteService < Captain::BaseTaskService
def event_name
operation
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb
index 030c0e510..f06aa42ca 100644
--- a/lib/captain/summary_service.rb
+++ b/lib/captain/summary_service.rb
@@ -24,4 +24,8 @@ class Captain::SummaryService < Captain::BaseTaskService
def event_name
'summarize'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/tool_instrumentation.rb b/lib/captain/tool_instrumentation.rb
index 157aab829..3c4a1c76e 100644
--- a/lib/captain/tool_instrumentation.rb
+++ b/lib/captain/tool_instrumentation.rb
@@ -10,12 +10,14 @@ module Captain::ToolInstrumentation
response = nil
executed = false
- tracer.in_span(params[:span_name]) do |span|
- set_tool_session_attributes(span, params)
- response = yield
- executed = true
- span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
- set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
+ with_propagated_langfuse_attributes(params) do
+ tracer.in_span(params[:span_name]) do |span|
+ set_tool_session_attributes(span, params)
+ response = yield
+ executed = true
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, response[:message] || response.to_json)
+ set_tool_session_error_attributes(span, response) if response.is_a?(Hash)
+ end
end
response
rescue StandardError => e
@@ -24,9 +26,7 @@ module Captain::ToolInstrumentation
end
def set_tool_session_attributes(span, params)
- span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
- span.set_attribute(ATTR_LANGFUSE_SESSION_ID, "#{params[:account_id]}_#{params[:conversation_id]}") if params[:conversation_id].present?
- span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
+ set_metadata_attributes(span, params)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
end
@@ -43,6 +43,7 @@ module Captain::ToolInstrumentation
return unless message.respond_to?(:role) && message.role.to_s == 'assistant'
tracer.in_span("llm.#{event_name}.generation") do |span|
+ apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model)
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens)
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index 25d0e5196..006a862b2 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -44,6 +44,12 @@ conversations:
- "not_equal_to"
- "is_present"
- "is_not_present"
+ contact_id:
+ attribute_type: "standard"
+ data_type: "number"
+ filter_operators:
+ - "equal_to"
+ - "not_equal_to"
priority:
attribute_type: "standard"
data_type: "text"
diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb
index 326bb901e..0257f5c3a 100644
--- a/lib/integrations/llm_instrumentation.rb
+++ b/lib/integrations/llm_instrumentation.rb
@@ -29,16 +29,18 @@ module Integrations::LlmInstrumentation
result = nil
executed = false
- tracer.in_span(params[:span_name]) do |span|
- set_metadata_attributes(span, params)
+ with_propagated_langfuse_attributes(params) do
+ tracer.in_span(params[:span_name]) do |span|
+ set_metadata_attributes(span, params)
- # By default, the input and output of a trace are set from the root observation
- span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
- result = yield
- executed = true
- span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
- set_error_attributes(span, result) if result.is_a?(Hash)
- result
+ # By default, the input and output of a trace are set from the root observation
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
+ result = yield
+ executed = true
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
+ set_error_attributes(span, result) if result.is_a?(Hash)
+ result
+ end
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
@@ -51,6 +53,7 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
+ apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
@@ -96,23 +99,6 @@ module Integrations::LlmInstrumentation
end
end
- def instrument_with_span(span_name, params, &)
- result = nil
- executed = false
- tracer.in_span(span_name) do |span|
- track_result = lambda do |r|
- executed = true
- result = r
- end
- yield(span, track_result)
- end
- rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
- raise unless executed
-
- result
- end
-
private
def resolve_account(params)
diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb
index 551d0780f..26af2aae1 100644
--- a/lib/integrations/llm_instrumentation_completion_helpers.rb
+++ b/lib/integrations/llm_instrumentation_completion_helpers.rb
@@ -10,7 +10,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
- set_common_span_metadata(span, params)
end
def set_audio_transcription_span_attributes(span, params)
@@ -18,7 +17,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
- set_common_span_metadata(span, params)
end
def set_moderation_span_attributes(span, params)
@@ -26,12 +24,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
- set_common_span_metadata(span, params)
- end
-
- def set_common_span_metadata(span, params)
- span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
- span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
end
def set_embedding_result_attributes(span, result)
diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb
index dfe1e7704..f274d1145 100644
--- a/lib/integrations/llm_instrumentation_constants.rb
+++ b/lib/integrations/llm_instrumentation_constants.rb
@@ -29,4 +29,5 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
+ ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s'
end
diff --git a/lib/integrations/llm_instrumentation_context.rb b/lib/integrations/llm_instrumentation_context.rb
new file mode 100644
index 000000000..27b1eb2b2
--- /dev/null
+++ b/lib/integrations/llm_instrumentation_context.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Integrations::LlmInstrumentationContext
+ LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes
+ LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes
+
+ private
+
+ def with_propagated_langfuse_attributes(params)
+ previous_attributes = current_langfuse_attributes
+ previous_observation_metadata_attributes = current_observation_metadata_attributes
+ self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params))
+ self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))
+
+ yield
+ ensure
+ self.current_langfuse_attributes = previous_attributes
+ self.current_observation_metadata_attributes = previous_observation_metadata_attributes
+ end
+
+ def apply_current_langfuse_attributes(span)
+ set_langfuse_attributes(span, current_langfuse_attributes)
+ set_langfuse_attributes(span, current_observation_metadata_attributes)
+ end
+
+ def current_langfuse_attributes
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {}
+ end
+
+ def current_langfuse_attributes=(attrs)
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs
+ end
+
+ def current_observation_metadata_attributes
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {}
+ end
+
+ def current_observation_metadata_attributes=(attrs)
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs
+ end
+end
diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb
index 129092ed4..debbfaeda 100644
--- a/lib/integrations/llm_instrumentation_helpers.rb
+++ b/lib/integrations/llm_instrumentation_helpers.rb
@@ -2,6 +2,7 @@
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
+ include Integrations::LlmInstrumentationContext
include Integrations::LlmInstrumentationCompletionHelpers
def determine_provider(model_name)
@@ -51,15 +52,55 @@ module Integrations::LlmInstrumentationHelpers
end
def set_metadata_attributes(span, params)
- session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
- span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
- span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
- span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
+ set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params)))
+ set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)))
+ end
- return unless params[:metadata].is_a?(Hash)
+ def propagated_langfuse_attributes(params)
+ attrs = {}
+ session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
+
+ attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id]
+ attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present?
+ attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present?
+
+ return attrs unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
- span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
+ attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s
+ end
+
+ attrs
+ end
+
+ def propagated_observation_metadata_attributes(params)
+ attrs = {}
+ session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
+
+ add_observation_metadata(attrs, 'user_id', params[:account_id])
+ add_observation_metadata(attrs, 'account_id', params[:account_id])
+ add_observation_metadata(attrs, 'session_id', session_id)
+ add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json)
+ add_observation_metadata(attrs, 'feature_name', params[:feature_name])
+
+ return attrs unless params[:metadata].is_a?(Hash)
+
+ params[:metadata].each do |key, value|
+ add_observation_metadata(attrs, key, value)
+ end
+
+ attrs
+ end
+
+ def add_observation_metadata(attrs, key, value)
+ return if value.blank?
+
+ attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s
+ end
+
+ def set_langfuse_attributes(span, attrs)
+ attrs.each do |key, value|
+ span.set_attribute(key, value)
end
end
end
diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb
index 85ea599f8..2def9749d 100644
--- a/lib/integrations/llm_instrumentation_spans.rb
+++ b/lib/integrations/llm_instrumentation_spans.rb
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
+ apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@@ -61,6 +62,24 @@ module Integrations::LlmInstrumentationSpans
Rails.logger.warn "Failed to end tool span: #{e.message}"
end
+ def instrument_with_span(span_name, params, &)
+ result = nil
+ executed = false
+ tracer.in_span(span_name) do |span|
+ set_metadata_attributes(span, params)
+ track_result = lambda do |r|
+ executed = true
+ result = r
+ end
+ yield(span, track_result)
+ end
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
+ raise unless executed
+
+ result
+ end
+
private
def set_llm_turn_request_attributes(span, params)
diff --git a/lib/opentelemetry_config.rb b/lib/opentelemetry_config.rb
index 5ed17e098..32be413d0 100644
--- a/lib/opentelemetry_config.rb
+++ b/lib/opentelemetry_config.rb
@@ -72,7 +72,10 @@ module OpentelemetryConfig
config = {
endpoint: traces_endpoint,
- headers: { 'Authorization' => "Basic #{auth_header}" }
+ headers: {
+ 'Authorization' => "Basic #{auth_header}",
+ 'x-langfuse-ingestion-version' => '4'
+ }
}
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
diff --git a/package.json b/package.json
index 081c706c6..d8527051d 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.13",
+ "@chatwoot/prosemirror-schema": "1.3.17",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index afcf5b60f..a4b61061c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.13
- version: 1.3.13
+ specifier: 1.3.17
+ version: 1.3.17
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.13':
- resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==}
+ '@chatwoot/prosemirror-schema@1.3.17':
+ resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.13':
+ '@chatwoot/prosemirror-schema@1.3.17':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3
new file mode 100644
index 000000000..c2af2b6d1
Binary files /dev/null and b/public/audio/dashboard/ringtone.mp3 differ
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 860791c0e..ccb5d7449 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'layout' => 'classic',
- 'social_profiles' => {}
+ 'social_profiles' => {},
+ 'locale_translations' => {}
}
)
end
diff --git a/spec/drops/contact_drop_spec.rb b/spec/drops/contact_drop_spec.rb
index d00a0924d..cd6d1a185 100644
--- a/spec/drops/contact_drop_spec.rb
+++ b/spec/drops/contact_drop_spec.rb
@@ -11,6 +11,11 @@ describe ContactDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word (capitalized) as first name when name has only one word' do
+ contact.update!(name: 'john')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized name') do
contact.update!(name: 'john doe')
expect(subject.name).to eq 'John Doe'
diff --git a/spec/drops/user_drop_spec.rb b/spec/drops/user_drop_spec.rb
index 1093ec4a0..34f8f5eaa 100644
--- a/spec/drops/user_drop_spec.rb
+++ b/spec/drops/user_drop_spec.rb
@@ -11,6 +11,11 @@ describe UserDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word as first name when name has only one word' do
+ user.update!(name: 'John')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized first name') do
user.update!(name: 'john doe')
expect(subject.first_name).to eq 'John'
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index b3dc473eb..fb970f726 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
context 'when usage limit is exceeded' do
@@ -111,6 +112,68 @@ RSpec.describe Captain::BaseTaskService, type: :model do
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
+ context 'when account has its own OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'still increments usage for services that do not opt into BYOK' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'returns usage limit exceeded error for services that do not opt into BYOK' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
+ expect(result[:error_code]).to eq(429)
+ end
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ result = perform_result
+ klass = Class.new(described_class) do
+ define_method(:perform) { result }
+ define_method(:event_name) { 'test_event' }
+ define_method(:use_account_openai_hook?) { true }
+ end
+ klass.prepend(Enterprise::Captain::BaseTaskService)
+ klass
+ end
+
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'does not increment usage on a successful result' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'bypasses the 429 gate and returns the underlying result' do
+ result = service.perform
+ expect(result).to eq(perform_result)
+ end
+ end
+ end
+
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
index 9d233943e..6b2cc55c8 100644
--- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
@@ -39,6 +39,30 @@ RSpec.describe Captain::Llm::AssistantChatService do
allow(mock_chat).to receive(:ask).and_return(mock_response)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
+
+ it 'marks final response generations for observation-level evaluators' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ message = instance_double(RubyLLM::Message, content: 'Final answer', input_tokens: 10, output_tokens: 20, tool_calls: {})
+
+ attributes = service.send(:generation_attributes, mock_chat, message)
+
+ expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
+ end
+
+ it 'marks tool call generations separately from final responses' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ message = instance_double(
+ RubyLLM::Message,
+ content: '',
+ input_tokens: 10,
+ output_tokens: 20,
+ tool_calls: { 'call_1' => instance_double(RubyLLM::ToolCall) }
+ )
+
+ attributes = service.send(:generation_attributes, mock_chat, message)
+
+ expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
+ end
end
describe 'image analysis' do
diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
index 4d4bc7aaf..9a099fc67 100644
--- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
@@ -53,14 +53,15 @@ RSpec.describe Captain::Tools::FirecrawlService do
let(:expected_payload) do
{
url: url,
- maxDepth: 50,
- ignoreSitemap: false,
+ maxDiscoveryDepth: 50,
+ sitemap: 'include',
limit: crawl_limit,
- webhook: webhook_url,
+ webhook: { url: webhook_url },
scrapeOptions: {
onlyMainContent: true,
formats: ['markdown'],
- excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
+ excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS,
+ maxAge: 0
}
}.to_json
end
@@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call is successful' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'makes a POST request with correct parameters' do
service.perform(url, webhook_url, crawl_limit)
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'uses default crawl limit when not specified' do
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
service.perform(url, webhook_url)
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call fails' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_raise(StandardError.new('Connection failed'))
end
@@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API returns an error response' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_return(status: 422, body: '{"error": "Invalid URL"}')
end
it 'makes the request but does not raise an error' do
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 91a56407d..4c8fa3394 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -55,4 +55,57 @@ RSpec.describe MutexApplicationJob do
end.to raise_error(StandardError)
end
end
+
+ describe '.retry_on_lock_conflict' do
+ let(:job_class) do
+ Class.new(described_class) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
+
+ attr_reader :fallback_args
+
+ def perform(lock_key, _payload)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+
+ def process_without_lock(lock_key, payload)
+ @fallback_args = [lock_key, payload]
+ end
+ end
+ end
+
+ let(:payload) { { 'message' => 'hello' } }
+
+ before do
+ stub_const('LockConflictTestJob', job_class)
+ end
+
+ it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ job = job_class.new(lock_key, payload)
+
+ expect { job.perform_now }.not_to raise_error
+ expect(job.fallback_args).to eq([lock_key, payload])
+ end
+
+ context 'without an exhaustion handler' do
+ let(:job_class) do
+ Class.new(described_class) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1
+
+ def perform(lock_key)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+ end
+ end
+
+ it 'raises the lock acquisition error when retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ expect do
+ job_class.perform_now(lock_key)
+ end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
+ end
+ end
+ end
end
diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb
index f8bdae4be..082f9fff6 100644
--- a/spec/lib/base_markdown_renderer_spec.rb
+++ b/spec/lib/base_markdown_renderer_spec.rb
@@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do
describe '#image' do
context 'when image has a height' do
it 'renders the img tag with the correct attributes' do
- markdown = ''
- expect(render_markdown(markdown)).to include('
')
+ markdown = ''
+ expect(render_markdown(markdown)).to include('
')
+ end
+ end
+
+ context 'when image has a width' do
+ it 'renders the img tag with the correct attributes' do
+ markdown = ''
+ expect(render_markdown(markdown)).to include(
+ '
'
+ )
+ end
+ end
+
+ context 'when the sizing param contains an attribute-injection payload' do
+ it 'drops the malicious height value' do
+ markdown = ')'
+ rendered = render_markdown(markdown)
+ expect(rendered).not_to include('style=')
+ expect(rendered).not_to include('onmouseover="')
+ end
+
+ it 'drops the malicious width value' do
+ markdown = ')'
+ rendered = render_markdown(markdown)
+ expect(rendered).not_to include('style=')
+ expect(rendered).not_to include('onmouseover="')
end
end
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index 5112e47ed..34c889967 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -260,11 +260,12 @@ RSpec.describe Captain::BaseTaskService do
expect(result[:request_messages]).to eq(messages)
end
- it 'does not track exceptions for account hook failures' do
+ it 'tracks exceptions against the system key when an account hook exists' do
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
- expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
- expect(ChatwootExceptionTracker).not_to receive(:new)
+ expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_raise(error)
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ expect(exception_tracker).to receive(:capture_exception)
result = service.send(:make_api_call, model: model, messages: messages)
@@ -279,11 +280,60 @@ RSpec.describe Captain::BaseTaskService do
before { hook }
+ it 'uses system api key by default' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ Class.new(described_class) do
+ def event_name
+ 'test_event'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+ end
+ end
+
+ before do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ end
+
it 'uses api key from hook' do
expect(service.send(:api_key)).to eq('hook-key')
end
end
+ it 'uses account OpenAI hook for editor task services' do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ user = create(:user, account: account)
+ follow_up_context = {
+ 'event_name' => 'professional',
+ 'original_context' => 'Original text',
+ 'last_response' => 'Last response'
+ }
+
+ editor_services = [
+ Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
+ Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
+ Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::FollowUpService.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: 'Make it shorter',
+ conversation_display_id: conversation.display_id
+ )
+ ]
+
+ editor_services.each do |editor_service|
+ expect(editor_service.send(:api_key)).to eq('hook-key')
+ end
+ end
+
context 'when openai hook is not configured' do
it 'uses system api key' do
expect(service.send(:api_key)).to eq('test-key')
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
index e4e980e01..34e0c9ece 100644
--- a/spec/lib/captain/csat_utility_analysis_service_spec.rb
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -4,6 +4,11 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
+ end
+
describe '#perform' do
before do
allow(account).to receive(:feature_enabled?).and_call_original
@@ -21,4 +26,22 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
end
end
+
+ describe '#api_key' do
+ context 'when account has an OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'uses the account hook key' do
+ expect(service.send(:api_key)).to eq('customer-own-key')
+ end
+ end
+
+ context 'when account does not have an OpenAI hook key' do
+ it 'uses the system key' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+ end
end
diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb
index 0be62f437..f291cd7b2 100644
--- a/spec/lib/integrations/llm_instrumentation_spec.rb
+++ b/spec/lib/integrations/llm_instrumentation_spec.rb
@@ -144,7 +144,10 @@ RSpec.describe Integrations::LlmInstrumentation do
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
- expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['reply_suggestion'])
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
end
it 'sets completion message attributes when result contains message' do
@@ -253,6 +256,76 @@ RSpec.describe Integrations::LlmInstrumentation do
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
end
+ it 'propagates trace attributes as observation metadata to child tool spans' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ tool_span = instance_double(OpenTelemetry::Trace::Span)
+ tool_instance = test_class.new
+ allow(root_span).to receive(:set_attribute)
+ allow(tool_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(tool_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('tool.search').and_yield(tool_span)
+
+ instance.instrument_agent_session(params) do
+ tool_instance.instrument_tool_call('search', { query: 'test' }) { 'tool result' }
+ end
+
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
+ end
+
+ it 'keeps inherited session metadata for nested service spans with their own feature tag' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ nested_span = instance_double(OpenTelemetry::Trace::Span)
+ nested_instance = test_class.new
+ nested_params = params.merge(span_name: 'llm.translate_query', conversation_id: nil, feature_name: 'translate_query')
+ allow(root_span).to receive(:set_attribute)
+ allow(nested_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(nested_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('llm.translate_query').and_yield(nested_span)
+
+ instance.instrument_agent_session(params) do
+ nested_instance.instrument_llm_call(nested_params) { 'translated query' }
+ end
+
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['translate_query'])
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'translate_query')
+ end
+
+ it 'propagates session metadata to nested embedding spans' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ embedding_span = instance_double(OpenTelemetry::Trace::Span)
+ embedding_instance = test_class.new
+ embedding_params = {
+ span_name: 'llm.captain.embedding',
+ account_id: 123,
+ feature_name: 'embedding',
+ model: 'text-embedding-3-small',
+ input: 'search result'
+ }
+ allow(root_span).to receive(:set_attribute)
+ allow(embedding_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(embedding_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('llm.captain.embedding').and_yield(embedding_span)
+
+ instance.instrument_agent_session(params) do
+ embedding_instance.instrument_embedding_call(embedding_params) { [0.1, 0.2, 0.3] }
+ end
+
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['embedding'])
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'embedding')
+ end
+
# Regression test for Langfuse double-counting bug.
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index 309a38a65..9a6797aaf 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -99,6 +99,33 @@ RSpec.describe Imap::ImapMailbox do
end
end
+ context 'when a new email contains null bytes' do
+ let(:inbound_mail) do
+ Mail.new.tap do |mail|
+ mail.from = 'email@gmail.com'
+ mail.to = 'imap@gmail.com'
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { class_instance.process(inbound_mail, channel) }.to change(Conversation, :count).by(1)
+
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'when a new email with invalid from' do
let(:inbound_mail) { create_inbound_email_from_mail(from: 'invalidemail', to: 'imap@gmail.com', subject: 'Hello!') }
diff --git a/spec/mailboxes/mailbox_helper_spec.rb b/spec/mailboxes/mailbox_helper_spec.rb
index 613040cb3..4b02205ec 100644
--- a/spec/mailboxes/mailbox_helper_spec.rb
+++ b/spec/mailboxes/mailbox_helper_spec.rb
@@ -47,6 +47,31 @@ RSpec.describe MailboxHelper do
helper_instance.send(:create_message)
end
end
+
+ context 'when message data contains null bytes' do
+ let(:mail) do
+ mail = Mail.new
+ mail.from = 'Sender '
+ mail.to = 'Inbox '
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ mail
+ end
+
+ it 'creates the message with sanitized values' do
+ helper_instance = mailbox_helper_obj.new(conversation, processed_mail)
+
+ expect { helper_instance.send(:create_message) }.to change(conversation.messages, :count).by(1)
+
+ message = conversation.messages.last
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.dig('email', 'message_id')).to eq('message@example.com')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
end
describe '#embed_plain_text_email_with_inline_image' do
diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb
index d062c7d73..20ce60dad 100644
--- a/spec/mailboxes/reply_mailbox_spec.rb
+++ b/spec/mailboxes/reply_mailbox_spec.rb
@@ -67,6 +67,40 @@ RSpec.describe ReplyMailbox do
end
end
+ context 'when new conversation email contains null bytes' do
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
+ let(:mail_with_null_bytes) do
+ Mail.new.tap do |mail|
+ mail.from = 'sender@example.com'
+ mail.to = email_channel.email
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ before do
+ allow(null_byte_mail).to receive(:mail).and_return(mail_with_null_bytes)
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { described_class.receive null_byte_mail }.to change(Conversation, :count).by(1)
+
+ conversation = Conversation.last
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'with inline attachments' do
let(:mail_with_inline_images) { create_inbound_email_from_fixture('mail_with_inline_images.eml') }
let(:described_subject) { described_class.receive mail_with_inline_images }
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 86a2363e9..df4c4f41f 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -462,6 +462,26 @@ RSpec.describe ConversationReplyMailer do
expect(mail.delivery_method.settings.empty?).to be false
expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com'
expect(mail.delivery_method.settings[:port]).to eq 587
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 15
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
+
+ it 'uses configured smtp timeout values' do
+ with_modified_env SMTP_OPEN_TIMEOUT: '10', SMTP_READ_TIMEOUT: '30' do
+ mail = described_class.email_reply(message)
+
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 10
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
+ end
+
+ it 'uses default smtp timeout values when env values are blank' do
+ with_modified_env SMTP_OPEN_TIMEOUT: '', SMTP_READ_TIMEOUT: '' do
+ mail = described_class.email_reply(message)
+
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 15
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
end
it 'renders sender name in the from address' do
diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb
index 38d9a7da6..c71a458fd 100644
--- a/spec/models/portal_spec.rb
+++ b/spec/models/portal_spec.rb
@@ -60,6 +60,94 @@ RSpec.describe Portal do
portal.update(custom_domain: '')
expect(portal.custom_domain).to be_nil
end
+
+ context 'with locale_translations' do
+ it 'allows valid locale translations' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } })
+
+ expect(portal).to be_valid
+ end
+
+ it 'rejects unknown fields within a locale translation' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'tagline' => 'nope' } } })
+
+ expect(portal).not_to be_valid
+ end
+
+ it 'retains a locale override after it becomes the default so it can still be edited' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro' } } })
+
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } })
+ end
+ end
+ end
+ end
+
+ describe '#localized_value' do
+ let!(:account) { create(:account) }
+ let!(:portal) do
+ create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+ end
+
+ it 'returns the override for the requested locale' do
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it 'falls back to the base column when the locale has no override for the field' do
+ expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme')
+ end
+
+ it 'falls back to the base column when the locale has no overrides at all' do
+ expect(portal.localized_value('name', 'fr')).to eq('Help Center')
+ end
+
+ it 'keeps serving the override for a locale that has become the default' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it "inherits the default locale's override for a locale without its own" do
+ portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the default locale when no locale is given' do
+ expect(portal.localized_value('name')).to eq('Help Center')
+ end
+ end
+
+ describe '#display_title' do
+ let!(:account) { create(:account) }
+
+ it 'prefers the localized page_title' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } })
+
+ expect(portal.display_title('es')).to eq('Centro | Acme')
+ end
+
+ it 'falls back to the localized name when no page_title is set' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+
+ expect(portal.display_title('es')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the base values for the default locale' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme')
+
+ expect(portal.display_title).to eq('Help Center | Acme')
end
end
end
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index 1bf5c219d..fa054b330 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -72,8 +72,8 @@ describe Conversations::FilterService do
it 'filter conversations by additional_attributes and status' do
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by priority' do
@@ -133,12 +133,84 @@ describe Conversations::FilterService do
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
end
+ it 'filters conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ matching_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(matching_conversation.id)
+ end
+
+ it 'filters conversations using not_equal_to contact operator' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ other_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'not_equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(other_conversation.id)
+ end
+
+ it 'applies inbox permissions when filtering conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ restricted_inbox = create(:inbox, account: account)
+ accessible_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: restricted_inbox, contact: contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(accessible_conversation.id)
+ end
+
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filters items with contains filter_operator with values being an array' do
@@ -184,10 +256,10 @@ describe Conversations::FilterService do
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where(
+ conversations = account.conversations.where(
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
)
- expect(result[:count][:all_count]).to be conversations.count
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by tags' do
@@ -413,8 +485,8 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
- expect(result[:conversations].length).to be expected_count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expect(result[:conversations].length).to eq expected_count
end
it 'binds created_at comparison values as dates' do
@@ -470,10 +542,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
- 'platinum').count
+ expected_count = account.conversations.where("created_at > ? AND custom_attributes->>'conversation_type' = ?",
+ DateTime.parse('2022-01-20'), 'platinum').count
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
context 'with x_days_before filter' do
@@ -502,11 +574,11 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
- 'platinum').count
+ expected_count = account.conversations.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?",
+ (Time.zone.today - 3.days), 'platinum').count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
it 'filter by last_activity_at 2_days_before' do
@@ -520,10 +592,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
+ expected_count = account.conversations.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
@@ -548,10 +620,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
expect(Current.account).to be_nil
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
diff --git a/spec/services/facebook/send_on_facebook_service_spec.rb b/spec/services/facebook/send_on_facebook_service_spec.rb
index f99b1c469..0a2b6407c 100644
--- a/spec/services/facebook/send_on_facebook_service_spec.rb
+++ b/spec/services/facebook/send_on_facebook_service_spec.rb
@@ -73,8 +73,7 @@ describe Facebook::SendOnFacebookService do
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
message: { text: message.content },
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
@@ -86,17 +85,26 @@ describe Facebook::SendOnFacebookService do
}
}
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
+ it 'sends as a standard RESPONSE without a tag by default' do
+ message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
+ described_class.new(message: message).perform
+ expect(bot).to have_received(:deliver).with(
+ hash_including(messaging_type: 'RESPONSE'),
+ { page_id: facebook_channel.page_id }
+ )
+ expect(bot).not_to have_received(:deliver).with(hash_including(:tag), anything)
+ end
+
it 'sends with HUMAN_AGENT tag when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled' do
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
described_class.new(message: message).perform
expect(bot).to have_received(:deliver).with(
- hash_including(tag: 'HUMAN_AGENT'),
+ hash_including(messaging_type: 'MESSAGE_TAG', tag: 'HUMAN_AGENT'),
{ page_id: facebook_channel.page_id }
)
end
@@ -201,8 +209,7 @@ describe Facebook::SendOnFacebookService do
{ content_type: 'text', payload: 'text 2', title: 'text 2' }
]
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
end
diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb
index a40a46343..1f74cf0b7 100644
--- a/spec/services/imap/fetch_email_service_spec.rb
+++ b/spec/services/imap/fetch_email_service_spec.rb
@@ -80,11 +80,11 @@ RSpec.describe Imap::FetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
@@ -93,7 +93,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{imap_email_channel.email}, found 1.")
expect(imap).to have_received(:logout)
end
@@ -115,7 +115,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result.length).to eq 0
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).not_to have_received(:fetch).with(1, 'BODY.PEEK[]')
end
end
@@ -129,12 +129,12 @@ RSpec.describe Imap::FetchEmailService do
Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id)
end
valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num])
allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers)
allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header])
- allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
@@ -143,7 +143,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]')
expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822')
+ expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]')
end
end
end
diff --git a/spec/services/imap/microsoft_fetch_email_service_spec.rb b/spec/services/imap/microsoft_fetch_email_service_spec.rb
index a4a0a62d1..cc20d5b35 100644
--- a/spec/services/imap/microsoft_fetch_email_service_spec.rb
+++ b/spec/services/imap/microsoft_fetch_email_service_spec.rb
@@ -30,11 +30,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: microsoft_channel).perform
@@ -45,7 +45,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
end
end
@@ -56,11 +56,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: microsoft_channel, interval: 8).perform
@@ -71,7 +71,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
end
end
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index fe6b179c1..430aa1561 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -381,6 +381,32 @@ describe Whatsapp::IncomingMessageService do
expect(location_attachment.coordinates_long).to eq(-122.3895553)
expect(location_attachment.external_url).to eq('http://location_url.test')
end
+
+ it 'truncates long fallback titles to avoid dropping location messages' do
+ long_place_name = [
+ 'Gremi de Fusters, 33, Edificio VIP Asima, Piso 2, Local 2, Norte',
+ '07009 Poligon industrial de Son Castello, Illes Balears, Espana'
+ ].join(', ')
+ source_id = 'wamid.long-location-fallback-title'
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
+ 'messages' => [{ 'from' => '2423423243', 'id' => source_id,
+ 'location' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
+ :address => long_place_name,
+ :latitude => 37.7893768,
+ :longitude => -122.3895553,
+ :name => long_place_name,
+ :url => 'http://location_url.test' },
+ 'timestamp' => '1633034394', 'type' => 'location' }]
+ }.with_indifferent_access
+
+ expect { described_class.new(inbox: whatsapp_channel.inbox, params: params).perform }
+ .to change { Message.where(source_id: source_id).count }.from(0).to(1)
+
+ location_attachment = Message.find_by!(source_id: source_id).attachments.first
+ expect(location_attachment.fallback_title).to eq("#{long_place_name}, #{long_place_name}".first(255))
+ expect(location_attachment.fallback_title.length).to eq(255)
+ end
end
context 'when valid contact message params' do
diff --git a/tailwind.config.js b/tailwind.config.js
index b4c4b9078..cc2aebb63 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -106,24 +106,30 @@ const tailwindConfig = {
textDecoration: 'underline',
},
ul: {
- paddingInlineStart: '0.625em',
+ paddingInlineStart: '0',
+ listStylePosition: 'inside',
},
ol: {
- paddingInlineStart: '0.625em',
+ paddingInlineStart: '0',
+ listStylePosition: 'inside',
},
- 'ul li': {
- margin: '0 0 0.5em 1em',
+ 'ul > li': {
+ marginBlockEnd: '0.5em',
listStyleType: 'disc',
- '[dir="rtl"] &': {
- margin: '0 1em 0.5em 0',
- },
+ paddingInlineStart: '1.5em',
+ textIndent: '-1.5em',
},
- 'ol li': {
- margin: '0 0 0.5em 1em',
+ 'ol > li': {
+ marginBlockEnd: '0.5em',
listStyleType: 'decimal',
- '[dir="rtl"] &': {
- margin: '0 1em 0.5em 0',
- },
+ paddingInlineStart: '1.5em',
+ textIndent: '-1.5em',
+ },
+ 'li > p:first-child': {
+ display: 'inline',
+ },
+ 'li > *': {
+ textIndent: '0',
},
blockquote: {
color: 'rgb(var(--slate-11))',