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 b7fc14e74..fb52a8eeb 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/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 => {
({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
+ 'customize-content': t(
+ 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
+ ),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
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/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
index 63a592375..4603da83b 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -73,6 +73,12 @@ const getValueFromConversation = (conversation, attributeKey) => {
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 eb8fecf01..85ce67bc6 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -63,6 +63,13 @@ const createMarkdownInstance = (linkify = true) => {
});
};
+// Help center article tables persist column widths as an internal
+// `` comment before the table. It exists only for the
+// editor's markdown round-trip and must never surface as text — markdown-it runs
+// with `html: false`, which would otherwise escape it into a visible comment in
+// rendered/plain output (e.g. dashboard search snippets). Strip it on the way in.
+const COLWIDTHS_MARKER_REGEX = /\r?\n?/g;
+
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
@@ -75,7 +82,7 @@ class MessageFormatter {
isAPrivateNote = false,
linkify = true
) {
- this.message = message || '';
+ this.message = (message || '').replace(COLWIDTHS_MARKER_REGEX, '');
this.isAPrivateNote = isAPrivateNote;
this.isATweet = isATweet;
this.linkify = linkify;
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 12b84085c..3350399eb 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -126,6 +126,16 @@ describe('#MessageFormatter', () => {
});
});
+ describe('help center table colwidth marker', () => {
+ it('strips the internal colwidths marker from rendered output', () => {
+ const message =
+ '\n| A | B |\n| --- | --- |\n| 1 | 2 |';
+ const formatter = new MessageFormatter(message);
+ expect(formatter.formattedMessage).not.toContain('cw-colwidths');
+ expect(formatter.plainText).not.toContain('cw-colwidths');
+ });
+ });
+
describe('#sanitize', () => {
it('sanitizes markup and removes all unnecessary elements', () => {
const message =
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/jobs/webhooks/whatsapp_events_job.rb b/app/jobs/webhooks/whatsapp_events_job.rb
index f904b3723..14429e61c 100644
--- a/app/jobs/webhooks/whatsapp_events_job.rb
+++ b/app/jobs/webhooks/whatsapp_events_job.rb
@@ -126,12 +126,17 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
def channel_is_inactive?(channel)
return true if channel.blank?
- return true if channel.reauthorization_required?
+ # Only skip for embedded signup when reauth is required; manual flow uses API keys and should still receive webhooks
+ return true if channel.reauthorization_required? && embedded_signup_channel?(channel)
return true unless channel.account.active?
false
end
+ def embedded_signup_channel?(channel)
+ (channel.provider_config || {}).to_h['source'] == 'embedded_signup'
+ end
+
def find_channel_by_url_param(params)
return unless params[:phone_number]
diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb
index 835d03661..c64b36ca0 100644
--- a/app/listeners/webhook_listener.rb
+++ b/app/listeners/webhook_listener.rb
@@ -68,7 +68,7 @@ class WebhookListener < BaseListener
def inbox_created(event)
inbox, account = extract_inbox_and_account(event)
- inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
payload = inbox_webhook_data.merge(event: __method__.to_s)
deliver_account_webhooks(payload, account)
end
@@ -78,7 +78,7 @@ class WebhookListener < BaseListener
changed_attributes = extract_changed_attributes(event)
return if changed_attributes.blank?
- inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_webhook_data = Inbox::EventDataPresenter.new(inbox).webhook_data
payload = inbox_webhook_data.merge(event: __method__.to_s, changed_attributes: changed_attributes)
deliver_account_webhooks(payload, account)
end
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/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 37665a15c..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
@@ -43,11 +44,16 @@ class Portal < ApplicationRecord
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
- validate :config_json_format
+ 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
{
@@ -91,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
@@ -105,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/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb
index ae0e69608..4dfa10abe 100644
--- a/app/presenters/conversations/event_data_presenter.rb
+++ b/app/presenters/conversations/event_data_presenter.rb
@@ -23,7 +23,10 @@ class Conversations::EventDataPresenter < SimpleDelegator
# Like #push_data but with message text normalized for external integrations (webhooks).
def webhook_data
- push_data.merge(messages: webhook_push_messages)
+ push_data.merge(
+ account: account.webhook_data,
+ messages: webhook_push_messages
+ )
end
private
diff --git a/app/presenters/inbox/event_data_presenter.rb b/app/presenters/inbox/event_data_presenter.rb
index a408424ae..7f832bb5a 100644
--- a/app/presenters/inbox/event_data_presenter.rb
+++ b/app/presenters/inbox/event_data_presenter.rb
@@ -32,4 +32,8 @@ class Inbox::EventDataPresenter < SimpleDelegator
channel: channel
}
end
+
+ def webhook_data
+ push_data.merge(account: account.webhook_data)
+ end
end
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/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/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/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder
index 0ae0745cd..b34bbe95b 100644
--- a/app/views/api/v1/models/_inbox.json.jbuilder
+++ b/app/views/api/v1/models/_inbox.json.jbuilder
@@ -130,7 +130,11 @@ json.bot_name resource.channel.try(:bot_name) if resource.telegram?
if resource.whatsapp?
json.message_templates resource.channel.try(:message_templates)
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
- json.reauthorization_required resource.channel.try(:reauthorization_required?)
+ # Only show reauthorization for embedded signup; manual flow uses API keys, not OAuth
+ json.reauthorization_required(
+ (resource.channel.try(:provider_config) || {}).to_h['source'] == 'embedded_signup' &&
+ resource.channel.try(:reauthorization_required?)
+ )
end
## Voice attributes for TwilioSms
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/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/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/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/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb
index 665d4c80c..fa191f5ed 100644
--- a/lib/custom_markdown_renderer.rb
+++ b/lib/custom_markdown_renderer.rb
@@ -9,9 +9,32 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
@embed_regexes ||= config.transform_values { |embed_config| Regexp.new(embed_config['regex']) }
end
+ # Matches columnResizing({ cellMinWidth: 50 }) in @chatwoot/prosemirror-schema
+ # so cells without an explicit colwidth render the same minimum here as in the editor.
+ TABLE_CELL_MIN_WIDTH_PX = 50
+ COLWIDTHS_COMMENT = //
+
+ # The article editor serializes column widths as a `` HTML
+ # comment immediately before each resized table. Capture it (emitting nothing) so the
+ # next `table` can size itself; any other raw HTML keeps its default rendering.
+ def html(node)
+ match = node.string_content.match(COLWIDTHS_COMMENT)
+ return super unless match
+
+ @pending_colwidths = match[1].split(',').map(&:to_i)
+ end
+
def table(node)
- out('
')
- super
+ widths = @pending_colwidths
+ @pending_colwidths = nil
+
+ if sized_widths?(widths)
+ out(table_wrapper_open(widths))
+ out(inject_table_sizing(capture_html { super(node) }, widths))
+ else
+ out('
')
+ super
+ end
out('
')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
+ def sized_widths?(widths)
+ widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
+ end
+
+ def fully_sized?(widths)
+ widths.all? { |w| w.to_i.positive? }
+ end
+
+ # Fully-sized tables hug their exact width so the card doesn't trail empty space;
+ # partial tables stay a plain full-width card so flexible columns can expand.
+ def table_wrapper_open(widths)
+ return '
' unless fully_sized?(widths)
+
+ %(
)
+ end
+
+ # Let the gem render the whole table, then splice a
and sizing style
+ # into the opening tag. Delegating the row/cell/tbody/alignment markup to
+ # super keeps this working across commonmarker upgrades.
+ # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
+ def inject_table_sizing(html, widths)
+ opening = %(\n#{colgroup_html(widths)})
+ html.sub(/]*>\n?/, opening)
+ end
+
+ # Capture everything `super` writes by swapping the renderer's output buffer.
+ def capture_html
+ original = @stream
+ @stream = StringIO.new(+'')
+ yield
+ @stream.string
+ ensure
+ @stream = original
+ end
+
+ # Total table width: each column's saved width, or the cell min for unsized ones.
+ def total_width(widths)
+ widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
+ end
+
+ # Fully sized → lock to the exact total (min-width too, so a narrow saved width
+ # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
+ # the container (flexible columns) yet scrolls when the sized columns exceed it.
+ def table_sizing_style(widths)
+ total = total_width(widths)
+ return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
+
+ "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
+ end
+
+ def colgroup_html(widths)
+ cols = widths.map { |w| w.to_i.positive? ? %( ) : ' ' }
+ "#{cols.join} \n"
+ end
+
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
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/package.json b/package.json
index d8527051d..41313c8b3 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.17",
+ "@chatwoot/prosemirror-schema": "1.3.19",
"@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 a4b61061c..68e667953 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.17
- version: 1.3.17
+ specifier: 1.3.19
+ version: 1.3.19
'@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.17':
- resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
+ '@chatwoot/prosemirror-schema@1.3.19':
+ resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
'@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.17':
+ '@chatwoot/prosemirror-schema@1.3.19':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 9e03e2587..0fd6ad7bf 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -100,6 +100,35 @@ RSpec.describe 'Inboxes API', type: :request do
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
+ it 'returns reauthorization_required for embedded signup whatsapp channel when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(true)
+ end
+
+ it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_channel.update!(provider_config: whatsapp_channel.provider_config.merge('source' => 'manual'))
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(false)
+ end
+
it 'returns the inbox if assigned inbox is assigned as agent' do
create(:inbox_member, user: agent, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
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/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/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
index d82658102..8d1b24b52 100644
--- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb
+++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
@@ -62,6 +62,14 @@ RSpec.describe Webhooks::WhatsappEventsJob do
job.perform_now(params)
end
+ it 'still enqueues for manual channels even when reauthorization required' do
+ channel.update!(provider_config: channel.provider_config.merge('source' => 'manual'))
+ channel.prompt_reauthorization!
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
+ job.perform_now(params)
+ end
+
it 'will not enqueue if channel is not present' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
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/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 28c5e069c..6484f2e6c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
+ describe '#table' do
+ def render_table(markdown)
+ doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
+ described_class.new.render(doc)
+ end
+
+ let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
+
+ it 'renders a table without column widths when no marker is present' do
+ output = render_table(plain_table)
+ expect(output).to include('')
+ expect(output).not_to include('colgroup')
+ expect(output).not_to include('cw-colwidths')
+ end
+
+ context 'when every column has a saved width' do
+ it 'lays the table out at the total width with a sized colgroup' do
+ output = render_table("\n#{plain_table}")
+ # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
+ expect(output).to include('')
+ expect(output).to include('
')
+ expect(output).to include(' ')
+ end
+ end
+
+ context 'when only some columns have a saved width' do
+ it 'fills the container so unsized columns stay flexible, floored at the sized total' do
+ output = render_table("\n#{plain_table}")
+ # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
+ expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
+ expect(output).to include(' ')
+ # No exact-width lock on the wrapper or table — the table must be free to expand.
+ expect(output).to include('')
+ expect(output).to include('width: 400px !important;')
+ expect(output).to include(' ')
+ expect(output.scan('colgroup').length).to eq(2)
+ end
+
+ it 'does not emit the marker comment into the rendered html' do
+ expect(render_table("\n#{plain_table}")).not_to include('cw-colwidths')
+ end
+ end
+
describe '#image' do
it 'renders width in px with responsive cap and auto height' do
markdown = ''
diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb
index 08deeb6c4..e3f9f0402 100644
--- a/spec/listeners/agent_bot_listener_spec.rb
+++ b/spec/listeners/agent_bot_listener_spec.rb
@@ -82,7 +82,7 @@ describe AgentBotListener do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
agent_bot.outgoing_url,
- hash_including(event: 'conversation_status_changed', changed_attributes: anything),
+ hash_including(event: 'conversation_status_changed', account: account.webhook_data, changed_attributes: anything),
:agent_bot_webhook,
hash_including(secret: agent_bot.secret)
).once
@@ -158,6 +158,24 @@ describe AgentBotListener do
end
end
+ describe '#conversation_resolved' do
+ let(:event_name) { 'conversation.resolved' }
+ let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
+
+ context 'when agent bot is configured' do
+ it 'sends account details in the conversation payload' do
+ create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
+ expect(AgentBots::WebhookJob).to receive(:perform_later).with(
+ agent_bot.outgoing_url,
+ hash_including(event: 'conversation_resolved', account: account.webhook_data),
+ :agent_bot_webhook,
+ hash_including(secret: agent_bot.secret)
+ ).once
+ listener.conversation_resolved(event)
+ end
+ end
+ end
+
describe '#webwidget_triggered' do
let(:event_name) { 'webwidget.triggered' }
diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb
index a7a64f175..b63f43c2f 100644
--- a/spec/listeners/webhook_listener_spec.rb
+++ b/spec/listeners/webhook_listener_spec.rb
@@ -101,6 +101,17 @@ describe WebhookListener do
).once
listener.conversation_created(conversation_created_event)
end
+
+ it 'includes account details in the conversation payload' do
+ webhook = create(:webhook, inbox: inbox, account: account)
+ expect(WebhookJob).to receive(:perform_later).with(
+ webhook.url,
+ hash_including(account: account.webhook_data),
+ :account_webhook,
+ hash_including(secret: webhook.secret)
+ ).once
+ listener.conversation_created(conversation_created_event)
+ end
end
context 'when inbox is an API Channel' do
@@ -250,7 +261,7 @@ describe WebhookListener do
context 'when webhook is configured' do
it 'triggers webhook' do
- inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
expect(WebhookJob).to receive(:perform_later).with(
webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook,
@@ -258,6 +269,17 @@ describe WebhookListener do
).once
listener.inbox_created(inbox_created_event)
end
+
+ it 'includes account details in the inbox payload' do
+ webhook = create(:webhook, account: account, subscriptions: ['inbox_created'])
+ expect(WebhookJob).to receive(:perform_later).with(
+ webhook.url,
+ hash_including(account: account.webhook_data),
+ :account_webhook,
+ hash_including(secret: webhook.secret)
+ ).once
+ listener.inbox_created(inbox_created_event)
+ end
end
end
@@ -287,7 +309,7 @@ describe WebhookListener do
it 'triggers webhook' do
webhook = create(:webhook, account: account, subscriptions: ['inbox_updated'])
- inbox_data = Inbox::EventDataPresenter.new(inbox).push_data
+ inbox_data = Inbox::EventDataPresenter.new(inbox).webhook_data
changed_attributes_data = [{ 'name' => { 'previous_value': 'Inbox 1', 'current_value': inbox.name } }]
expect(WebhookJob).to receive(:perform_later).with(
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/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/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb
index 76fd8f8a8..21cb26c98 100644
--- a/spec/presenters/conversations/event_data_presenter_spec.rb
+++ b/spec/presenters/conversations/event_data_presenter_spec.rb
@@ -46,6 +46,10 @@ RSpec.describe Conversations::EventDataPresenter do
end
describe '#webhook_data' do
+ it 'includes account details for webhook consumers' do
+ expect(presenter.webhook_data[:account]).to eq(conversation.account.webhook_data)
+ end
+
it 'normalizes hard-break backslashes in message content' do
message = create(:message, conversation: conversation, account: conversation.account,
message_type: :outgoing, content: "Hello\\\nWorld")
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