Compare commits

..
Author SHA1 Message Date
Vishnu NarayananandGitHub 6c5077dbad Merge branch 'develop' into fix/widget-conversations-throttle-key 2026-05-27 15:27:33 +05:30
Vishnu Narayanan 4bd84f05be fix: add widget conversation throttle by ip + website_token
Replaces the per-IP 6-per-12h throttle (disabled in cloud as too aggressive
for shared NAT IPs) with a per-(IP, website_token) 30-per-minute burst
throttle. Distinct widgets behind a shared NAT IP get separate buckets so
the original NAT pain is preserved while a single source can no longer
create unbounded conversations against one widget.

Adds ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS as an independent kill switch
so this throttle can be toggled without re-enabling the legacy widget
throttles. RATE_LIMIT_WIDGET_CONVERSATIONS controls the per-minute cap.
2026-05-05 22:59:27 +05:30
167 changed files with 1437 additions and 9297 deletions
+3 -5
View File
@@ -996,13 +996,11 @@ GEM
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.10.0)
railties (>= 5.1, < 9)
vite_rails (3.0.17)
railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2)
vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
+1 -1
View File
@@ -1 +1 @@
4.14.1
4.14.0
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment)
{
fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url')
external_url: attachment['url']
}
end
# Facebook shared posts point to page URLs, not downloadable media URLs.
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
def normalize_file_type(type)
return :fallback if type.to_sym == :share
super
end
def conversation_params
{
account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = attachment_file_type(uploaded_attachment)
tag_voice_message(attachment)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
def attachment_file_type(uploaded_attachment)
if uploaded_attachment.is_a?(String)
file_type_by_signed_id(uploaded_attachment)
else
file_type(uploaded_attachment&.content_type)
end
end
def tag_voice_message(attachment)
return unless @is_voice_message && attachment.file_type == 'audio'
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@@ -1,6 +1,16 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts }
end
private
def ensure_unread_counts_enabled
return if Current.account.feature_enabled?('conversation_unread_counts')
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
end
end
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0',
force_authentication: '1',
response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to])
state: generate_instagram_token(Current.account.id)
}
)
if redirect_url
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end
def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
# can route the user back to inbox setup. The purpose is part of the signed
# payload (tamper-proof), and a non-onboarding request keeps the default
# purpose, leaving callers like Notion byte-identical.
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
end
def state_purpose
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
@@ -1,36 +0,0 @@
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def update
@account = Current.account
finalize = finalizing_account_details?
@account.assign_attributes(account_params)
@account.custom_attributes.merge!(custom_attributes_params)
@account.custom_attributes.delete('onboarding_step') if finalize
@account.save!
# TODO: re-enable when the help center generation UI is ready to surface progress
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
render 'api/v1/accounts/update', format: :json
end
private
def finalizing_account_details?
@account.custom_attributes['onboarding_step'] == 'account_details'
end
def website
custom_attributes_params[:website]
end
def account_params
params.permit(:name, :locale)
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
end
end
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id, params[:return_to])
state: generate_tiktok_token(Current.account.id)
)
if redirect_url
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state])
end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code
params[:code]
end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address
'outlook.office365.com'
end
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
# `upn` is the v1.0 fallback.
def imap_login_identity
users_data['preferred_username'] || users_data['upn'] || super
end
end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email)
channel_email.update!({
imap_login: imap_login_identity, imap_address: imap_address,
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
})
end
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
# id_token's email claim; providers override when their server requires a different
# claim (e.g. Microsoft SMTP requires UPN).
def imap_login_identity
users_data['email']
end
def provider_name
raise NotImplementedError
end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
# The sgid purpose carries the onboarding return hint (see
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
# both resolves the account and records the return target — then fall back to the
# default purpose used by every other caller.
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id
end
def return_to
account # resolving the sgid records which purpose matched
@return_to
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
@@ -1,31 +0,0 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
layout 'portal'
def index
@query = params[:query].to_s.strip
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
search_articles
@articles = @articles.page(params[:page]).per(10)
end
private
def search_articles
@articles = @query.present? ? @articles.search(search_params) : @articles.none
end
def search_params
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
permitted[:query] = @query
end
end
end
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state])
end
def return_to
tiktok_token_return_to(params[:state])
end
def account
@account ||= Account.find(account_id)
end
+9 -16
View File
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_instagram_token(account_id, return_to = nil)
def generate_instagram_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil
end
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
# Verifies and decodes a Instagram JWT token
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Instagram JWT token, if present.
def instagram_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
decode_token(token, client_secret)
end
private
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil
+3 -10
View File
@@ -45,16 +45,9 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def portal_query_string(theme, is_plain_layout_enabled)
query_params = {}
query_params[:theme] = theme if theme.present? && theme != 'system'
query_params[:show_plain_layout] = true if is_plain_layout_enabled
query_params.present? ? "?#{query_params.to_query}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/#{portal_locale}"
end
@@ -68,7 +61,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
@@ -76,7 +69,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
+9 -16
View File
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
# @param return_to [String, nil] Optional onboarding return hint
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id, return_to = nil)
def generate_tiktok_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub')
end
# Reads the onboarding return hint from a Tiktok JWT token, if present.
def tiktok_token_return_to(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('return_to')
decode_token(token, client_secret)
end
private
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
def token_payload(account_id, return_to = nil)
payload = { sub: account_id, iat: Time.current.to_i }
payload[:return_to] = return_to if return_to.present?
payload
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) => {
let payload;
if (files && files.length !== 0) {
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes));
}
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else {
payload = {
content: message,
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
bccEmails = '',
toEmails = '',
templateParams,
isVoiceMessage = false,
}) {
return axios({
method: 'post',
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
bccEmails,
toEmails,
templateParams,
isVoiceMessage,
}),
});
}
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class OnboardingAPI extends ApiClient {
constructor() {
super('onboarding', { accountScoped: true });
}
update(data) {
return axios.patch(this.url, data);
}
}
export default new OnboardingAPI();
@@ -83,29 +83,5 @@ describe('#ConversationAPI', () => {
template_params: undefined,
});
});
it('appends is_voice_message when isVoiceMessage is true', () => {
const formPayload = buildCreatePayload({
message: 'voice message',
echoId: 42,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: true,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toEqual('true');
});
it('does not append is_voice_message when isVoiceMessage is false', () => {
const formPayload = buildCreatePayload({
message: 'regular audio',
echoId: 43,
isPrivate: false,
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
isVoiceMessage: false,
});
expect(formPayload).toBeInstanceOf(FormData);
expect(formPayload.get('is_voice_message')).toBeNull();
});
});
});
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter();
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
if (props.status === STATUS_COMPLETED) {
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
}
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
@@ -1,48 +1,34 @@
<script setup>
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = computed(() => [
const contactMenuItems = [
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add',
value: 'add',
icon: 'i-lucide-plus',
},
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
]
: []),
...(checkPermissions(['administrator', 'contact_manage'])
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
action: 'export',
value: 'export',
icon: 'i-lucide-upload',
},
{
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
];
const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => {
@@ -35,14 +35,7 @@ const props = defineProps({
},
});
defineEmits([
'accept',
'reject',
'end',
'toggleMute',
'goToConversation',
'dismiss',
]);
defineEmits(['accept', 'reject', 'end', 'toggleMute', 'goToConversation']);
const { t } = useI18n();
@@ -122,19 +115,6 @@ const channelIcon = computed(() => {
<span class="text-xs font-medium text-n-teal-9 tracking-tight">
{{ statusLabel }}
</span>
<!-- Dismiss: removes the notification from the UI without declining.
Incoming only outgoing/ongoing calls are ended via the call
controls, not silently dismissed. -->
<NextButton
v-if="isIncoming"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.DISMISS_CALL')"
icon="i-ph-x-bold"
slate
ghost
xs
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div>
</div>
@@ -25,7 +25,6 @@ const {
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
@@ -52,15 +51,6 @@ const mainCardState = computed(() => {
: VOICE_CALL_DIRECTION.INCOMING;
});
// Stacked cards are always non-active (ringing) calls, so reflect each call's
// real direction. An outbound call must render as OUTGOING — otherwise it shows
// the incoming-only dismiss (✕) control and the agent could drop it locally
// without terminating, leaving the customer ringing with no widget to end it.
const stackedCardState = call =>
call?.callDirection === VOICE_CALL_DIRECTION.OUTBOUND
? VOICE_CALL_DIRECTION.OUTGOING
: VOICE_CALL_DIRECTION.INCOMING;
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
@@ -240,11 +230,10 @@ onBeforeUnmount(stopRingtone);
v-for="call in stackedIncomingCalls"
:key="call.callSid"
:call="call"
:state="stackedCardState(call)"
:state="VOICE_CALL_DIRECTION.INCOMING"
:call-info="getCallInfo(call)"
@accept="handleJoinCall(call)"
@reject="rejectIncomingCall(call.callSid)"
@dismiss="dismissCall(call.callSid)"
@go-to-conversation="goToConversation(call)"
/>
@@ -259,7 +248,6 @@ onBeforeUnmount(stopRingtone);
:show-mute="hasActiveCall && isWhatsappActive"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
@end="handleEndCall"
@toggle-mute="toggleMute"
@go-to-conversation="goToConversation(activeCall || primaryIncomingCall)"
@@ -25,7 +25,7 @@ const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available';
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
@@ -127,6 +127,7 @@ const menuItems = computed(() => {
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -557,10 +554,11 @@ provideMessageContext({
<Avatar v-bind="avatarInfo" :size="24" />
</div>
<div
class="[grid-area:bubble] flex min-w-0"
class="[grid-area:bubble] flex"
:class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}"
@contextmenu="openContextMenu($event)"
>
@@ -95,7 +95,7 @@ const replyToPreview = computed(() => {
<template>
<div
class="text-sm min-w-0"
class="text-sm"
:class="[
messageClass,
{
@@ -1,37 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useMessageContext } from '../provider.js';
const { attachments, content } = useMessageContext();
const attachment = computed(() => attachments.value?.[0] || {});
const url = computed(
() => attachment.value.dataUrl || attachment.value.data_url
);
const title = computed(
() =>
attachment.value.fallbackTitle ||
attachment.value.fallback_title ||
url.value
);
</script>
<template>
<BaseBubble class="p-3" data-bubble-name="fallback">
<FormattedContent v-if="content" :content="content" class="mb-2" />
<a
v-if="url"
:href="url"
target="_blank"
rel="noopener noreferrer"
class="block max-w-[320px] truncate text-sm text-n-brand underline"
>
{{ title }}
</a>
<span v-else class="text-sm text-n-slate-11">
{{ title }}
</span>
</BaseBubble>
</template>
@@ -91,6 +91,10 @@ const wasDeclinedByAgent = computed(
endReason.value === VOICE_CALL_END_REASON.AGENT_REJECTED
);
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
@@ -120,48 +124,43 @@ const durationSeconds = computed(() => {
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
// Agent who handled the call (initiator on outbound, answerer on inbound), taken
// strictly from the persisted accept fields — never the conversation's current
// assignee, which would mis-attribute a historical call after a reassignment.
const handlerName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (!acceptedByAgentId.value) return null;
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
return agent?.available_name || agent?.name || null;
});
const handledBy = computed(() =>
handlerName.value
? t('CONVERSATION.VOICE_CALL.HANDLED_BY', { agentName: handlerName.value })
: null
);
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
if (isFailed.value) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
// RINGING or an as-yet-unknown/initial status: orient purely by direction so an
// outbound call never falls through to the "Incoming call" label.
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const subtext = computed(() => {
// Completed: "Handled by {agent} · 0:42" (drops either part when absent).
if (status.value === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.CALLING')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return [handledBy.value, formattedDuration.value]
.filter(Boolean)
.join(' · ');
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
return handledBy.value;
if (isOutbound.value) return null;
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return null;
}
if (isFailed.value) {
// Missed/failed calls have no handler, so keep the reason rather than "Handled by".
if (isOutbound.value) {
return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT');
}
@@ -172,10 +171,6 @@ const subtext = computed(() => {
}
return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
}
// RINGING or an as-yet-unknown/initial status.
if (isOutbound.value) {
return handledBy.value || t('CONVERSATION.VOICE_CALL.CALLING');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
@@ -31,17 +31,6 @@ const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl);
});
const TRANSCRIPT_PREVIEW_LENGTH = 200;
const isTranscriptExpanded = ref(false);
const isTranscriptLong = computed(
() => (attachment.transcribedText?.length || 0) > TRANSCRIPT_PREVIEW_LENGTH
);
const displayedTranscript = computed(() => {
const text = attachment.transcribedText || '';
if (!isTranscriptLong.value || isTranscriptExpanded.value) return text;
return `${text.slice(0, TRANSCRIPT_PREVIEW_LENGTH).trimEnd()}`;
});
const audioPlayer = useTemplateRef('audioPlayer');
const isPlaying = ref(false);
@@ -226,18 +215,7 @@ const downloadAudio = async () => {
v-if="attachment.transcribedText && showTranscribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ displayedTranscript }}
<button
v-if="isTranscriptLong"
class="block mt-1 p-0 border-0 bg-transparent text-n-slate-11 hover:text-n-slate-12 font-medium"
@click="isTranscriptExpanded = !isTranscriptExpanded"
>
{{
isTranscriptExpanded
? $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_LESS')
: $t('CONVERSATION.VOICE_CALL.TRANSCRIPT_SHOW_MORE')
}}
</button>
{{ attachment.transcribedText }}
</div>
</div>
</template>
@@ -61,9 +61,21 @@ const hasAdvancedAssignment = computed(() => {
);
});
const fetchConversationUnreadCounts = currentAccountId => {
const hasConversationUnreadCounts = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
});
const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
if (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get');
};
@@ -188,7 +200,7 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact');
});
watch(accountId, fetchConversationUnreadCounts, {
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
@@ -14,11 +14,11 @@ const props = defineProps({
const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isVoiceMessage);
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
});
const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isVoiceMessage)
props.attachments.filter(attachment => attachment.isRecordedAudio)
);
const onRemoveAttachment = itemIndex => {
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/audioConversionUtils';
import { convertAudio } from './utils/mp3ConversionUtils';
const props = defineProps({
audioRecordFormat: {
@@ -18,7 +18,6 @@ const emit = defineEmits([
'finishRecord',
'pause',
'play',
'recordError',
]);
const waveformContainer = ref(null);
@@ -27,7 +26,6 @@ const record = ref(null);
const isRecording = ref(false);
const isPlaying = ref(false);
const hasRecording = ref(false);
const recordedAudioUrl = ref(null);
const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: time });
@@ -37,28 +35,6 @@ const formatTimeProgress = time => {
);
};
const AUDIO_EXTENSION_MAP = {
'audio/ogg': 'ogg',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/webm': 'webm',
};
const getRecordPluginOptions = audioFormat => {
const options = {
scrollingWaveform: true,
renderRecordedAudio: false,
};
if (
audioFormat === 'audio/ogg' &&
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
) {
options.mimeType = 'audio/ogg;codecs=opus';
}
return options;
};
const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value,
@@ -69,7 +45,10 @@ const initWaveSurfer = () => {
barGap: 1,
barRadius: 2,
plugins: [
RecordPlugin.create(getRecordPluginOptions(props.audioRecordFormat)),
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
}),
],
});
@@ -83,34 +62,21 @@ const initWaveSurfer = () => {
});
record.value.on('record-end', async blob => {
try {
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
// Use the converted blob's actual type, which may differ from the
// requested format when the browser can't produce it (e.g. Safari falls
// back to MP3 instead of OGG). This keeps the filename, content type, and
// voice-note flag consistent with the real bytes.
const audioType = audioBlob.type || props.audioRecordFormat;
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3';
const fileName = `${getUuid()}.${ext}`;
const file = new File([audioBlob], fileName, {
type: audioType,
});
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = URL.createObjectURL(audioBlob);
wavesurfer.value.load(recordedAudioUrl.value);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
} catch (error) {
isRecording.value = false;
hasRecording.value = false;
emit('recordError', { error });
}
const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
const fileName = `${getUuid()}.mp3`;
const file = new File([audioBlob], fileName, {
type: props.audioRecordFormat,
});
wavesurfer.value.load(audioUrl);
emit('finishRecord', {
name: file.name,
type: file.type,
size: file.size,
file,
});
hasRecording.value = true;
isRecording.value = false;
});
record.value.on('record-progress', time => {
@@ -143,10 +109,6 @@ onMounted(() => {
});
onUnmounted(() => {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = null;
}
if (wavesurfer.value) {
wavesurfer.value.destroy();
}
@@ -1,7 +1,5 @@
import lamejs from '@breezystack/lamejs';
import { remuxWebmToOgg } from './webmOpusToOgg';
const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) {
@@ -141,20 +139,6 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate);
} else if (outputFormat === 'audio/ogg') {
const inputType = inputBlob.type.split(';')[0].trim();
if (inputType === 'audio/webm' || inputType === 'video/webm') {
audio = await remuxWebmToOgg(inputBlob);
} else if (inputType === 'audio/ogg') {
audio = inputBlob;
} else {
// Browsers that record neither WebM nor OGG (e.g. Safari records
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
// still sends as a regular audio message instead of failing. The caller
// keys the voice-note flag off the returned blob type, so an MP3 result
// is never mislabeled as an OGG/Opus voice note.
audio = await convertToMp3(inputBlob, bitrate);
}
} else {
throw new Error('Unsupported output format');
}
@@ -1,457 +0,0 @@
/* eslint-disable no-bitwise */
/**
* WebM/Opus → OGG/Opus remuxer
*
* Chrome's MediaRecorder produces WebM containers even when
* `audio/ogg;codecs=opus` is requested. WhatsApp Cloud API requires
* proper OGG/Opus files for voice messages.
*
* This module extracts raw Opus packets from the WebM (EBML) container
* and repackages them into a valid OGG bitstream. The audio data itself
* is never re-encoded — only the container format changes.
*
* References:
* EBML (container for WebM): RFC 8794 — https://www.rfc-editor.org/rfc/rfc8794
* Matroska/WebM elements: https://www.matroska.org/technical/elements.html
* OGG bitstream framing: RFC 3533 — https://www.rfc-editor.org/rfc/rfc3533
* Opus codec: RFC 6716 — https://www.rfc-editor.org/rfc/rfc6716
* Opus in OGG (OpusHead/Tags): RFC 7845 — https://www.rfc-editor.org/rfc/rfc7845
*/
// ======================== EBML / WebM parser ========================
const EBML_IDS = {
Segment: 0x18538067,
SegmentInfo: 0x1549a966,
Tracks: 0x1654ae6b,
TrackEntry: 0xae,
CodecPrivate: 0x63a2,
Audio: 0xe1,
SamplingFrequency: 0xb5,
Channels: 0x9f,
Cluster: 0x1f43b675,
Timecode: 0xe7,
SimpleBlock: 0xa3,
BlockGroup: 0xa0,
Block: 0xa1,
};
const MASTER_ELEMENTS = new Set([
0x1a45dfa3, // EBML header
EBML_IDS.Segment,
EBML_IDS.SegmentInfo,
EBML_IDS.Tracks,
EBML_IDS.TrackEntry,
EBML_IDS.Audio,
EBML_IDS.Cluster,
EBML_IDS.BlockGroup,
]);
/** Read an EBML variable-length integer (data size). */
function readVint(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 8 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 8 || pos + len > data.length) return null;
let value = first & (mask - 1);
for (let i = 1; i < len; i += 1) {
value = value * 256 + data[pos + i];
}
return { value, length: len };
}
/** Read an EBML element ID (leading marker bits are kept). */
function readElementId(data, pos) {
if (pos >= data.length) return null;
const first = data[pos];
if (first === 0) return null;
let len = 1;
let mask = 0x80;
while (len <= 4 && !(first & mask)) {
len += 1;
mask >>= 1;
}
if (len > 4 || pos + len > data.length) return null;
let id = first;
for (let i = 1; i < len; i += 1) {
id = id * 256 + data[pos + i];
}
return { id, length: len };
}
function readUintBE(data, offset, length) {
let v = 0;
for (let i = 0; i < length; i += 1) v = v * 256 + data[offset + i];
return v;
}
function readFloatBE(data, offset, length) {
if (length !== 4 && length !== 8) return NaN;
const buf = new ArrayBuffer(length);
const u8 = new Uint8Array(buf);
for (let i = 0; i < length; i += 1) u8[i] = data[offset + i];
const view = new DataView(buf);
return length === 4 ? view.getFloat32(0) : view.getFloat64(0);
}
/** Extract the raw Opus frame from a SimpleBlock / Block element. */
function extractFrameFromBlock(data, offset, end) {
const trackVint = readVint(data, offset);
if (!trackVint) return null;
let pos = offset + trackVint.length;
// int16 relative timecode (big-endian, signed) skip
pos += 2;
// Flags byte skip. Lacing (Xiph/EBML/fixed-size) is NOT supported;
// this assumes single-frame blocks as produced by MediaRecorder.
const flags = data[pos];
const lacingBits = (flags >> 1) & 0x03;
if (lacingBits !== 0) {
// eslint-disable-next-line no-console
console.warn(
'webmOpusToOgg: laced SimpleBlock detected (unsupported), frame may be invalid'
);
}
pos += 1;
if (pos >= end) return null;
return data.slice(pos, end);
}
/**
* Walk the EBML tree and collect metadata + Opus frames.
* We only descend into master elements and only extract the fields we need.
*/
function parseWebM(buffer) {
const data = new Uint8Array(buffer);
const result = {
channels: 1,
sampleRate: 48000,
codecPrivate: null,
frames: [],
};
function walk(start, end) {
let pos = start;
while (pos < end) {
const idRes = readElementId(data, pos);
if (!idRes) break;
pos += idRes.length;
const sizeRes = readVint(data, pos);
if (!sizeRes) break;
pos += sizeRes.length;
// Handle "unknown size" (all-ones VINT) by treating it as the rest of the parent
// Use Math.pow instead of bit-shift to avoid 32-bit overflow for 5+ byte VINTs
const maxVint = 2 ** (7 * sizeRes.length) - 1;
const elEnd =
sizeRes.value === maxVint ? end : Math.min(pos + sizeRes.value, end);
if (MASTER_ELEMENTS.has(idRes.id)) {
walk(pos, elEnd);
} else {
switch (idRes.id) {
case EBML_IDS.Channels:
result.channels = readUintBE(data, pos, sizeRes.value);
break;
case EBML_IDS.SamplingFrequency:
result.sampleRate = readFloatBE(data, pos, sizeRes.value);
break;
case EBML_IDS.CodecPrivate:
result.codecPrivate = data.slice(pos, elEnd);
break;
case EBML_IDS.SimpleBlock:
case EBML_IDS.Block: {
const frame = extractFrameFromBlock(data, pos, elEnd);
if (frame && frame.length > 0) result.frames.push(frame);
break;
}
default:
break;
}
}
pos = elEnd;
}
}
walk(0, data.length);
return result;
}
// ======================== OGG writer ========================
/** OGG CRC-32 table (polynomial 0x04C11DB7). */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i << 24;
for (let j = 0; j < 8; j += 1) {
c = ((c << 1) ^ (c & 0x80000000 ? 0x04c11db7 : 0)) >>> 0;
}
t[i] = c;
}
return t;
})();
function oggCrc32(bytes) {
let crc = 0;
for (let i = 0; i < bytes.length; i += 1) {
crc = (CRC_TABLE[((crc >>> 24) ^ bytes[i]) & 0xff] ^ (crc << 8)) >>> 0;
}
return crc;
}
/**
* Build one OGG page.
*
* @param {number} headerType 0x02 = BOS, 0x04 = EOS, 0x00 = normal
* @param {number} granulePosition 48 kHz sample count
* @param {number} serialNumber logical stream id
* @param {number} pageSeq page sequence counter
* @param {Uint8Array[]} packets one or more complete Opus packets
*/
function createOggPage(
headerType,
granulePosition,
serialNumber,
pageSeq,
packets
) {
// Build the lacing / segment table
const segTable = [];
let dataLen = 0;
packets.forEach(pkt => {
let rem = pkt.length;
while (rem >= 255) {
segTable.push(255);
rem -= 255;
}
segTable.push(rem); // final segment (0 when pkt.length is a multiple of 255)
dataLen += pkt.length;
});
const hdrLen = 27 + segTable.length;
const page = new Uint8Array(hdrLen + dataLen);
const dv = new DataView(page.buffer);
// Capture pattern
page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS"
page[4] = 0; // version
page[5] = headerType;
// Granule position (int64 LE)
dv.setUint32(6, granulePosition & 0xffffffff, true);
dv.setUint32(
10,
Math.floor(granulePosition / 0x100000000) & 0xffffffff,
true
);
dv.setUint32(14, serialNumber, true); // serial
dv.setUint32(18, pageSeq, true); // page sequence
dv.setUint32(22, 0, true); // CRC placeholder
page[26] = segTable.length;
for (let i = 0; i < segTable.length; i += 1) page[27 + i] = segTable[i];
let off = hdrLen;
packets.forEach(pkt => {
page.set(pkt, off);
off += pkt.length;
});
// Fill in the CRC
dv.setUint32(22, oggCrc32(page), true);
return page;
}
// ======================== Opus helpers ========================
/** Lookup table: frame duration in ms for each Opus TOC config index (0-31). */
const OPUS_FRAME_MS = [
10,
20,
40,
60, // 0-3 SILK NB
10,
20,
40,
60, // 4-7 SILK MB
10,
20,
40,
60, // 8-11 SILK WB
10,
20, // 12-13 Hybrid SWB
10,
20, // 14-15 Hybrid FB
2.5,
5,
10,
20, // 16-19 CELT NB
2.5,
5,
10,
20, // 20-23 CELT WB
2.5,
5,
10,
20, // 24-27 CELT SWB
2.5,
5,
10,
20, // 28-31 CELT FB
];
/** Return the total number of 48 kHz PCM samples represented by an Opus packet. */
function opusPacketSamples(pkt) {
if (!pkt || pkt.length === 0) return 960; // default 20 ms
const toc = pkt[0];
const config = (toc >> 3) & 0x1f;
const code = toc & 0x03;
const samplesPerFrame = ((OPUS_FRAME_MS[config] || 20) * 48000) / 1000;
let frameCount;
if (code <= 1) frameCount = code + 1;
else if (code === 2) frameCount = 2;
else frameCount = pkt.length >= 2 ? pkt[1] & 0x3f : 1;
return samplesPerFrame * frameCount;
}
function buildOpusHead(channels, sampleRate, preSkip) {
const buf = new Uint8Array(19);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusHead'));
buf[8] = 1; // version
buf[9] = channels;
dv.setUint16(10, preSkip, true);
dv.setUint32(12, sampleRate, true);
dv.setInt16(16, 0, true); // output gain
buf[18] = 0; // channel mapping family
return buf;
}
function buildOpusTags() {
const vendor = new TextEncoder().encode('chatwoot');
const buf = new Uint8Array(8 + 4 + vendor.length + 4);
const dv = new DataView(buf.buffer);
buf.set(new TextEncoder().encode('OpusTags'));
dv.setUint32(8, vendor.length, true);
buf.set(vendor, 12);
dv.setUint32(12 + vendor.length, 0, true); // 0 user comments
return buf;
}
// ======================== Public API ========================
const MAX_FRAMES_PER_PAGE = 50; // ~1 s at 20 ms/frame
const MAX_SEGMENTS_PER_PAGE = 255;
/**
* Remux a WebM/Opus blob into an OGG/Opus blob.
* If the input is already OGG (starts with "OggS"), it is returned as-is.
*
* @param {Blob} webmBlob
* @returns {Promise<Blob>} OGG/Opus blob
*/
export async function remuxWebmToOgg(webmBlob) {
const buffer = await webmBlob.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Already OGG? Return unchanged.
if (
bytes.length >= 4 &&
bytes[0] === 0x4f &&
bytes[1] === 0x67 &&
bytes[2] === 0x67 &&
bytes[3] === 0x53
) {
return webmBlob;
}
const { channels, sampleRate, codecPrivate, frames } = parseWebM(buffer);
if (frames.length === 0) {
throw new Error('No Opus frames found in WebM input');
}
// Extract pre-skip from the WebM CodecPrivate (which IS the OpusHead)
let preSkip = 312;
if (codecPrivate && codecPrivate.length >= 12) {
const magic = new TextDecoder().decode(codecPrivate.slice(0, 8));
if (magic === 'OpusHead') {
preSkip = new DataView(
codecPrivate.buffer,
codecPrivate.byteOffset,
codecPrivate.length
).getUint16(10, true);
}
}
const serial = (Math.random() * 0x100000000) >>> 0;
let pageSeq = 0;
const pages = [];
// Page 0 OpusHead (BOS)
pages.push(
createOggPage(0x02, 0, serial, pageSeq, [
buildOpusHead(channels, sampleRate, preSkip),
])
);
pageSeq += 1;
// Page 1 OpusTags
pages.push(createOggPage(0x00, 0, serial, pageSeq, [buildOpusTags()]));
pageSeq += 1;
// Audio pages
let granule = 0;
let idx = 0;
while (idx < frames.length) {
const packets = [];
let segs = 0;
while (idx < frames.length && packets.length < MAX_FRAMES_PER_PAGE) {
const pkt = frames[idx];
// createOggPage always appends a terminating lacing value, so a packet
// spans floor(len/255)+1 segments (including the extra 0 when len is an
// exact multiple of 255). Math.ceil would undercount those cases.
const pktSegs = Math.floor(pkt.length / 255) + 1;
if (segs + pktSegs > MAX_SEGMENTS_PER_PAGE && packets.length > 0) break;
packets.push(pkt);
segs += pktSegs;
granule += opusPacketSamples(pkt);
idx += 1;
}
const isLast = idx >= frames.length;
pages.push(
createOggPage(isLast ? 0x04 : 0x00, granule, serial, pageSeq, packets)
);
pageSeq += 1;
}
// Concatenate pages into a single buffer
const total = pages.reduce((s, p) => s + p.length, 0);
const out = new Uint8Array(total);
let off = 0;
pages.forEach(p => {
out.set(p, off);
off += p.length;
});
return new Blob([out], { type: 'audio/ogg' });
}
@@ -375,10 +375,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
@@ -1011,18 +1008,14 @@ export default {
onFinishRecorder(file) {
this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true;
// Added a new key isVoiceMessage to the file to identify recorded audio
// Added a new key isRecordedAudio to the file to find it's and recorded audio
// Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = {
...file,
isVoiceMessage: true,
isRecordedAudio: true,
};
return file && this.onFileUpload(autoRecordedFile);
},
onRecordError() {
this.toggleAudioRecorder();
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
},
toggleTyping(status) {
const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate;
@@ -1049,7 +1042,7 @@ export default {
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
isVoiceMessage: file?.isVoiceMessage || false,
isRecordedAudio: file?.isRecordedAudio || false,
});
};
},
@@ -1085,7 +1078,6 @@ export default {
private: false,
message: caption,
sender: this.sender,
isVoiceMessage: attachment.isVoiceMessage || false,
};
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
@@ -1135,9 +1127,6 @@ export default {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId);
if (attachment.isVoiceMessage) {
messagePayload.isVoiceMessage = true;
}
} else {
messagePayload.files.push(attachment.resource.file);
}
@@ -1226,7 +1215,7 @@ export default {
this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter(
file => !file?.isVoiceMessage
file => !file?.isRecordedAudio
);
},
toggleEditorSize() {
@@ -1304,7 +1293,6 @@ export default {
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@record-error="onRecordError"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
@@ -10,12 +10,10 @@ vi.mock('shared/helpers/mitt', () => ({
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
return {
...actual,
default: {
track: vi.fn(),
},
actual.default = {
track: vi.fn(),
};
return actual;
});
describe('useTrack', () => {
@@ -16,12 +16,10 @@ vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
return {
...actual,
default: {
track: vi.fn(),
},
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: {
@@ -52,10 +52,6 @@ export function useAccount() {
});
};
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return {
accountId,
route,
@@ -65,6 +61,5 @@ export function useAccount() {
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
finishOnboarding,
};
}
+1
View File
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
};
export const PREMIUM_FEATURES = [
@@ -13,6 +13,7 @@ import {
} from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -171,10 +172,23 @@ class ActionCableConnector extends BaseActionCableConnector {
};
fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get');
};
isConversationUnreadCountsEnabled = () => {
const accountId = this.app.$store.getters.getCurrentAccountId;
const isFeatureEnabled =
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
return isFeatureEnabled?.(
accountId,
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
);
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
@@ -30,6 +30,7 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
},
},
};
@@ -88,6 +89,21 @@ describe('ActionCableConnector - Copilot Tests', () => {
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
});
it('does not refetch unread counts when unread count feature is disabled', () => {
store.$store.getters[
'accounts/isFeatureEnabledonAccount'
].mockReturnValue(false);
actionCable.onReceived({
event: 'conversation.unread_count_changed',
data: { account_id: 1 },
});
expect(mockDispatch).not.toHaveBeenCalledWith(
'conversationUnreadCounts/get'
);
});
it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
-5
View File
@@ -1,5 +1,4 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
@@ -59,10 +58,6 @@ function extractCallerSnapshot(message) {
// Snapshot caller info from the message at add-time so the widget can keep
// rendering it after the user navigates away from a conversation list that
// had the conversation hydrated (and Vuex evicts it from the store).
// Only incoming messages carry the contact as the sender; on outbound calls
// the sender is the initiating agent, so skip the snapshot and let the widget
// fall back to the conversation's contact (conversation.meta.sender).
if (message?.message_type !== MESSAGE_TYPE.INCOMING) return null;
const sender = message?.sender;
if (!sender) return null;
return {
@@ -88,7 +88,6 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -147,7 +146,6 @@
},
"CARD": {
"STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
@@ -86,16 +86,13 @@
"MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up",
"MISSED_CALL_DECLINED_BY": "Declined by {agentName}",
"CALL_ENDED": "Call ended",
"HANDLED_BY": "Handled by {agentName}",
"NOT_ANSWERED_YET": "Not answered yet",
"CALLING": "Calling…",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call",
"CALL_BACK": "Call back",
"TRANSCRIPT_SHOW_MORE": "Show more",
"TRANSCRIPT_SHOW_LESS": "Show less"
"CALL_BACK": "Call back"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
@@ -231,7 +228,6 @@
"TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -316,7 +312,6 @@
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"DISMISS_CALL": "Dismiss",
"JOIN_CALL": "Join call",
"END_CALL": "End call",
"MUTE": "Mute mic",
@@ -28,7 +28,7 @@ import {
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount();
const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser');
@@ -195,12 +195,6 @@ const handleWebsiteEnter = () => {
websiteInput.value?.blur();
};
const normalizeWebsiteUrl = raw => {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
};
const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear
@@ -217,27 +211,9 @@ const handleSubmit = async () => {
return;
}
// Detect which enrichable fields the user actually edited *before*
// normalizing — otherwise an untouched auto-filled domain
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
// and gets falsely reported as changed, skewing onboarding telemetry.
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
const fieldsChanged = Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key);
// Persist with a scheme so downstream consumers (Firecrawl, portal
// homepage_link) get a fully-qualified URL regardless of what the user typed.
website.value = normalizeWebsiteUrl(website.value);
isSubmitting.value = true;
try {
await finishOnboarding({
await updateAccount({
name: accountName.value,
locale: locale.value,
website: website.value,
@@ -248,11 +224,20 @@ const handleSubmit = async () => {
user_role: userRole.value,
});
const init = initialValues.value;
const enrichableFields = {
website: website.value,
company_size: companySize.value,
industry: industry.value,
};
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info
),
fields_changed: fieldsChanged,
fields_changed: Object.entries(enrichableFields)
.filter(([key, val]) => val !== init[key])
.map(([key]) => key),
user_role: userRole.value,
company_size: companySize.value,
industry: industry.value,
@@ -253,6 +253,7 @@ export default {
if (
this.isAWhatsAppCloudChannel &&
this.isEmbeddedSignupWhatsApp &&
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHANNEL_VOICE
@@ -1,7 +1,6 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AccountAPI from '../../api/account';
import OnboardingAPI from '../../api/onboarding';
import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
@@ -84,15 +83,6 @@ export const actions = {
throw new Error(error);
}
},
finishOnboarding: async ({ commit }, payload) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
const response = await OnboardingAPI.update(payload);
commit(types.default.EDIT_ACCOUNT, response.data);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
@@ -7,7 +7,7 @@ const state = {
records: [],
meta: {
currentPage: 1,
perPage: 25,
perPage: 15,
totalEntries: 0,
},
uiFlags: {
@@ -48,6 +48,9 @@ export const actions = {
// Ignore errors so the sidebar can continue rendering without badges.
}
},
clear({ commit }) {
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
},
};
export const mutations = {
@@ -39,4 +39,15 @@ describe('#actions', () => {
expect(commit).not.toHaveBeenCalled();
});
});
describe('#clear', () => {
it('clears unread counts', () => {
actions.clear({ commit });
expect(commit).toHaveBeenCalledWith(
types.SET_CONVERSATION_UNREAD_COUNTS,
{}
);
});
});
});
+1 -3
View File
@@ -54,9 +54,7 @@ export const useCallsStore = defineStore('calls', {
return;
}
// Prepend so the newest call surfaces as the primary card (incomingCalls[0])
// and older calls drop into the stack below it.
this.calls.unshift({
this.calls.push({
...callData,
isActive: false,
});
+3 -3
View File
@@ -1,9 +1,9 @@
import Rails from '@rails/ujs';
import { Turbo } from '@hotwired/turbo-rails';
import Turbolinks from 'turbolinks';
import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start();
Turbo.start();
Turbolinks.start();
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
+1 -1
View File
@@ -55,6 +55,6 @@ body {
}
}
.turbo-progress-bar {
.turbolinks-progress-bar {
background-color: var(--dynamic-portal-color);
}
@@ -138,40 +138,21 @@ export default {
this.isLoading = false;
}
},
handleSubmit() {
const query = this.normalizedSearchTerm;
if (!query) return;
const searchParams = new URLSearchParams({ query });
const { theme, isPlainLayoutEnabled } = window.portalConfig;
if (theme) searchParams.set('theme', theme);
if (isPlainLayoutEnabled === 'true') {
searchParams.set('show_plain_layout', 'true');
}
window.location.href = `/hc/${this.portalSlug}/${this.localeCode}/search?${searchParams.toString()}`;
},
},
};
</script>
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<form @submit.prevent="handleSubmit">
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<button type="submit" class="sr-only">
{{ searchTranslations.submit }}
</button>
</form>
<PublicSearchInput
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<div
v-if="shouldShowSearchBox"
class="absolute w-full top-14"
@@ -112,8 +112,8 @@ export default {
<p class="py-1 px-3" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbo="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
data-turbolinks="false"
class="font-medium text-sm cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
+3 -2
View File
@@ -24,9 +24,10 @@ export const getHeadingsfromTheArticle = () => {
permalink.className = 'permalink text-slate-600 ml-3';
permalink.href = `#${slug}`;
permalink.title = headingText;
permalink.dataset.turbo = 'false';
permalink.dataset.turbolinks = 'false';
permalink.textContent = '#';
element.appendChild(permalink);
rows.push({
slug,
title: headingText,
@@ -187,7 +188,7 @@ export const InitializationHelpers = {
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbo'] = false;
a['data-turbolinks'] = false;
a.click();
}
},
@@ -23,5 +23,3 @@ class Internal::TriggerDailyScheduledItemsJob < ApplicationJob
@designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440
end
end
Internal::TriggerDailyScheduledItemsJob.prepend_mod_with('Internal::TriggerDailyScheduledItemsJob')
+1 -1
View File
@@ -92,7 +92,7 @@ class ActionCableListener < BaseListener
def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
return if account.blank?
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
tokens = user_tokens(account, inbox_members)
+1
View File
@@ -109,6 +109,7 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
after_destroy :remove_account_sequences
def agents
-4
View File
@@ -55,14 +55,10 @@ class Article < ApplicationRecord
before_validation :ensure_article_slug
before_validation :ensure_locale_in_article
# Slugs that collide with help center routes (e.g. /hc/:slug/:locale/search)
RESERVED_SLUGS = %w[search articles categories].freeze
validates :account_id, presence: true
validates :author_id, presence: true
validates :title, presence: true
validates :content, presence: true, if: :published?
validates :slug, exclusion: { in: RESERVED_SLUGS }
# ensuring that the position is always set correctly
before_create :add_position_to_article
+3 -17
View File
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
enum campaign_type: { ongoing: 0, one_off: 1 }
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
enum campaign_status: { active: 0, completed: 1, processing: 2 }
enum campaign_status: { active: 0, completed: 1 }
has_many :conversations, dependent: :nullify, autosave: true
@@ -56,27 +56,13 @@ class Campaign < ApplicationRecord
def trigger!
return unless one_off?
return unless feature_enabled?
return unless mark_processing!
return if completed?
execute_campaign
end
private
def feature_enabled?
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
end
def mark_processing!
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
with_lock do
next if completed? || processing?
processing!
end
end
def execute_campaign
case inbox.inbox_type
when 'Twilio SMS'
@@ -84,7 +70,7 @@ class Campaign < ApplicationRecord
when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
end
end
+8 -8
View File
@@ -41,19 +41,19 @@ class Channel::Whatsapp < ApplicationRecord
end
# Mirrors Channel::TwilioSms#voice_enabled? so the call subsystem can duck-type across providers.
# Meta's Calling API is available to any whatsapp_cloud inbox (embedded-signup or manual keys);
# only 360dialog (default provider) can't reach the call APIs.
# Meta's Calling API is only available via the embedded-signup whatsapp_cloud flow —
# 360dialog (default provider) and manual whatsapp_cloud setups can't reach the call APIs.
def voice_enabled?
voice_calling_supported? &&
provider_config['calling_enabled'].present? &&
account.feature_enabled?('channel_voice')
end
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is
# reachable by any whatsapp_cloud inbox, so 360dialog inboxes can't be toggled
# on even though calling_enabled would persist.
# Whether this inbox can do WhatsApp calling at all. Meta's Calling API is only
# reachable via the embedded-signup whatsapp_cloud flow, so manual whatsapp_cloud
# and 360dialog inboxes can't be toggled on even though calling_enabled would persist.
def voice_calling_supported?
provider == 'whatsapp_cloud'
provider == 'whatsapp_cloud' && provider_config['source'] == 'embedded_signup'
end
def provider_service
@@ -69,7 +69,7 @@ class Channel::Whatsapp < ApplicationRecord
# Saved with validate: false to skip validate_provider_config's remote credential
# re-check, which could spuriously fail and desync the flag from Meta.
def enable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
raise 'WhatsApp calling requires the channel_voice feature' unless account.feature_enabled?('channel_voice')
provider_service.update_calling_status('ENABLED')
@@ -82,7 +82,7 @@ class Channel::Whatsapp < ApplicationRecord
# `calls` from the webhook subscription (best-effort, so a Meta outage can't
# trap admins). Leaves Meta's WABA calling.status untouched.
def disable_voice_calling!
raise 'WhatsApp calling requires a whatsapp_cloud inbox' unless voice_calling_supported?
raise 'WhatsApp calling requires an embedded-signup whatsapp_cloud inbox' unless voice_calling_supported?
self.provider_config = provider_config.merge('calling_enabled' => false)
save!(validate: false)
-2
View File
@@ -51,5 +51,3 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator?
end
end
ContactPolicy.prepend_mod_with('ContactPolicy')
@@ -4,6 +4,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event)
message, = extract_message_and_account(event)
return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation)
end
@@ -35,7 +36,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id])
return if account.blank?
return unless account&.feature_enabled?('conversation_unread_counts')
return unless remove_deleted_conversation(account, conversation_data)
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
@@ -9,6 +9,8 @@ class Conversations::UnreadCounts::Notifier
end
def perform
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
@@ -5,10 +5,12 @@ class Sms::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
@@ -5,10 +5,12 @@ class Twilio::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed?
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels)
campaign.completed!
end
private
+1 -35
View File
@@ -1,5 +1,3 @@
require 'resolv'
class WebsiteBrandingService
include SocialLinkParser
@@ -26,8 +24,7 @@ class WebsiteBrandingService
colors: extract_colors(doc),
logos: extract_logos(doc),
socials: build_socials(links),
email: @email,
email_provider: detect_email_provider
email: @email
})
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}"
@@ -107,37 +104,6 @@ class WebsiteBrandingService
rescue URI::InvalidURIError
nil
end
GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze
MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze
# Probes the domain's MX records to infer the mailbox provider, returning
# 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown.
def detect_email_provider
hosts = mx_records
return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS)
return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS)
nil
end
# Matches on the registrable domain of the MX host (anchored on a label
# boundary) so lookalikes like "notgoogle.com" don't get misclassified.
def mx_hosted_by?(hosts, provider_domains)
hosts.any? do |host|
provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") }
end
end
def mx_records
Resolv::DNS.open do |resolver|
resolver.timeouts = 5
resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase }
end
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}"
[]
end
end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
def perform
validate_campaign!
process_audience(extract_audience_labels)
# marks campaign completed so that other jobs won't pick it up
campaign.completed!
process_audience(extract_audience_labels)
end
private
@@ -90,8 +90,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
def phone_id_path(version = 'v13.0')
"#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
def phone_id_path
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
end
def business_account_path
@@ -116,11 +116,14 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_attachment_message(phone_number, message)
attachment = message.attachments.first
normalize_opus_content_type(attachment)
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
type_content = build_attachment_content(type, attachment, message)
type_content = {
'link': attachment.download_url
}
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
response = HTTParty.post(
"#{phone_id_path('v24.0')}/messages",
"#{phone_id_path}/messages",
headers: api_headers,
body: {
:messaging_product => 'whatsapp',
@@ -139,33 +142,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
response.parsed_response&.dig('error', 'message')
end
def voice_message?(type, attachment)
type == 'audio' && attachment.meta&.dig('is_voice_message') && attachment.file.content_type == 'audio/ogg'
end
# Marcel gem may re-detect OGG/Opus files as audio/opus after ActiveStorage
# blob attachment, but WhatsApp Cloud API requires audio/ogg content type
# for voice messages. Normalize so the download URL serves the correct
# Content-Type header. No-op when the frontend already uploads as audio/ogg.
def normalize_opus_content_type(attachment)
return unless attachment.file.attached?
blob = attachment.file.blob
return unless blob.content_type == 'audio/opus'
return if blob.update(content_type: 'audio/ogg')
Rails.logger.error("Failed to normalize blob #{blob.id} content_type from audio/opus to audio/ogg")
end
def build_attachment_content(type, attachment, message)
type_content = { 'link' => attachment.download_url }
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
type_content['filename'] = attachment.file.filename if type == 'document'
type_content['voice'] = true if voice_message?(type, attachment)
type_content
end
def template_body_parameters(template_info)
template_body = {
name: template_info[:name],
+1 -1
View File
@@ -1,6 +1,6 @@
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="turbo-cache-control" content="no-cache">
<meta name="turbolinks-cache-control" content="no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= vite_client_tag %>
@@ -82,7 +82,6 @@ html.light {
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
submit: '<%= I18n.t('public_portal.search.submit') %>',
},
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
@@ -11,9 +11,9 @@
<%= I18n.t('public_portal.hero.sub_title') %>
</p>
<div class="mt-8 group/herosearch relative z-30">
<form class="mt-8 group/herosearch relative z-30" onsubmit="return false">
<div id="search-wrap-hero" class="block w-full"></div>
</div>
</form>
<% if popular_topics.any? %>
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
@@ -55,7 +55,7 @@
<% portal.public_locale_codes.each do |code| %>
<% is_current = code == locale %>
<a href="/hc/<%= portal.slug %>/<%= code %>/"
data-turbo="false"
data-turbolinks="false"
class="flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md transition <%= is_current ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12' %>">
<span class="text-xs font-semibold uppercase min-w-9 text-center text-n-slate-11 bg-n-slate-2 border border-solid border-n-weak rounded px-1.5 py-0.5 flex-shrink-0"><%= code %></span>
<span class="flex-1 truncate <%= is_current ? 'font-medium' : '' %>"><%= language_name(code) %></span>
@@ -1,20 +0,0 @@
<%# locals: (input_class:) %>
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<% if @theme_from_params.present? %>
<input type="hidden" name="theme" value="<%= @theme_from_params %>">
<% end %>
<% if @is_plain_layout_enabled %>
<input type="hidden" name="show_plain_layout" value="true">
<% end %>
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="<%= input_class %>">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
@@ -1,33 +0,0 @@
<script>
(function() {
const searchInputs = document.querySelectorAll('[data-search-input]');
searchInputs.forEach(function(input) {
let debounceTimer;
if (input.value.length > 0) {
setTimeout(function() {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
input.addEventListener('input', function() {
const form = input.closest('[data-search-form]');
const query = input.value.trim();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
if (query !== currentQuery) {
form.requestSubmit();
}
}, 500);
});
input.closest('[data-search-form]').addEventListener('submit', function() {
clearTimeout(debounceTimer);
});
});
})();
</script>
@@ -1,64 +0,0 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
<a
href="<%= public_portal_locale_path(@portal.slug, @locale) %>"
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
>
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
<span class="text-n-slate-12 font-medium truncate"><%= I18n.t('public_portal.search.results') %></span>
</nav>
<div class="mb-8">
<h1 class="text-3xl md:text-4xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form',
input_class: 'w-full px-4 py-3 border border-n-weak rounded-lg bg-n-alpha-1 text-n-slate-12 placeholder-n-slate-10 focus:outline-none focus:ring-2 focus:ring-n-portal focus:border-transparent' %>
</div>
<%= render 'public/api/v1/portals/search/search_handler' %>
<% if @articles.empty? %>
<%= render 'public/api/v1/portals/documentation_layout/empty_state',
message: I18n.t('public_portal.search.no_results', query: @query) %>
<% else %>
<p class="mb-6 text-sm text-n-slate-11">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<div class="grid grid-cols-1 gap-4">
<% @articles.each do |article| %>
<%= render 'public/api/v1/portals/documentation_layout/article_card',
portal: @portal,
article: article %>
<% end %>
</div>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-n-weak rounded-l-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-n-weak rounded-r-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
@@ -1,118 +0,0 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% 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' %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
</div>
<% end %>
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
<%= render 'public/api/v1/portals/search/search_handler' %>
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400"></span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</section>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.14.1'
version: '4.14.0'
development:
<<: *shared
+1 -2
View File
@@ -19,9 +19,8 @@
help_url: https://chwt.app/hc/fb
- name: conversation_unread_counts
display_name: Conversation Unread Counts
enabled: true
enabled: false
chatwoot_internal: true
deprecated: true
- name: ip_lookup
display_name: IP Lookup
enabled: false
+15 -5
View File
@@ -170,15 +170,25 @@ class Rack::Attack
###-----------Widget API Throttling---------------###
###-----------------------------------------------###
## Burst protection on widget conversation creation. Keyed on (IP, website_token)
## so distinct widgets behind a shared NAT IP get separate buckets. Independent
## kill switch so it can be toggled without touching the legacy ENABLE_RACK_ATTACK_WIDGET_API.
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS', true))
throttle('api/v1/widget/conversations',
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONVERSATIONS', '30').to_i,
period: 1.minute) do |req|
next unless req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
token = req.params['website_token'].presence ||
ActionDispatch::Request.new(req.env).params['website_token'].presence
"#{req.ip}:#{token}" if token
end
end
# Rack attack on widget APIs can be disabled by setting ENABLE_RACK_ATTACK_WIDGET_API to false
# For clients using the widgets in specific conditions like inside and iframe
# TODO: Deprecate this feature in future after finding a better solution
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
## Prevent Conversation Bombing on Widget APIs ###
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
req.ip if req.path_without_extentions == '/api/v1/widget/conversations' && req.post?
end
## Prevent Contact update Bombing in Widget API ###
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
req.ip if req.path_without_extentions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
-12
View File
@@ -215,18 +215,6 @@
value:
locked: false
type: code
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Per Account Batch Limit'
description: 'Maximum syncable Captain documents to enqueue per account in one scheduler run. Defaults to 50.'
value: 50
locked: false
type: number
- name: CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT
display_title: 'Captain Document Auto Sync Global Batch Limit'
description: 'Maximum syncable Captain documents to enqueue globally in one scheduler run. Defaults to 1000.'
value: 1000
locked: false
type: number
# End of Captain Config
# ------- Context.dev Config ------- #
+3 -9
View File
@@ -81,6 +81,9 @@ en:
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
conversations:
unread_counts:
feature_not_enabled: Conversation unread counts feature not enabled for this account
data_import:
data_type:
invalid: Invalid data type
@@ -427,13 +430,6 @@ en:
empty_placeholder: No results found.
loading_placeholder: Searching...
results_title: Search results
results: Search Results
results_for: "Search Results for '%{query}'"
no_results: "No results found for '%{query}'"
found_results:
one: Found 1 result
other: 'Found %{count} results'
submit: Search
toc_header: 'On this page'
sidebar:
help_center: Help Center
@@ -465,8 +461,6 @@ en:
others: others
by: By
no_articles: There are no articles here
previous: Previous
next: Next
article_actions:
label: Open in
view_markdown: View as Markdown
-3
View File
@@ -29,7 +29,6 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
get '/app/accounts/:account_id/onboarding/inbox-setup', to: 'dashboard#index', as: 'app_onboarding_inbox_setup'
resource :widget, only: [:show]
namespace :survey do
@@ -55,7 +54,6 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
@@ -590,7 +588,6 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
@@ -2,7 +2,7 @@ class Api::V1::Accounts::AuditLogsController < Api::V1::Accounts::EnterpriseAcco
before_action :check_admin_authorization?
before_action :fetch_audit
RESULTS_PER_PAGE = 25
RESULTS_PER_PAGE = 15
def show
@audit_logs = @audit_logs.page(params[:page]).per(RESULTS_PER_PAGE)
@@ -75,6 +75,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
Current.account.captain_documents.where(id: params[:ids]).find_each(batch_size: 100) do |document|
next unless document.syncable?
next unless document.available?
next if document.sync_in_progress?
document.update!(
sync_status: :syncing,
@@ -37,6 +37,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def sync
return render_could_not_create_error(I18n.t('captain.documents.sync_not_supported_for_pdf')) unless @document.syncable?
return render_could_not_create_error(I18n.t('captain.documents.sync_only_available_documents')) unless @document.available?
return render_could_not_create_error(I18n.t('captain.documents.sync_already_in_progress')) if @document.sync_in_progress?
@document.update!(
sync_status: :syncing,
@@ -35,14 +35,8 @@ class Api::V1::Accounts::WhatsappCallsController < Api::V1::Accounts::BaseContro
def initiate
@call = create_outbound_call
# Link the call to its message in one transaction so the message.created
# broadcast (an after_create_commit hook) fires only once call.message_id is
# set. Otherwise the live ringing bubble receives a message with no `call`
# payload (no direction/agent) and renders "Calling…" instead of "Handled by …".
ActiveRecord::Base.transaction do
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
@message = Voice::CallMessageBuilder.new(@call).perform!
@call.update!(message_id: @message.id)
end
private
@@ -1,9 +0,0 @@
module Enterprise::Public::Api::V1::Portals::SearchController
private
def search_articles
return super if @query.blank? || !@portal.account.feature_enabled?('help_center_embedding_search')
@articles = @articles.vector_search(search_params.merge(account_id: @portal.account_id, limit: nil))
end
end
@@ -48,7 +48,9 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
perform_sync(document, start_time)
mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing)
@@ -62,12 +64,6 @@ class Captain::Documents::PerformSyncJob < MutexApplicationJob
private
def perform_sync(document, start_time)
mark_sync_started(document)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
def log_sync_outcome(document, **fields)
payload = {
document_id: document.id,
@@ -1,27 +1,21 @@
class Captain::Documents::ScheduleSyncsJob < ApplicationJob
queue_as :scheduled_jobs
DEFAULT_PER_ACCOUNT_BATCH_LIMIT = 50
DEFAULT_GLOBAL_BATCH_LIMIT = 1000
PER_ACCOUNT_HOURLY_CAP = 50
GLOBAL_HOURLY_CAP = 1000
DUE_DOCUMENT_BATCH_SIZE = PER_ACCOUNT_HOURLY_CAP * 2 # Inspite of skipping, we should at least reach the hourly cap
SYNC_STALE_TIMEOUT = Captain::Document::SYNC_STALE_TIMEOUT
DAILY_SYNC_JITTER = 4.hours
WEEKLY_SYNC_JITTER = 1.day
MONTHLY_SYNC_JITTER = 4.days
def perform(plan_name = nil)
@per_account_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', DEFAULT_PER_ACCOUNT_BATCH_LIMIT)
@global_batch_limit = configured_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', DEFAULT_GLOBAL_BATCH_LIMIT)
@remaining_global_capacity = @global_batch_limit
@plan_name = plan_name.to_s.downcase.presence
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
sync_intervals = Enterprise::Account.captain_document_sync_intervals
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0, documents_skipped: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
break if @remaining_global_capacity <= 0
stats[:accounts_scanned] += 1
next unless account.feature_enabled?('captain_document_auto_sync')
next unless account_in_selected_plan?(account)
stats[:accounts_enabled] += 1
interval = account.captain_document_sync_interval(sync_intervals)
@@ -30,6 +24,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
stats[:accounts_scheduled] += 1
result = enqueue_due_documents(account, interval)
stats[:documents_enqueued] += result[:enqueued]
stats[:documents_skipped] += result[:skipped]
end
log_scheduler_summary(stats)
@@ -38,85 +33,92 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
private
def enqueue_due_documents(account, interval)
per_account_limit = [@per_account_batch_limit, @remaining_global_capacity].min
result = { enqueued: 0 }
per_account_limit = [PER_ACCOUNT_HOURLY_CAP, @remaining_global_capacity].min
result = { enqueued: 0, skipped: 0 }
skipped_document_ids = []
due_documents(account, interval).limit(per_account_limit).to_a.each do |document|
process_due_document(document, interval, result)
while result[:enqueued] < per_account_limit
documents = due_documents(account, interval, skipped_document_ids).limit(DUE_DOCUMENT_BATCH_SIZE).to_a
break if documents.empty?
documents.each do |document|
break if result[:enqueued] >= per_account_limit
process_due_document(document, result, skipped_document_ids)
end
end
result
end
def process_due_document(document, interval, result)
sync_execution_delay = sync_jitter(interval)
def process_due_document(document, result, skipped_document_ids)
return unless document.syncable?
Captain::Documents::PerformSyncJob.set(queue: :purgable, wait: sync_execution_delay).perform_later(document)
# Reserve the sync slot before enqueueing so later scheduler runs skip this document while the job is queued.
unless reserve_sync_slot(document)
result[:skipped] += 1
skipped_document_ids << document.id
return
end
Captain::Documents::PerformSyncJob.perform_later(document)
@remaining_global_capacity -= 1
result[:enqueued] += 1
end
def due_documents(account, interval)
def due_documents(account, interval, skipped_document_ids)
syncing = Captain::Document.sync_statuses[:syncing]
synced = Captain::Document.sync_statuses[:synced]
failed = Captain::Document.sync_statuses[:failed]
stale_cutoff = SYNC_STALE_TIMEOUT.ago
# The scheduler runs at predictable plan windows. Use a wider due window so
# jittered executions do not miss the next window just because they finished later.
sync_due_before = due_window(interval).ago
documents = account.captain_documents.syncable.where(status: :available).where(
'(sync_status = ? AND last_synced_at < ?) OR (sync_status = ? AND last_sync_attempted_at < ?) OR ' \
'(sync_status = ? AND last_sync_attempted_at < ?)',
synced, sync_due_before, failed, sync_due_before, syncing, stale_cutoff
synced, interval.ago, failed, interval.ago, syncing, SYNC_STALE_TIMEOUT.ago
)
documents = documents.where.not(id: skipped_document_ids) if skipped_document_ids.present?
documents.order(Arel.sql('last_sync_attempted_at ASC NULLS FIRST'), :id)
end
def configured_sync_limit(config_key, default)
configured_value = InstallationConfig.find_by(name: config_key)&.value
limit = configured_value.to_s.to_i
limit.positive? ? limit : default
def reserve_sync_slot(document)
mark_sync_started(document)
true
rescue ActiveRecord::RecordInvalid => e
log_document_skip(document, e)
false
end
def account_in_selected_plan?(account)
return true if @plan_name.blank?
def log_document_skip(document, error)
payload = {
event: 'document_skipped',
document_id: document.id,
account_id: document.account_id,
assistant_id: document.assistant_id,
error_class: error.class.name,
error_message: error.message,
validation_errors: document.errors.full_messages
}
account_sync_plan(account) == @plan_name
end
def account_sync_plan(account)
plan = account.custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
plan.to_s.downcase.presence
end
def sync_jitter(interval)
jitter_window = if interval <= 1.day
DAILY_SYNC_JITTER
elsif interval <= 1.week
WEEKLY_SYNC_JITTER
else
MONTHLY_SYNC_JITTER
end
rand(0..jitter_window.to_i).seconds
end
def due_window(interval)
(interval.to_i / 2).seconds
Rails.logger.warn("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
def log_scheduler_summary(stats)
payload = {
event: 'completed',
plan_name: @plan_name,
global_cap_hit: @remaining_global_capacity <= 0,
per_account_batch_limit: @per_account_batch_limit,
global_batch_limit: @global_batch_limit,
remaining_global_capacity: @remaining_global_capacity
}.merge(stats)
Rails.logger.info("[Captain::Documents::ScheduleSyncsJob] #{payload.to_json}")
end
def mark_sync_started(document)
document.update!(
sync_status: :syncing,
sync_step: nil,
last_sync_error_code: nil,
last_sync_attempted_at: Time.current
)
end
end
@@ -1,19 +0,0 @@
module Enterprise::Internal::TriggerDailyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later('enterprise')
Captain::Documents::ScheduleSyncsJob.perform_later('business') if business_auto_sync_due?
Captain::Documents::ScheduleSyncsJob.perform_later('startups') if startup_auto_sync_due?
end
private
def business_auto_sync_due?
Time.current.utc.sunday?
end
def startup_auto_sync_due?
Time.current.utc.day == 1
end
end
@@ -0,0 +1,7 @@
module Enterprise::Internal::TriggerHourlyScheduledItemsJob
def perform
super
Captain::Documents::ScheduleSyncsJob.perform_later
end
end
@@ -31,11 +31,10 @@ module Enterprise::Webhooks::WhatsappEventsJob
# and timer/recorder kick off before the contact actually answers.
def handle_call_events(channel, params)
value = params.dig(:entry, 0, :changes, 0, :value) || {}
contacts = value[:contacts]
Array(value[:calls]).each do |call_payload|
with_call_lock(channel, call_payload[:id]) do
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload], contacts: contacts }).perform
Whatsapp::IncomingCallService.new(inbox: channel.inbox, params: { calls: [call_payload] }).perform
end
end
@@ -26,15 +26,13 @@ module Enterprise::Concerns::Article
# if using add the filter block to the below query
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
limit = params.key?(:limit) ? params[:limit] : 5
article_embeddings = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
article_embeddings = article_embeddings.limit(limit) if limit.present?
article_ids = article_embeddings.pluck(:article_id)
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
.limit(5)
.pluck(:article_id)
# Fetch the articles by the IDs obtained from the nearest neighbors search
where(id: article_ids).in_order_of(:id, article_ids)
where(id: article_ids)
end
end
@@ -1,9 +0,0 @@
module Enterprise::ContactPolicy
def export?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
def import?
@account_user.custom_role&.permissions&.include?('contact_manage') || super
end
end
@@ -1,5 +1,5 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v2'.freeze
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured?
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
def crawl_payload(url, webhook_url, crawl_limit)
{
url: url,
maxDiscoveryDepth: 50,
sitemap: 'include',
maxDepth: 50,
ignoreSitemap: false,
limit: crawl_limit,
webhook: { url: webhook_url },
webhook: webhook_url,
scrapeOptions: scrape_options
}.to_json
end
@@ -50,7 +50,6 @@ class Captain::Tools::FirecrawlService
def scrape_options
{
onlyMainContent: true,
maxAge: 0,
formats: ['markdown'],
excludeTags: FIRECRAWL_EXCLUDE_TAGS
}

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