Compare commits

..
Author SHA1 Message Date
Shivam MishraandVishnu Narayanan c8b65f86c9 feat: remove prompt param 2026-05-29 18:33:07 +05:30
128 changed files with 1167 additions and 8739 deletions
+3 -5
View File
@@ -996,13 +996,11 @@ GEM
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.4) version_gem (1.1.4)
vite_rails (3.10.0) vite_rails (3.0.17)
railties (>= 5.1, < 9) railties (>= 5.1, < 8)
vite_ruby (~> 3.0, >= 3.2.2) vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.10.2) vite_ruby (3.8.0)
dry-cli (>= 0.7, < 2) dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1) rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2) zeitwerk (~> 2.2)
warden (1.2.9) warden (1.2.9)
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
def fallback_params(attachment) def fallback_params(attachment)
{ {
fallback_title: attachment['title'], fallback_title: attachment['title'],
external_url: attachment['url'] || attachment.dig('payload', 'url') external_url: attachment['url']
} }
end 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 def conversation_params
{ {
account_id: @inbox.account_id, account_id: @inbox.account_id,
+7 -17
View File
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
@account = conversation.account @account = conversation.account
@message_type = params[:message_type] || 'outgoing' @message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments] @attachments = params[:attachments]
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
@automation_rule = content_attributes&.dig(:automation_rule_id) @automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters) return unless params.instance_of?(ActionController::Parameters)
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
file: uploaded_attachment file: uploaded_attachment
) )
attachment.file_type = attachment_file_type(uploaded_attachment) attachment.file_type = if uploaded_attachment.is_a?(String)
tag_voice_message(attachment) file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end 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 def process_emails
return unless @conversation.inbox&.inbox_type == 'Email' return unless @conversation.inbox&.inbox_type == 'Email'
@@ -1,6 +1,16 @@
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
before_action :ensure_unread_counts_enabled
def index def index
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
render json: { payload: counts } render json: { payload: counts }
end 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 end
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
enable_fb_login: '0', enable_fb_login: '0',
force_authentication: '1', force_authentication: '1',
response_type: 'code', response_type: 'code',
state: generate_instagram_token(Current.account.id, params[:return_to]) state: generate_instagram_token(Current.account.id)
} }
) )
if redirect_url if redirect_url
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: scope,
state: state, state: state
prompt: 'consent'
} }
) )
if redirect_url if redirect_url
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
end end
def state def state
# The sgid purpose doubles as a return hint: onboarding tags it so the callback Current.account.to_sgid(expires_in: 15.minutes).to_s
# 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'
end end
def base_url 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 def create
redirect_url = Tiktok::AuthClient.authorize_url( 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 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.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params) @account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_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.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save! @account.save!
end end
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
@long_lived_token_response = exchange_for_long_lived_token(@response.token) @long_lived_token_response = exchange_for_long_lived_token(@response.token)
inbox, already_exists = find_or_create_inbox 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 if already_exists
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else else
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
verify_instagram_token(params[:state]) verify_instagram_token(params[:state])
end end
def return_to
instagram_token_return_to(params[:state])
end
def oauth_code def oauth_code
params[:code] params[:code]
end end
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
def imap_address def imap_address
'outlook.office365.com' 'outlook.office365.com'
end 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 end
+2 -25
View File
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
def handle_response def handle_response
inbox, already_exists = find_or_create_inbox 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 if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else else
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
def update_channel(channel_email) def update_channel(channel_email)
channel_email.update!({ 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, imap_port: '993', imap_enabled: true,
provider: provider_name, provider: provider_name,
provider_config: { provider_config: {
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
}) })
end 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 def provider_name
raise NotImplementedError raise NotImplementedError
end end
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end 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 def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) account = GlobalID::Locator.locate_signed(params[:state])
@return_to = 'onboarding'
else
account = GlobalID::Locator.locate_signed(params[:state])
end
raise 'Invalid or expired state' if account.nil? raise 'Invalid or expired state' if account.nil?
account account
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
@account ||= account_from_signed_id @account ||= account_from_signed_id
end 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 # Fallback name, for when name field is missing from users_data
def fallback_name def fallback_name
users_data['email'].split('@').first.parameterize.titleize 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 def process_successful_authorization
inbox, already_exists = find_or_create_inbox 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 if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else else
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
@account_id ||= verify_tiktok_token(params[:state]) @account_id ||= verify_tiktok_token(params[:state])
end end
def return_to
tiktok_token_return_to(params[:state])
end
def account def account
@account ||= Account.find(account_id) @account ||= Account.find(account_id)
end end
+9 -16
View File
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
# Generates a signed JWT token for Instagram integration # Generates a signed JWT token for Instagram integration
# #
# @param account_id [Integer] The account ID to encode in the token # @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 # @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? 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 rescue StandardError => e
Rails.logger.error("Failed to generate Instagram token: #{e.message}") Rails.logger.error("Failed to generate Instagram token: #{e.message}")
nil nil
end end
def token_payload(account_id, return_to = nil) def token_payload(account_id)
payload = { sub: account_id, iat: Time.current.to_i } {
payload[:return_to] = return_to if return_to.present? sub: account_id,
payload iat: Time.current.to_i
}
end end
# Verifies and decodes a Instagram JWT token # Verifies and decodes a Instagram JWT token
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
def verify_instagram_token(token) def verify_instagram_token(token)
return if token.blank? || client_secret.blank? return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub') decode_token(token, client_secret)
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')
end end
private private
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
JWT.decode(token, secret, true, { JWT.decode(token, secret, true, {
algorithm: 'HS256', algorithm: 'HS256',
verify_expiration: true verify_expiration: true
}).first }).first['sub']
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}") Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
nil nil
+3 -10
View File
@@ -45,16 +45,9 @@ module PortalHelper
theme.present? && theme != 'system' ? "?theme=#{theme}" : '' theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end 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) def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if 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 else
"/hc/#{portal_slug}/#{portal_locale}" "/hc/#{portal_slug}/#{portal_locale}"
end end
@@ -68,7 +61,7 @@ module PortalHelper
is_plain_layout_enabled = params[:is_plain_layout_enabled] is_plain_layout_enabled = params[:is_plain_layout_enabled]
if 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 else
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end end
@@ -76,7 +69,7 @@ module PortalHelper
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if 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 else
"/hc/#{portal_slug}/articles/#{article_slug}" "/hc/#{portal_slug}/articles/#{article_slug}"
end end
+9 -16
View File
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration # Generates a signed JWT token for Tiktok integration
# #
# @param account_id [Integer] The account ID to encode in the token # @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 # @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? 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 rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}") Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil nil
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
def verify_tiktok_token(token) def verify_tiktok_token(token)
return if token.blank? || client_secret.blank? return if token.blank? || client_secret.blank?
decode_token(token, client_secret)&.dig('sub') decode_token(token, client_secret)
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')
end end
private private
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end end
def token_payload(account_id, return_to = nil) def token_payload(account_id)
payload = { sub: account_id, iat: Time.current.to_i } {
payload[:return_to] = return_to if return_to.present? sub: account_id,
payload iat: Time.current.to_i
}
end end
def decode_token(token, secret) def decode_token(token, secret)
JWT.decode(token, secret, true, { JWT.decode(token, secret, true, {
algorithm: 'HS256', algorithm: 'HS256',
verify_expiration: true verify_expiration: true
}).first }).first['sub']
rescue StandardError => e rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil nil
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
templateParams, templateParams,
isVoiceMessage = false,
}) => { }) => {
let payload; let payload;
if (files && files.length !== 0) { if (files && files.length !== 0) {
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
if (contentAttributes) { if (contentAttributes) {
payload.append('content_attributes', JSON.stringify(contentAttributes)); payload.append('content_attributes', JSON.stringify(contentAttributes));
} }
if (isVoiceMessage) {
payload.append('is_voice_message', true);
}
} else { } else {
payload = { payload = {
content: message, content: message,
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
bccEmails = '', bccEmails = '',
toEmails = '', toEmails = '',
templateParams, templateParams,
isVoiceMessage = false,
}) { }) {
return axios({ return axios({
method: 'post', method: 'post',
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
bccEmails, bccEmails,
toEmails, toEmails,
templateParams, 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, 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 { t } = useI18n();
const STATUS_COMPLETED = 'completed'; const STATUS_COMPLETED = 'completed';
const STATUS_PROCESSING = 'processing';
const { formatMessage } = useMessageFormatter(); const { formatMessage } = useMessageFormatter();
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
} }
if (props.status === STATUS_COMPLETED) { return props.status === STATUS_COMPLETED
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED'); ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
} : t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
if (props.status === STATUS_PROCESSING) {
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
}
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
}); });
const inboxName = computed(() => props.inbox?.name || ''); const inboxName = computed(() => props.inbox?.name || '');
@@ -1,48 +1,34 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import { usePolicy } from 'dashboard/composables/usePolicy';
const emit = defineEmits(['add', 'import', 'export']); const emit = defineEmits(['add', 'import', 'export']);
const { t } = useI18n(); const { t } = useI18n();
const { checkPermissions } = usePolicy();
const contactMenuItems = computed(() => [ const contactMenuItems = [
{ {
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'), label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
action: 'add', action: 'add',
value: 'add', value: 'add',
icon: 'i-lucide-plus', icon: 'i-lucide-plus',
}, },
...(checkPermissions(['administrator', 'contact_manage']) {
? [ label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
{ action: 'export',
label: t( value: 'export',
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT' icon: 'i-lucide-upload',
), },
action: 'export', {
value: 'export', label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
icon: 'i-lucide-upload', action: 'import',
}, value: 'import',
] icon: 'i-lucide-download',
: []), },
...(checkPermissions(['administrator', 'contact_manage']) ];
? [
{
label: t(
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
),
action: 'import',
value: 'import',
icon: 'i-lucide-download',
},
]
: []),
]);
const showActionsDropdown = ref(false); const showActionsDropdown = ref(false);
const handleContactAction = ({ action }) => { const handleContactAction = ({ action }) => {
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue'; import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue'; import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue'; import EmbedBubble from './bubbles/Embed.vue';
import FallbackBubble from './bubbles/Fallback.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue'; import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue'; import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue'; import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
if (Array.isArray(props.attachments) && props.attachments.length === 1) { if (Array.isArray(props.attachments) && props.attachments.length === 1) {
const fileType = props.attachments[0].fileType; const fileType = props.attachments[0].fileType;
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
if (!props.content) { if (!props.content) {
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble; if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble; if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
@@ -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>
@@ -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 (!currentAccountId) return;
if (!isEnabled) {
store.dispatch('conversationUnreadCounts/clear');
return;
}
store.dispatch('conversationUnreadCounts/get'); store.dispatch('conversationUnreadCounts/get');
}; };
@@ -188,7 +200,7 @@ onMounted(() => {
store.dispatch('customViews/get', 'contact'); store.dispatch('customViews/get', 'contact');
}); });
watch(accountId, fetchConversationUnreadCounts, { watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true, immediate: true,
}); });
@@ -14,11 +14,11 @@ const props = defineProps({
const emit = defineEmits(['removeAttachment']); const emit = defineEmits(['removeAttachment']);
const nonRecordedAudioAttachments = computed(() => { const nonRecordedAudioAttachments = computed(() => {
return props.attachments.filter(attachment => !attachment?.isVoiceMessage); return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
}); });
const recordedAudioAttachments = computed(() => const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isVoiceMessage) props.attachments.filter(attachment => attachment.isRecordedAudio)
); );
const onRemoveAttachment = itemIndex => { const onRemoveAttachment = itemIndex => {
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import WaveSurfer from 'wavesurfer.js'; import WaveSurfer from 'wavesurfer.js';
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js'; import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
import { format, intervalToDuration } from 'date-fns'; import { format, intervalToDuration } from 'date-fns';
import { convertAudio } from './utils/audioConversionUtils'; import { convertAudio } from './utils/mp3ConversionUtils';
const props = defineProps({ const props = defineProps({
audioRecordFormat: { audioRecordFormat: {
@@ -18,7 +18,6 @@ const emit = defineEmits([
'finishRecord', 'finishRecord',
'pause', 'pause',
'play', 'play',
'recordError',
]); ]);
const waveformContainer = ref(null); const waveformContainer = ref(null);
@@ -27,7 +26,6 @@ const record = ref(null);
const isRecording = ref(false); const isRecording = ref(false);
const isPlaying = ref(false); const isPlaying = ref(false);
const hasRecording = ref(false); const hasRecording = ref(false);
const recordedAudioUrl = ref(null);
const formatTimeProgress = time => { const formatTimeProgress = time => {
const duration = intervalToDuration({ start: 0, end: 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 = () => { const initWaveSurfer = () => {
wavesurfer.value = WaveSurfer.create({ wavesurfer.value = WaveSurfer.create({
container: waveformContainer.value, container: waveformContainer.value,
@@ -69,7 +45,10 @@ const initWaveSurfer = () => {
barGap: 1, barGap: 1,
barRadius: 2, barRadius: 2,
plugins: [ 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 => { record.value.on('record-end', async blob => {
try { const audioUrl = URL.createObjectURL(blob);
const audioBlob = await convertAudio(blob, props.audioRecordFormat); const audioBlob = await convertAudio(blob, props.audioRecordFormat);
// Use the converted blob's actual type, which may differ from the const fileName = `${getUuid()}.mp3`;
// requested format when the browser can't produce it (e.g. Safari falls const file = new File([audioBlob], fileName, {
// back to MP3 instead of OGG). This keeps the filename, content type, and type: props.audioRecordFormat,
// voice-note flag consistent with the real bytes. });
const audioType = audioBlob.type || props.audioRecordFormat; wavesurfer.value.load(audioUrl);
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3'; emit('finishRecord', {
const fileName = `${getUuid()}.${ext}`; name: file.name,
const file = new File([audioBlob], fileName, { type: file.type,
type: audioType, size: file.size,
}); file,
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value); });
recordedAudioUrl.value = URL.createObjectURL(audioBlob); hasRecording.value = true;
wavesurfer.value.load(recordedAudioUrl.value); isRecording.value = false;
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 });
}
}); });
record.value.on('record-progress', time => { record.value.on('record-progress', time => {
@@ -143,10 +109,6 @@ onMounted(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value);
recordedAudioUrl.value = null;
}
if (wavesurfer.value) { if (wavesurfer.value) {
wavesurfer.value.destroy(); wavesurfer.value.destroy();
} }
@@ -1,7 +1,5 @@
import lamejs from '@breezystack/lamejs'; import lamejs from '@breezystack/lamejs';
import { remuxWebmToOgg } from './webmOpusToOgg';
const writeString = (view, offset, string) => { const writeString = (view, offset, string) => {
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let i = 0; i < string.length; i++) { for (let i = 0; i < string.length; i++) {
@@ -141,20 +139,6 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
audio = await convertToWav(inputBlob); audio = await convertToWav(inputBlob);
} else if (outputFormat === 'audio/mp3') { } else if (outputFormat === 'audio/mp3') {
audio = await convertToMp3(inputBlob, bitrate); 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 { } else {
throw new Error('Unsupported output format'); 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}`; return `draft-${this.conversationIdByRoute}-${this.replyType}`;
}, },
audioRecordFormat() { audioRecordFormat() {
if (this.isAWhatsAppChannel) { if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.OGG;
}
if (this.isATelegramChannel) {
return AUDIO_FORMATS.MP3; return AUDIO_FORMATS.MP3;
} }
if (this.isAPIInbox) { if (this.isAPIInbox) {
@@ -1011,18 +1008,14 @@ export default {
onFinishRecorder(file) { onFinishRecorder(file) {
this.recordingAudioState = 'stopped'; this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true; 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 // Because to filter and show only non recorded audio and other attachments
const autoRecordedFile = { const autoRecordedFile = {
...file, ...file,
isVoiceMessage: true, isRecordedAudio: true,
}; };
return file && this.onFileUpload(autoRecordedFile); return file && this.onFileUpload(autoRecordedFile);
}, },
onRecordError() {
this.toggleAudioRecorder();
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
},
toggleTyping(status) { toggleTyping(status) {
const conversationId = this.currentChat.id; const conversationId = this.currentChat.id;
const isPrivate = this.isPrivate; const isPrivate = this.isPrivate;
@@ -1049,7 +1042,7 @@ export default {
isPrivate: this.isPrivate, isPrivate: this.isPrivate,
thumb: reader.result, thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined, blobSignedId: blob ? blob.signed_id : undefined,
isVoiceMessage: file?.isVoiceMessage || false, isRecordedAudio: file?.isRecordedAudio || false,
}); });
}; };
}, },
@@ -1085,7 +1078,6 @@ export default {
private: false, private: false,
message: caption, message: caption,
sender: this.sender, sender: this.sender,
isVoiceMessage: attachment.isVoiceMessage || false,
}; };
attachmentPayload = this.setReplyToInPayload(attachmentPayload); attachmentPayload = this.setReplyToInPayload(attachmentPayload);
@@ -1135,9 +1127,6 @@ export default {
this.attachedFiles.forEach(attachment => { this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) { if (this.globalConfig.directUploadsEnabled) {
messagePayload.files.push(attachment.blobSignedId); messagePayload.files.push(attachment.blobSignedId);
if (attachment.isVoiceMessage) {
messagePayload.isVoiceMessage = true;
}
} else { } else {
messagePayload.files.push(attachment.resource.file); messagePayload.files.push(attachment.resource.file);
} }
@@ -1226,7 +1215,7 @@ export default {
this.hasRecordedAudio = false; this.hasRecordedAudio = false;
// Only clear the recorded audio when we click toggle button. // Only clear the recorded audio when we click toggle button.
this.attachedFiles = this.attachedFiles.filter( this.attachedFiles = this.attachedFiles.filter(
file => !file?.isVoiceMessage file => !file?.isRecordedAudio
); );
}, },
toggleEditorSize() { toggleEditorSize() {
@@ -1304,7 +1293,6 @@ export default {
:audio-record-format="audioRecordFormat" :audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged" @recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder" @finish-record="onFinishRecorder"
@record-error="onRecordError"
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
@@ -10,12 +10,10 @@ vi.mock('shared/helpers/mitt', () => ({
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal(); const actual = await importOriginal();
return { actual.default = {
...actual, track: vi.fn(),
default: {
track: vi.fn(),
},
}; };
return actual;
}); });
describe('useTrack', () => { describe('useTrack', () => {
@@ -16,12 +16,10 @@ vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks'); vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => { vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal(); const actual = await importOriginal();
return { actual.default = {
...actual, track: vi.fn(),
default: {
track: vi.fn(),
},
}; };
return actual;
}); });
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({ vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: { CAPTAIN_EVENTS: {
@@ -52,10 +52,6 @@ export function useAccount() {
}); });
}; };
const finishOnboarding = async data => {
await store.dispatch('accounts/finishOnboarding', data);
};
return { return {
accountId, accountId,
route, route,
@@ -65,6 +61,5 @@ export function useAccount() {
isCloudFeatureEnabled, isCloudFeatureEnabled,
isOnChatwootCloud, isOnChatwootCloud,
updateAccount, updateAccount,
finishOnboarding,
}; };
} }
+1
View File
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
COMPANIES: 'companies', COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search', ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes', CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
@@ -13,6 +13,7 @@ import {
} from 'dashboard/composables/useWhatsappCallSession'; } from 'dashboard/composables/useWhatsappCallSession';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox'; import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants'; import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const { isImpersonating } = useImpersonation(); const { isImpersonating } = useImpersonation();
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000; const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
@@ -171,10 +172,23 @@ class ActionCableConnector extends BaseActionCableConnector {
}; };
fetchConversationUnreadCounts = () => { fetchConversationUnreadCounts = () => {
if (!this.isConversationUnreadCountsEnabled()) return;
this.lastUnreadCountsFetchAt = Date.now(); this.lastUnreadCountsFetchAt = Date.now();
this.app.$store.dispatch('conversationUnreadCounts/get'); 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 }) => { onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id; const conversationId = conversation.id;
@@ -30,6 +30,7 @@ describe('ActionCableConnector - Copilot Tests', () => {
dispatch: mockDispatch, dispatch: mockDispatch,
getters: { getters: {
getCurrentAccountId: 1, getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
}, },
}, },
}; };
@@ -88,6 +89,21 @@ describe('ActionCableConnector - Copilot Tests', () => {
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get'); 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', () => { it('should throttle unread count refetches for repeated events', () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
@@ -88,7 +88,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -147,7 +146,6 @@
}, },
"CARD": { "CARD": {
"STATUS": { "STATUS": {
"PROCESSING": "Processing",
"COMPLETED": "Completed", "COMPLETED": "Completed",
"SCHEDULED": "Scheduled" "SCHEDULED": "Scheduled"
}, },
@@ -231,7 +231,6 @@
"TIP_AUDIORECORDER_ICON": "Record audio", "TIP_AUDIORECORDER_ICON": "Record audio",
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio", "TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
"TIP_AUDIORECORDER_ERROR": "Could not open the 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", "DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording", "START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording",
@@ -28,7 +28,7 @@ import {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { accountId, currentAccount, finishOnboarding } = useAccount(); const { accountId, currentAccount, updateAccount } = useAccount();
const { enabledLanguages } = useConfig(); const { enabledLanguages } = useConfig();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
@@ -195,12 +195,6 @@ const handleWebsiteEnter = () => {
websiteInput.value?.blur(); websiteInput.value?.blur();
}; };
const normalizeWebsiteUrl = raw => {
const trimmed = (raw || '').trim();
if (!trimmed) return '';
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
};
const handleSubmit = async () => { const handleSubmit = async () => {
// Block submit while enrichment is still running so users can't bypass // Block submit while enrichment is still running so users can't bypass
// the form with empty values — the controller would otherwise clear // the form with empty values — the controller would otherwise clear
@@ -217,27 +211,9 @@ const handleSubmit = async () => {
return; 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; isSubmitting.value = true;
try { try {
await finishOnboarding({ await updateAccount({
name: accountName.value, name: accountName.value,
locale: locale.value, locale: locale.value,
website: website.value, website: website.value,
@@ -248,11 +224,20 @@ const handleSubmit = async () => {
user_role: userRole.value, 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, { useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
has_enriched_data: Boolean( has_enriched_data: Boolean(
currentAccount.value?.custom_attributes?.brand_info 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, user_role: userRole.value,
company_size: companySize.value, company_size: companySize.value,
industry: industry.value, industry: industry.value,
@@ -1,7 +1,6 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import AccountAPI from '../../api/account'; import AccountAPI from '../../api/account';
import OnboardingAPI from '../../api/onboarding';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account'; import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api'; import { throwErrorMessage } from '../utils/api';
@@ -84,15 +83,6 @@ export const actions = {
throw new Error(error); 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 }) => { delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try { try {
@@ -48,6 +48,9 @@ export const actions = {
// Ignore errors so the sidebar can continue rendering without badges. // Ignore errors so the sidebar can continue rendering without badges.
} }
}, },
clear({ commit }) {
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
},
}; };
export const mutations = { export const mutations = {
@@ -39,4 +39,15 @@ describe('#actions', () => {
expect(commit).not.toHaveBeenCalled(); expect(commit).not.toHaveBeenCalled();
}); });
}); });
describe('#clear', () => {
it('clears unread counts', () => {
actions.clear({ commit });
expect(commit).toHaveBeenCalledWith(
types.SET_CONVERSATION_UNREAD_COUNTS,
{}
);
});
});
}); });
+3 -3
View File
@@ -1,9 +1,9 @@
import Rails from '@rails/ujs'; import Rails from '@rails/ujs';
import { Turbo } from '@hotwired/turbo-rails'; import Turbolinks from 'turbolinks';
import '../portal/application.scss'; import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers'; import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start(); 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); background-color: var(--dynamic-portal-color);
} }
@@ -138,40 +138,21 @@ export default {
this.isLoading = false; 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> </script>
<template> <template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4"> <div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<form @submit.prevent="handleSubmit"> <PublicSearchInput
<PublicSearchInput ref="searchInput"
ref="searchInput" :search-term="searchTerm"
:search-term="searchTerm" :search-placeholder="searchTranslations.searchPlaceholder"
:search-placeholder="searchTranslations.searchPlaceholder" :size="size"
:size="size" :kbd="kbdLabel"
:kbd="kbdLabel" @update:search-term="onUpdateSearchTerm"
@update:search-term="onUpdateSearchTerm" @focus="openSearch"
@focus="openSearch" />
/>
<button type="submit" class="sr-only">
{{ searchTranslations.submit }}
</button>
</form>
<div <div
v-if="shouldShowSearchBox" v-if="shouldShowSearchBox"
class="absolute w-full top-14" class="absolute w-full top-14"
@@ -112,8 +112,8 @@ export default {
<p class="py-1 px-3" :class="getClassName(element)"> <p class="py-1 px-3" :class="getClassName(element)">
<a <a
:href="`#${element.slug}`" :href="`#${element.slug}`"
data-turbo="false" data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer" class="font-medium text-sm cursor-pointer"
:class="elementTextStyles(element)" :class="elementTextStyles(element)"
> >
{{ element.title }} {{ element.title }}
+3 -2
View File
@@ -24,9 +24,10 @@ export const getHeadingsfromTheArticle = () => {
permalink.className = 'permalink text-slate-600 ml-3'; permalink.className = 'permalink text-slate-600 ml-3';
permalink.href = `#${slug}`; permalink.href = `#${slug}`;
permalink.title = headingText; permalink.title = headingText;
permalink.dataset.turbo = 'false'; permalink.dataset.turbolinks = 'false';
permalink.textContent = '#'; permalink.textContent = '#';
element.appendChild(permalink); element.appendChild(permalink);
rows.push({ rows.push({
slug, slug,
title: headingText, title: headingText,
@@ -187,7 +188,7 @@ export const InitializationHelpers = {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = window.location.hash; a.href = window.location.hash;
a['data-turbo'] = false; a['data-turbolinks'] = false;
a.click(); a.click();
} }
}, },
+1 -1
View File
@@ -92,7 +92,7 @@ class ActionCableListener < BaseListener
def conversation_unread_count_changed(event) def conversation_unread_count_changed(event)
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform 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) tokens = user_tokens(account, inbox_members)
+1
View File
@@ -109,6 +109,7 @@ class Account < ApplicationRecord
before_validation :validate_limit_keys before_validation :validate_limit_keys
after_create_commit :notify_creation 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 after_destroy :remove_account_sequences
def agents def agents
-4
View File
@@ -55,14 +55,10 @@ class Article < ApplicationRecord
before_validation :ensure_article_slug before_validation :ensure_article_slug
before_validation :ensure_locale_in_article 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 :account_id, presence: true
validates :author_id, presence: true validates :author_id, presence: true
validates :title, presence: true validates :title, presence: true
validates :content, presence: true, if: :published? validates :content, presence: true, if: :published?
validates :slug, exclusion: { in: RESERVED_SLUGS }
# ensuring that the position is always set correctly # ensuring that the position is always set correctly
before_create :add_position_to_article 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 } 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. # 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 has_many :conversations, dependent: :nullify, autosave: true
@@ -56,27 +56,13 @@ class Campaign < ApplicationRecord
def trigger! def trigger!
return unless one_off? return unless one_off?
return unless feature_enabled? return if completed?
return unless mark_processing!
execute_campaign execute_campaign
end end
private 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 def execute_campaign
case inbox.inbox_type case inbox.inbox_type
when 'Twilio SMS' when 'Twilio SMS'
@@ -84,7 +70,7 @@ class Campaign < ApplicationRecord
when 'Sms' when 'Sms'
Sms::OneoffSmsCampaignService.new(campaign: self).perform Sms::OneoffSmsCampaignService.new(campaign: self).perform
when 'Whatsapp' when 'Whatsapp'
Whatsapp::OneoffCampaignService.new(campaign: self).perform Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
end end
end end
-2
View File
@@ -51,5 +51,3 @@ class ContactPolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
end end
ContactPolicy.prepend_mod_with('ContactPolicy')
@@ -4,6 +4,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
def message_created(event) def message_created(event)
message, = extract_message_and_account(event) message, = extract_message_and_account(event)
return unless message.incoming? return unless message.incoming?
return unless message.account.feature_enabled?('conversation_unread_counts')
refresh(message.conversation) refresh(message.conversation)
end end
@@ -35,7 +36,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
return if conversation_data.blank? return if conversation_data.blank?
account = Account.find_by(id: conversation_data[:account_id]) 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) 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) 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 end
def perform 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 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) 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 "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed? 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_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title) audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels) process_audience(audience_labels)
campaign.completed!
end end
private private
@@ -5,10 +5,12 @@ class Twilio::OneoffSmsCampaignService
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off? raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
raise 'Completed Campaign' if campaign.completed? 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_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title) audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
process_audience(audience_labels) process_audience(audience_labels)
campaign.completed!
end end
private private
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
def perform def perform
validate_campaign! validate_campaign!
process_audience(extract_audience_labels) # marks campaign completed so that other jobs won't pick it up
campaign.completed! campaign.completed!
process_audience(extract_audience_labels)
end end
private private
@@ -90,8 +90,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end end
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions # 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') def phone_id_path
"#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}" "#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
end end
def business_account_path def business_account_path
@@ -116,11 +116,14 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def send_attachment_message(phone_number, message) def send_attachment_message(phone_number, message)
attachment = message.attachments.first attachment = message.attachments.first
normalize_opus_content_type(attachment)
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document' 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( response = HTTParty.post(
"#{phone_id_path('v24.0')}/messages", "#{phone_id_path}/messages",
headers: api_headers, headers: api_headers,
body: { body: {
:messaging_product => 'whatsapp', :messaging_product => 'whatsapp',
@@ -139,33 +142,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
response.parsed_response&.dig('error', 'message') response.parsed_response&.dig('error', 'message')
end 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) def template_body_parameters(template_info)
template_body = { template_body = {
name: template_info[:name], name: template_info[:name],
+1 -1
View File
@@ -1,6 +1,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="initial-scale=1"> <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"> <meta name="viewport" content="width=device-width, initial-scale=1">
<%= vite_client_tag %> <%= vite_client_tag %>
@@ -82,7 +82,6 @@ html.light {
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>', emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>', loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>', resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
submit: '<%= I18n.t('public_portal.search.submit') %>',
}, },
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>', isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
tocHeader: '<%= I18n.t('public_portal.toc_header') %>' tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
@@ -11,9 +11,9 @@
<%= I18n.t('public_portal.hero.sub_title') %> <%= I18n.t('public_portal.hero.sub_title') %>
</p> </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 id="search-wrap-hero" class="block w-full"></div>
</div> </form>
<% if popular_topics.any? %> <% if popular_topics.any? %>
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm"> <div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
@@ -55,7 +55,7 @@
<% portal.public_locale_codes.each do |code| %> <% portal.public_locale_codes.each do |code| %>
<% is_current = code == locale %> <% is_current = code == locale %>
<a href="/hc/<%= portal.slug %>/<%= code %>/" <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' %>"> 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="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> <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 -2
View File
@@ -19,9 +19,8 @@
help_url: https://chwt.app/hc/fb help_url: https://chwt.app/hc/fb
- name: conversation_unread_counts - name: conversation_unread_counts
display_name: Conversation Unread Counts display_name: Conversation Unread Counts
enabled: true enabled: false
chatwoot_internal: true chatwoot_internal: true
deprecated: true
- name: ip_lookup - name: ip_lookup
display_name: IP Lookup display_name: IP Lookup
enabled: false enabled: false
+3 -9
View File
@@ -81,6 +81,9 @@ en:
saml: saml:
feature_not_enabled: SAML feature not enabled for this account feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation 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_import:
data_type: data_type:
invalid: Invalid data type invalid: Invalid data type
@@ -427,13 +430,6 @@ en:
empty_placeholder: No results found. empty_placeholder: No results found.
loading_placeholder: Searching... loading_placeholder: Searching...
results_title: Search results 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' toc_header: 'On this page'
sidebar: sidebar:
help_center: Help Center help_center: Help Center
@@ -465,8 +461,6 @@ en:
others: others others: others
by: By by: By
no_articles: There are no articles here no_articles: There are no articles here
previous: Previous
next: Next
article_actions: article_actions:
label: Open in label: Open in
view_markdown: View as Markdown 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_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_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/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] resource :widget, only: [:show]
namespace :survey do namespace :survey do
@@ -55,7 +54,6 @@ Rails.application.routes.draw do
resource :contact_merge, only: [:create] resource :contact_merge, only: [:create]
end end
resource :bulk_actions, only: [:create] resource :bulk_actions, only: [:create]
resource :onboarding, only: [:update]
resources :agents, only: [:index, :create, :update, :destroy] do resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection post :bulk_create, on: :collection
end end
@@ -590,7 +588,6 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show' get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap' 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', 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/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', 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 get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
@@ -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
@@ -26,15 +26,13 @@ module Enterprise::Concerns::Article
# if using add the filter block to the below query # if using add the filter block to the below query
# .filter { |ae| ae.neighbor_distance <= distance_threshold } # .filter { |ae| ae.neighbor_distance <= distance_threshold }
limit = params.key?(:limit) ? params[:limit] : 5 article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
article_embeddings = ArticleEmbedding.where(article_id: filtered_article_ids) .limit(5)
.nearest_neighbors(:embedding, embedding, distance: 'cosine') .pluck(:article_id)
article_embeddings = article_embeddings.limit(limit) if limit.present?
article_ids = article_embeddings.pluck(:article_id)
# Fetch the articles by the IDs obtained from the nearest neighbors search # 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
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 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 FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
def self.configured? def self.configured?
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
def crawl_payload(url, webhook_url, crawl_limit) def crawl_payload(url, webhook_url, crawl_limit)
{ {
url: url, url: url,
maxDiscoveryDepth: 50, maxDepth: 50,
sitemap: 'include', ignoreSitemap: false,
limit: crawl_limit, limit: crawl_limit,
webhook: { url: webhook_url }, webhook: webhook_url,
scrapeOptions: scrape_options scrapeOptions: scrape_options
}.to_json }.to_json
end end
@@ -50,7 +50,6 @@ class Captain::Tools::FirecrawlService
def scrape_options def scrape_options
{ {
onlyMainContent: true, onlyMainContent: true,
maxAge: 0,
formats: ['markdown'], formats: ['markdown'],
excludeTags: FIRECRAWL_EXCLUDE_TAGS excludeTags: FIRECRAWL_EXCLUDE_TAGS
} }
@@ -19,15 +19,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
channel_voice channel_voice
].freeze ].freeze
BUSINESS_PLAN_FEATURES = %w[ BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
sla
custom_roles
csat_review_notes
conversation_required_attributes
advanced_assignment
custom_tools
companies
].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
+7 -6
View File
@@ -12,7 +12,7 @@
"start:test": "RAILS_ENV=test foreman start -f ./Procfile.test", "start:test": "RAILS_ENV=test foreman start -f ./Procfile.test",
"dev": "overmind start -f ./Procfile.dev", "dev": "overmind start -f ./Procfile.dev",
"ruby:prettier": "bundle exec rubocop -a", "ruby:prettier": "bundle exec rubocop -a",
"build:sdk": "vite build --config vite.lib.config.ts", "build:sdk": "BUILD_MODE=library vite build",
"prepare": "husky install", "prepare": "husky install",
"size": "size-limit", "size": "size-limit",
"story:dev": "histoire dev", "story:dev": "histoire dev",
@@ -40,7 +40,6 @@
"@formkit/vue": "^1.7.2", "@formkit/vue": "^1.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0", "@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0", "@highlightjs/vue-plugin": "^2.1.0",
"@hotwired/turbo-rails": "^8.0.13",
"@iconify-json/fluent": "^1.2.32", "@iconify-json/fluent": "^1.2.32",
"@iconify-json/material-symbols": "^1.2.10", "@iconify-json/material-symbols": "^1.2.10",
"@lk77/vue3-color": "^3.0.6", "@lk77/vue3-color": "^3.0.6",
@@ -53,7 +52,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/vue-table": "^8.20.5", "@tanstack/vue-table": "^8.20.5",
"@twilio/voice-sdk": "^2.12.4", "@twilio/voice-sdk": "^2.12.4",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.1.4",
"@vue/compiler-sfc": "^3.5.8", "@vue/compiler-sfc": "^3.5.8",
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.4",
@@ -93,6 +92,7 @@
"snakecase-keys": "^8.0.1", "snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2", "timezone-phone-codes": "^0.0.2",
"tinykeys": "^3.0.0", "tinykeys": "^3.0.0",
"turbolinks": "^5.2.0",
"urlpattern-polyfill": "^10.0.0", "urlpattern-polyfill": "^10.0.0",
"video.js": "7.21.1", "video.js": "7.21.1",
"videojs-record": "4.5.0", "videojs-record": "4.5.0",
@@ -146,8 +146,8 @@
"prosemirror-model": "^1.22.3", "prosemirror-model": "^1.22.3",
"size-limit": "^8.2.4", "size-limit": "^8.2.4",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"vite": "6.4.2", "vite": "^5.4.21",
"vite-plugin-ruby": "^5.2.1", "vite-plugin-ruby": "^5.0.0",
"vitest": "3.0.5" "vitest": "3.0.5"
}, },
"engines": { "engines": {
@@ -161,7 +161,8 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "6.4.2", "vite-node": "2.0.1",
"vite": "5.4.21",
"vitest": "3.0.5", "vitest": "3.0.5",
"minimatch@<4": "3.1.5", "minimatch@<4": "3.1.5",
"minimatch@>=9.0.0 <9.0.7": "9.0.9", "minimatch@>=9.0.0 <9.0.7": "9.0.9",
+195 -455
View File
File diff suppressed because it is too large Load Diff
@@ -140,45 +140,6 @@ describe Messages::Facebook::MessageBuilder do
end end
end end
[
{
source_id: 'm_fallback_test',
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
title: 'Shared link',
url: 'https://www.example.com/shared-link'
},
{
source_id: 'm_share_test',
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
title: 'Shared Facebook post',
url: 'https://www.facebook.com/example/posts/123'
}
].each do |message_data|
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
)
expect(Down).not_to receive(:download)
message_object = {
messaging: {
sender: { id: '3383290475046708' },
recipient: { id: facebook_channel.page_id },
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
}
}.to_json
message = Integrations::Facebook::MessageParser.new(message_object)
described_class.new(message, facebook_channel.inbox).perform
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
expect(attachment.file_type).to eq('fallback')
expect(attachment.fallback_title).to eq(message_data[:title])
expect(attachment.external_url).to eq(message_data[:url])
end
end
context 'when lock to single conversation' do context 'when lock to single conversation' do
subject(:mocked_message_builder) do subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
@@ -149,35 +149,6 @@ describe Messages::MessageBuilder do
end end
end end
context 'when is_voice_message is true' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/sample.ogg', 'audio/ogg')],
is_voice_message: true
})
end
it 'sets is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).to include('is_voice_message' => true)
end
end
context 'when is_voice_message is not provided' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/avatar.png', 'image/png')]
})
end
it 'does not set is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).not_to include('is_voice_message')
end
end
context 'when email channel messages' do context 'when email channel messages' do
let!(:channel_email) { create(:channel_email, account: account) } let!(:channel_email) { create(:channel_email, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) } let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
@@ -126,32 +126,47 @@ RSpec.describe 'Conversations API', type: :request do
Conversations::UnreadCounts::Store.clear_account!(account.id) Conversations::UnreadCounts::Store.clear_account!(account.id)
end end
it 'returns unread conversation counts scoped to the signed-in user' do context 'when conversation unread counts feature is enabled' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title]) before do
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title]) account.enable_features!(:conversation_unread_counts)
end
get "/api/v1/accounts/#{account.id}/conversations/unread_counts", it 'returns unread conversation counts scoped to the signed-in user' do
headers: agent.create_new_auth_token, create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
as: :json create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
expect(response).to have_http_status(:success) get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
expect(response.parsed_body['payload']).to eq( headers: agent.create_new_auth_token,
'inboxes' => { visible_inbox.id.to_s => 1 }, as: :json
'labels' => { label.id.to_s => 1 },
'teams' => {} expect(response).to have_http_status(:success)
) expect(response.parsed_body['payload']).to eq(
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
end end
it 'returns unread team conversation counts scoped to the signed-in user' do it 'returns forbidden when conversation unread counts feature is disabled' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts", get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
expect(response).to have_http_status(:success) expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1) expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
end end
end end
end end
@@ -833,6 +848,7 @@ RSpec.describe 'Conversations API', type: :request do
end end
it 'refreshes unread count cache when conversation is marked read' do it 'refreshes unread count cache when conversation is marked read' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.hour.ago) conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago) create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
Conversations::UnreadCounts::Builder.new(account).build_base! Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -920,6 +936,7 @@ RSpec.describe 'Conversations API', type: :request do
end end
it 'refreshes unread count cache when conversation is marked unread' do it 'refreshes unread count cache when conversation is marked unread' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now) conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
Conversations::UnreadCounts::Builder.new(account).build_base! Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
] ]
expect(params['scope']).to eq(expected_scope) expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"]) expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account # Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present expect(params['state']).to be_present
@@ -1,114 +0,0 @@
require 'rails_helper'
RSpec.describe 'Onboarding API', type: :request do
let(:account) { create(:account, domain: 'example.com') }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
context 'when unauthenticated' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent (non-admin)' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized and does not change the account' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Hijacked', website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.reload.name).not_to eq('Hijacked')
end
it 'does not create a help center portal' do
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'attacker.com' },
headers: agent.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when finalizing account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
it 'saves name and locale' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc', locale: 'fr' },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(account.reload.name).to eq('Acme Inc')
expect(account.locale).to eq('fr')
end
it 'merges custom_attributes' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
headers: admin.create_new_auth_token, as: :json
attrs = account.reload.custom_attributes
expect(attrs['website']).to eq('acme.com')
expect(attrs['industry']).to eq('tech')
expect(attrs['company_size']).to eq('10-50')
end
it 'clears onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
expect(arg_account.id).to eq(account.id)
expect(arg_user.id).to eq(admin.id)
end
expect(service).to have_received(:perform)
end
it 'does not create a help center portal when website is blank' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { name: 'Acme Inc' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
context 'when onboarding_step is not account_details' do
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
it 'does not clear onboarding_step' do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end
it 'does not create a help center portal' do
expect do
patch "/api/v1/accounts/#{account.id}/onboarding",
params: { website: 'acme.com' },
headers: admin.create_new_auth_token, as: :json
end.not_to change(account.portals, :count)
end
end
end
end
@@ -302,6 +302,16 @@ RSpec.describe 'Accounts API', type: :request do
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team') expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
end end
it 'clears onboarding step when current value is account_details' do
account.update(custom_attributes: { onboarding_step: 'account_details' })
patch "/api/v1/accounts/#{account.id}",
params: params,
headers: admin.create_new_auth_token,
as: :json
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
end
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
patch "/api/v1/accounts/#{account.id}", patch "/api/v1/accounts/#{account.id}",
params: params, params: params,
@@ -34,25 +34,6 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
expect(inbox.channel.imap_address).to eq 'outlook.office365.com' expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
end end
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
upn = 'testaccount@primary-domain.example'
mailbox = 'TestAccount@mailbox-domain.example'
response_body = {
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'),
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
}
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code, state: state }
channel = account.inboxes.last.channel
expect(channel.imap_login).to eq upn
expect(channel.email).to eq mailbox
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox inbox = create(:channel_email, account: account, email: email)&.inbox
expect(inbox.channel.provider_config).to eq({}) expect(inbox.channel.provider_config).to eq({})
@@ -1,26 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
subject(:contact_policy) { ContactPolicy }
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
let(:agent) { create(:user) }
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
permissions :export? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
permissions :import? do
context 'when agent has contact_manage permission' do
it { expect(contact_policy).to permit(agent_context, contact) }
end
end
end
@@ -53,13 +53,12 @@ RSpec.describe Captain::Tools::FirecrawlService do
let(:expected_payload) do let(:expected_payload) do
{ {
url: url, url: url,
maxDiscoveryDepth: 50, maxDepth: 50,
sitemap: 'include', ignoreSitemap: false,
limit: crawl_limit, limit: crawl_limit,
webhook: { url: webhook_url }, webhook: webhook_url,
scrapeOptions: { scrapeOptions: {
onlyMainContent: true, onlyMainContent: true,
maxAge: 0,
formats: ['markdown'], formats: ['markdown'],
excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
} }
@@ -75,7 +74,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call is successful' do context 'when the API call is successful' do
before do before do
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.with( .with(
body: expected_payload, body: expected_payload,
headers: expected_headers headers: expected_headers
@@ -86,7 +85,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'makes a POST request with correct parameters' do it 'makes a POST request with correct parameters' do
service.perform(url, webhook_url, crawl_limit) service.perform(url, webhook_url, crawl_limit)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with( .with(
body: expected_payload, body: expected_payload,
headers: expected_headers headers: expected_headers
@@ -96,7 +95,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'uses default crawl limit when not specified' do it 'uses default crawl limit when not specified' do
default_payload = expected_payload.gsub(crawl_limit.to_s, '10') default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.with( .with(
body: default_payload, body: default_payload,
headers: expected_headers headers: expected_headers
@@ -105,7 +104,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
service.perform(url, webhook_url) service.perform(url, webhook_url)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with( .with(
body: default_payload, body: default_payload,
headers: expected_headers headers: expected_headers
@@ -115,7 +114,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call fails' do context 'when the API call fails' do
before do before do
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.to_raise(StandardError.new('Connection failed')) .to_raise(StandardError.new('Connection failed'))
end end
@@ -127,14 +126,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API returns an error response' do context 'when the API returns an error response' do
before do before do
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl') stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.to_return(status: 422, body: '{"error": "Invalid URL"}') .to_return(status: 422, body: '{"error": "Invalid URL"}')
end end
it 'makes the request but does not raise an error' do it 'makes the request but does not raise an error' do
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl') expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with( .with(
body: expected_payload, body: expected_payload,
headers: expected_headers headers: expected_headers
+6 -6
View File
@@ -140,7 +140,7 @@ describe PortalHelper do
context 'when theme is not present' do context 'when theme is not present' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq( expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
'/hc/portal_slug/en?show_plain_layout=true' '/hc/portal_slug/en'
) )
end end
end end
@@ -148,7 +148,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq( expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
'/hc/portal_slug/en?show_plain_layout=true&theme=dark' '/hc/portal_slug/en?theme=dark'
) )
end end
end end
@@ -172,7 +172,7 @@ describe PortalHelper do
theme: nil, theme: nil,
is_plain_layout_enabled: true is_plain_layout_enabled: true
)).to eq( )).to eq(
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true' '/hc/portal_slug/en/categories/category_slug'
) )
end end
end end
@@ -186,7 +186,7 @@ describe PortalHelper do
theme: 'dark', theme: 'dark',
is_plain_layout_enabled: true is_plain_layout_enabled: true
)).to eq( )).to eq(
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true&theme=dark' '/hc/portal_slug/en/categories/category_slug?theme=dark'
) )
end end
end end
@@ -210,7 +210,7 @@ describe PortalHelper do
context 'when theme is not present' do context 'when theme is not present' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq( expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
'/hc/portal_slug/articles/article_slug?show_plain_layout=true' '/hc/portal_slug/articles/article_slug'
) )
end end
end end
@@ -218,7 +218,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq( expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
'/hc/portal_slug/articles/article_slug?show_plain_layout=true&theme=dark' '/hc/portal_slug/articles/article_slug?theme=dark'
) )
end end
end end
@@ -40,13 +40,5 @@ RSpec.describe TriggerScheduledItemsJob do
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
described_class.perform_now described_class.perform_now
end end
it 'does not trigger campaigns that are already processing' do
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
described_class.perform_now
end
end end
end end
@@ -237,6 +237,10 @@ describe ActionCableListener do
let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) } let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
account.enable_features!(:conversation_unread_counts)
end
it 'sends a lightweight refresh event to inbox agents and admins' do it 'sends a lightweight refresh event to inbox agents and admins' do
expect(conversation.inbox.reload.inbox_members.count).to eq(1) expect(conversation.inbox.reload.inbox_members.count).to eq(1)
@@ -261,6 +265,14 @@ describe ActionCableListener do
listener.conversation_unread_count_changed(event) listener.conversation_unread_count_changed(event)
end end
it 'does not broadcast when conversation unread counts feature is disabled' do
account.disable_features!(:conversation_unread_counts)
expect(ActionCableBroadcastJob).not_to receive(:perform_later)
listener.conversation_unread_count_changed(event)
end
it 'supports deleted conversation data' do it 'supports deleted conversation data' do
event = Events::Base.new( event = Events::Base.new(
event_name, event_name,
+38
View File
@@ -50,6 +50,44 @@ RSpec.describe Account do
end end
end end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
end
it 'clears unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
it 'clears unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
end
end
describe 'inbound_email_domain' do describe 'inbound_email_domain' do
let(:account) { create(:account) } let(:account) { create(:account) }
-9
View File
@@ -20,15 +20,6 @@ RSpec.describe Article do
expect(article).not_to be_valid expect(article).not_to be_valid
expect(article.errors[:content]).to include("can't be blank") expect(article.errors[:content]).to include("can't be blank")
end end
it 'rejects reserved slugs that collide with help center routes' do
Article::RESERVED_SLUGS.each do |reserved_slug|
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
title: reserved_slug, slug: reserved_slug, content: 'content')
expect(article).not_to be_valid
expect(article.errors[:slug]).to include('is reserved')
end
end
end end
describe 'associations' do describe 'associations' do
-48
View File
@@ -83,38 +83,6 @@ RSpec.describe Campaign do
campaign.save! campaign.save!
campaign.trigger! campaign.trigger!
end end
it 'marks the campaign as processing before triggering the service' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform) do
expect(campaign.reload.processing?).to be true
end
campaign.trigger!
end
it 'does not trigger a processing campaign again' do
campaign.save!
campaign.processing!
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
campaign.trigger!
end
it 'keeps the campaign processing when triggering fails' do
campaign.save!
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
expect(campaign.reload.processing?).to be true
end
end end
context 'when SMS campaign' do context 'when SMS campaign' do
@@ -139,22 +107,6 @@ RSpec.describe Campaign do
end end
end end
context 'when WhatsApp campaign feature is disabled' do
let(:account) { create(:account) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
end
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
it 'does not mark the campaign as processing' do
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
campaign.trigger!
expect(campaign.reload.active?).to be true
end
end
context 'when Website campaign' do context 'when Website campaign' do
let(:campaign) { build(:campaign) } let(:campaign) { build(:campaign) }
@@ -11,6 +11,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end end
it 'refreshes unread counts when an incoming message is created' do it 'refreshes unread counts when an incoming message is created' do
account.enable_features!(:conversation_unread_counts)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming) message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message) event = Events::Base.new('message.created', Time.zone.now, message: message)
@@ -29,6 +30,17 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new) expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end end
it 'ignores incoming message creation when conversation unread counts are disabled' do
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
expect(message).not_to receive(:conversation)
listener.message_created(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'refreshes unread counts when conversation status changes' do it 'refreshes unread counts when conversation status changes' do
changed_attributes = { 'status' => %w[open resolved] } changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes) event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -78,6 +90,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end end
it 'removes unread count memberships when a conversation is deleted' do it 'removes unread count memberships when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts)
label = create(:label, account: account) label = create(:label, account: account)
team = create(:team, account: account) team = create(:team, account: account)
assignee = create(:user, account: account) assignee = create(:user, account: account)
@@ -6,6 +6,7 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
let(:refresh_result) { true } let(:refresh_result) { true }
before do before do
conversation.account.enable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher) allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher)
allow(Rails.configuration.dispatcher).to receive(:dispatch) allow(Rails.configuration.dispatcher).to receive(:dispatch)
end end
@@ -29,4 +30,19 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch) expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end end
end end
context 'when conversation unread counts feature is disabled' do
before do
conversation.account.disable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Store).to receive(:clear_account!)
end
it 'does not refresh, clear cache, or dispatch unread count changed event' do
described_class.new(conversation).perform
expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new)
expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
end end
@@ -45,19 +45,6 @@ describe Sms::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true expect(campaign.reload.completed?).to be true
end end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(sms_channel).to receive(:send_text_message) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account) contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title]) contact.update_labels([label1.title])
@@ -61,24 +61,6 @@ describe Twilio::OneoffSmsCampaignService do
expect(campaign.reload.completed?).to be true expect(campaign.reload.completed?).to be true
end end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
) do
expect(campaign.reload.completed?).to be false
end
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account) contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title]) contact.update_labels([label1.title])
@@ -82,19 +82,6 @@ describe Whatsapp::OneoffCampaignService do
expect(campaign.reload.completed?).to be true expect(campaign.reload.completed?).to be true
end end
it 'marks the campaign completed after processing the audience' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(whatsapp_channel).to receive(:send_template) do
expect(campaign.reload.completed?).to be false
end
described_class.new(campaign: campaign).perform
expect(campaign.reload.completed?).to be true
end
it 'processes contacts with matching labels' do it 'processes contacts with matching labels' do
contact_with_label1, contact_with_label2, contact_with_both_labels = contact_with_label1, contact_with_label2, contact_with_both_labels =
create_list(:contact, 3, :with_phone_number, account: account) create_list(:contact, 3, :with_phone_number, account: account)
@@ -60,7 +60,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages') stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with( .with(
body: hash_including({ body: hash_including({
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
# ref: https://github.com/bblimke/webmock/issues/900 # ref: https://github.com/bblimke/webmock/issues/900
# reason for Webmock::API.hash_including # reason for Webmock::API.hash_including
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages') stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
.with( .with(
body: hash_including({ body: hash_including({
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
@@ -91,41 +91,6 @@ describe Whatsapp::Providers::WhatsappCloudService do
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id' expect(service.send_message('+123456789', message)).to eq 'message_id'
end end
it 'calls message endpoints for audio voice message with voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_voice_message' => true })
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'voice.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio',
audio: WebMock::API.hash_including({ link: anything, voice: true })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for regular audio attachment without voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio)
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'audio.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio'
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
result = service.send_message('+123456789', message)
expect(result).to eq 'message_id'
end
end end
end end
-28
View File
@@ -145,34 +145,6 @@ inbox_create_payload:
$ref: ./request/inbox/create_payload.yml $ref: ./request/inbox/create_payload.yml
inbox_update_payload: inbox_update_payload:
$ref: ./request/inbox/update_payload.yml $ref: ./request/inbox/update_payload.yml
inbox_create_web_widget_channel_payload:
$ref: ./request/inbox/channels/create_web_widget_channel_payload.yml
inbox_create_api_channel_payload:
$ref: ./request/inbox/channels/create_api_channel_payload.yml
inbox_create_email_channel_payload:
$ref: ./request/inbox/channels/create_email_channel_payload.yml
inbox_create_line_channel_payload:
$ref: ./request/inbox/channels/create_line_channel_payload.yml
inbox_create_telegram_channel_payload:
$ref: ./request/inbox/channels/create_telegram_channel_payload.yml
inbox_create_whatsapp_channel_payload:
$ref: ./request/inbox/channels/create_whatsapp_channel_payload.yml
inbox_create_sms_channel_payload:
$ref: ./request/inbox/channels/create_sms_channel_payload.yml
inbox_update_web_widget_channel_payload:
$ref: ./request/inbox/channels/update_web_widget_channel_payload.yml
inbox_update_api_channel_payload:
$ref: ./request/inbox/channels/update_api_channel_payload.yml
inbox_update_email_channel_payload:
$ref: ./request/inbox/channels/update_email_channel_payload.yml
inbox_update_line_channel_payload:
$ref: ./request/inbox/channels/update_line_channel_payload.yml
inbox_update_telegram_channel_payload:
$ref: ./request/inbox/channels/update_telegram_channel_payload.yml
inbox_update_whatsapp_channel_payload:
$ref: ./request/inbox/channels/update_whatsapp_channel_payload.yml
inbox_update_sms_channel_payload:
$ref: ./request/inbox/channels/update_sms_channel_payload.yml
# Team # Team
team_create_update_payload: team_create_update_payload:
@@ -1,22 +0,0 @@
type: object
title: API channel
required:
- type
properties:
type:
type: string
enum: ['api']
example: api
webhook_url:
type: string
description: Webhook URL for API channel inbox callbacks
example: 'https://example.com/webhook'
hmac_mandatory:
type: boolean
description: Require HMAC verification for incoming API channel messages
example: false
additional_attributes:
type: object
description: Additional attributes stored on contacts created through the API channel
example:
source: mobile_app

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