Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8b65f86c9 |
+3
-5
@@ -996,13 +996,11 @@ GEM
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
vite_rails (3.10.0)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_rails (3.0.17)
|
||||
railties (>= 5.1, < 8)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.10.2)
|
||||
vite_ruby (3.8.0)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
rack-proxy (~> 0.6, >= 0.6.1)
|
||||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
|
||||
@@ -92,18 +92,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
def fallback_params(attachment)
|
||||
{
|
||||
fallback_title: attachment['title'],
|
||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||
external_url: attachment['url']
|
||||
}
|
||||
end
|
||||
|
||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||
def normalize_file_type(type)
|
||||
return :fallback if type.to_sym == :share
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
|
||||
@@ -13,7 +13,6 @@ class Messages::MessageBuilder
|
||||
@account = conversation.account
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@is_voice_message = ActiveModel::Type::Boolean.new.cast(params[:is_voice_message])
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@@ -57,25 +56,16 @@ class Messages::MessageBuilder
|
||||
file: uploaded_attachment
|
||||
)
|
||||
|
||||
attachment.file_type = attachment_file_type(uploaded_attachment)
|
||||
tag_voice_message(attachment)
|
||||
attachment.file_type = if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(
|
||||
uploaded_attachment
|
||||
)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_file_type(uploaded_attachment)
|
||||
if uploaded_attachment.is_a?(String)
|
||||
file_type_by_signed_id(uploaded_attachment)
|
||||
else
|
||||
file_type(uploaded_attachment&.content_type)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_voice_message(attachment)
|
||||
return unless @is_voice_message && attachment.file_type == 'audio'
|
||||
|
||||
attachment.meta = (attachment.meta || {}).merge('is_voice_message' => true)
|
||||
end
|
||||
|
||||
def process_emails
|
||||
return unless @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accounts::BaseController
|
||||
before_action :ensure_unread_counts_enabled
|
||||
|
||||
def index
|
||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||
render json: { payload: counts }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_unread_counts_enabled
|
||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
render json: { error: I18n.t('errors.conversations.unread_counts.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: generate_instagram_token(Current.account.id, params[:return_to])
|
||||
state: generate_instagram_token(Current.account.id)
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: scope,
|
||||
state: state,
|
||||
prompt: 'consent'
|
||||
state: state
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
|
||||
@@ -8,15 +8,7 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def state
|
||||
# The sgid purpose doubles as a return hint: onboarding tags it so the callback
|
||||
# can route the user back to inbox setup. The purpose is part of the signed
|
||||
# payload (tamper-proof), and a non-onboarding request keeps the default
|
||||
# purpose, leaving callers like Notion byte-identical.
|
||||
Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s
|
||||
end
|
||||
|
||||
def state_purpose
|
||||
params[:return_to] == 'onboarding' ? 'onboarding' : 'default'
|
||||
Current.account.to_sgid(expires_in: 15.minutes).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
|
||||
def update
|
||||
@account = Current.account
|
||||
finalize = finalizing_account_details?
|
||||
|
||||
@account.assign_attributes(account_params)
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if finalize
|
||||
@account.save!
|
||||
|
||||
# TODO: re-enable when the help center generation UI is ready to surface progress
|
||||
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
|
||||
|
||||
render 'api/v1/accounts/update', format: :json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finalizing_account_details?
|
||||
@account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
end
|
||||
|
||||
def website
|
||||
custom_attributes_params[:website]
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:name, :locale)
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O
|
||||
|
||||
def create
|
||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||
state: generate_tiktok_token(Current.account.id, params[:return_to])
|
||||
state: generate_tiktok_token(Current.account.id)
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
|
||||
@@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
|
||||
@@ -28,8 +28,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -151,10 +149,6 @@ class Instagram::CallbacksController < ApplicationController
|
||||
verify_instagram_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
instagram_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
@@ -14,11 +14,4 @@ class Microsoft::CallbacksController < OauthCallbackController
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
end
|
||||
|
||||
# Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field;
|
||||
# it must match the token's UPN. `preferred_username` is the documented v2.0 claim;
|
||||
# `upn` is the v1.0 fallback.
|
||||
def imap_login_identity
|
||||
users_data['preferred_username'] || users_data['upn'] || super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,8 +16,6 @@ class OauthCallbackController < ApplicationController
|
||||
def handle_response
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -46,7 +44,7 @@ class OauthCallbackController < ApplicationController
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: imap_login_identity, imap_address: imap_address,
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: provider_name,
|
||||
provider_config: {
|
||||
@@ -57,13 +55,6 @@ class OauthCallbackController < ApplicationController
|
||||
})
|
||||
end
|
||||
|
||||
# Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the
|
||||
# id_token's email claim; providers override when their server requires a different
|
||||
# claim (e.g. Microsoft SMTP requires UPN).
|
||||
def imap_login_identity
|
||||
users_data['email']
|
||||
end
|
||||
|
||||
def provider_name
|
||||
raise NotImplementedError
|
||||
end
|
||||
@@ -90,19 +81,10 @@ class OauthCallbackController < ApplicationController
|
||||
decoded_token[0]
|
||||
end
|
||||
|
||||
# The sgid purpose carries the onboarding return hint (see
|
||||
# OauthAuthorizationController#state). Try the onboarding purpose first — a match
|
||||
# both resolves the account and records the return target — then fall back to the
|
||||
# default purpose used by every other caller.
|
||||
def account_from_signed_id
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding'))
|
||||
@return_to = 'onboarding'
|
||||
else
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
end
|
||||
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
raise 'Invalid or expired state' if account.nil?
|
||||
|
||||
account
|
||||
@@ -112,11 +94,6 @@ class OauthCallbackController < ApplicationController
|
||||
@account ||= account_from_signed_id
|
||||
end
|
||||
|
||||
def return_to
|
||||
account # resolving the sgid records which purpose matched
|
||||
@return_to
|
||||
end
|
||||
|
||||
# Fallback name, for when name field is missing from users_data
|
||||
def fallback_name
|
||||
users_data['email'].split('@').first.parameterize.titleize
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:index]
|
||||
before_action :portal
|
||||
before_action :set_portal_layout
|
||||
before_action :set_view_variant
|
||||
before_action :ensure_portal_feature_enabled
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@query = params[:query].to_s.strip
|
||||
@articles = @portal.articles.published.includes(:category).where(locale: params[:locale])
|
||||
|
||||
search_articles
|
||||
|
||||
@articles = @articles.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_articles
|
||||
@articles = @query.present? ? @articles.search(search_params) : @articles.none
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.permit(:query, :locale, :sort, :status, :page).tap do |permitted|
|
||||
permitted[:query] = @query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Public::Api::V1::Portals::SearchController.prepend_mod_with('Public::Api::V1::Portals::SearchController')
|
||||
@@ -20,8 +20,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
def process_successful_authorization
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding'
|
||||
|
||||
if already_exists
|
||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
@@ -129,10 +127,6 @@ class Tiktok::CallbacksController < ApplicationController
|
||||
@account_id ||= verify_tiktok_token(params[:state])
|
||||
end
|
||||
|
||||
def return_to
|
||||
tiktok_token_return_to(params[:state])
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
@@ -4,21 +4,21 @@ module Instagram::IntegrationHelper
|
||||
# Generates a signed JWT token for Instagram integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @param return_to [String, nil] Optional onboarding return hint
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_instagram_token(account_id, return_to = nil)
|
||||
def generate_instagram_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Instagram JWT token
|
||||
@@ -28,14 +28,7 @@ module Instagram::IntegrationHelper
|
||||
def verify_instagram_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
end
|
||||
|
||||
# Reads the onboarding return hint from a Instagram JWT token, if present.
|
||||
def instagram_token_return_to(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('return_to')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -48,7 +41,7 @@ module Instagram::IntegrationHelper
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -45,16 +45,9 @@ module PortalHelper
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
end
|
||||
|
||||
def portal_query_string(theme, is_plain_layout_enabled)
|
||||
query_params = {}
|
||||
query_params[:theme] = theme if theme.present? && theme != 'system'
|
||||
query_params[:show_plain_layout] = true if is_plain_layout_enabled
|
||||
query_params.present? ? "?#{query_params.to_query}" : ''
|
||||
end
|
||||
|
||||
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{portal_locale}"
|
||||
end
|
||||
@@ -68,7 +61,7 @@ module PortalHelper
|
||||
is_plain_layout_enabled = params[:is_plain_layout_enabled]
|
||||
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
|
||||
end
|
||||
@@ -76,7 +69,7 @@ module PortalHelper
|
||||
|
||||
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{portal_query_string(theme, is_plain_layout_enabled)}"
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}"
|
||||
end
|
||||
|
||||
@@ -2,12 +2,11 @@ module Tiktok::IntegrationHelper
|
||||
# Generates a signed JWT token for Tiktok integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @param return_to [String, nil] Optional onboarding return hint
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_tiktok_token(account_id, return_to = nil)
|
||||
def generate_tiktok_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256')
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||
nil
|
||||
@@ -20,14 +19,7 @@ module Tiktok::IntegrationHelper
|
||||
def verify_tiktok_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('sub')
|
||||
end
|
||||
|
||||
# Reads the onboarding return hint from a Tiktok JWT token, if present.
|
||||
def tiktok_token_return_to(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)&.dig('return_to')
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,17 +28,18 @@ module Tiktok::IntegrationHelper
|
||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def token_payload(account_id, return_to = nil)
|
||||
payload = { sub: account_id, iat: Time.current.to_i }
|
||||
payload[:return_to] = return_to if return_to.present?
|
||||
payload
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||
nil
|
||||
|
||||
@@ -12,7 +12,6 @@ export const buildCreatePayload = ({
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) => {
|
||||
let payload;
|
||||
if (files && files.length !== 0) {
|
||||
@@ -34,9 +33,6 @@ export const buildCreatePayload = ({
|
||||
if (contentAttributes) {
|
||||
payload.append('content_attributes', JSON.stringify(contentAttributes));
|
||||
}
|
||||
if (isVoiceMessage) {
|
||||
payload.append('is_voice_message', true);
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
content: message,
|
||||
@@ -68,7 +64,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails = '',
|
||||
toEmails = '',
|
||||
templateParams,
|
||||
isVoiceMessage = false,
|
||||
}) {
|
||||
return axios({
|
||||
method: 'post',
|
||||
@@ -83,7 +78,6 @@ class MessageApi extends ApiClient {
|
||||
bccEmails,
|
||||
toEmails,
|
||||
templateParams,
|
||||
isVoiceMessage,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class OnboardingAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('onboarding', { accountScoped: true });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.patch(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new OnboardingAPI();
|
||||
@@ -83,29 +83,5 @@ describe('#ConversationAPI', () => {
|
||||
template_params: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends is_voice_message when isVoiceMessage is true', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'voice message',
|
||||
echoId: 42,
|
||||
isPrivate: false,
|
||||
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
|
||||
isVoiceMessage: true,
|
||||
});
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('is_voice_message')).toEqual('true');
|
||||
});
|
||||
|
||||
it('does not append is_voice_message when isVoiceMessage is false', () => {
|
||||
const formPayload = buildCreatePayload({
|
||||
message: 'regular audio',
|
||||
echoId: 43,
|
||||
isPrivate: false,
|
||||
files: [new Blob(['audio-data'], { type: 'audio/ogg' })],
|
||||
isVoiceMessage: false,
|
||||
});
|
||||
expect(formPayload).toBeInstanceOf(FormData);
|
||||
expect(formPayload.get('is_voice_message')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ const emit = defineEmits(['edit', 'delete']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
@@ -69,15 +68,9 @@ const campaignStatus = computed(() => {
|
||||
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_COMPLETED) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED');
|
||||
}
|
||||
|
||||
if (props.status === STATUS_PROCESSING) {
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.PROCESSING');
|
||||
}
|
||||
|
||||
return t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
return props.status === STATUS_COMPLETED
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
});
|
||||
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
+15
-29
@@ -1,48 +1,34 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
const emit = defineEmits(['add', 'import', 'export']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const contactMenuItems = computed(() => [
|
||||
const contactMenuItems = [
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.ADD_CONTACT'),
|
||||
action: 'add',
|
||||
value: 'add',
|
||||
icon: 'i-lucide-plus',
|
||||
},
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'
|
||||
),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(checkPermissions(['administrator', 'contact_manage'])
|
||||
? [
|
||||
{
|
||||
label: t(
|
||||
'CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'
|
||||
),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.EXPORT_CONTACT'),
|
||||
action: 'export',
|
||||
value: 'export',
|
||||
icon: 'i-lucide-upload',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.IMPORT_CONTACT'),
|
||||
action: 'import',
|
||||
value: 'import',
|
||||
icon: 'i-lucide-download',
|
||||
},
|
||||
];
|
||||
const showActionsDropdown = ref(false);
|
||||
|
||||
const handleContactAction = ({ action }) => {
|
||||
|
||||
@@ -31,7 +31,6 @@ import FileBubble from './bubbles/File.vue';
|
||||
import AudioBubble from './bubbles/Audio.vue';
|
||||
import VideoBubble from './bubbles/Video.vue';
|
||||
import EmbedBubble from './bubbles/Embed.vue';
|
||||
import FallbackBubble from './bubbles/Fallback.vue';
|
||||
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
|
||||
import EmailBubble from './bubbles/Email/Index.vue';
|
||||
import UnsupportedBubble from './bubbles/Unsupported.vue';
|
||||
@@ -329,8 +328,6 @@ const componentToRender = computed(() => {
|
||||
if (Array.isArray(props.attachments) && props.attachments.length === 1) {
|
||||
const fileType = props.attachments[0].fileType;
|
||||
|
||||
if (fileType === ATTACHMENT_TYPES.FALLBACK) return FallbackBubble;
|
||||
|
||||
if (!props.content) {
|
||||
if (fileType === ATTACHMENT_TYPES.IMAGE) return ImageBubble;
|
||||
if (fileType === ATTACHMENT_TYPES.FILE) return FileBubble;
|
||||
|
||||
@@ -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 (!isEnabled) {
|
||||
store.dispatch('conversationUnreadCounts/clear');
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
@@ -188,7 +200,7 @@ onMounted(() => {
|
||||
store.dispatch('customViews/get', 'contact');
|
||||
});
|
||||
|
||||
watch(accountId, fetchConversationUnreadCounts, {
|
||||
watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ const props = defineProps({
|
||||
const emit = defineEmits(['removeAttachment']);
|
||||
|
||||
const nonRecordedAudioAttachments = computed(() => {
|
||||
return props.attachments.filter(attachment => !attachment?.isVoiceMessage);
|
||||
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
|
||||
});
|
||||
|
||||
const recordedAudioAttachments = computed(() =>
|
||||
props.attachments.filter(attachment => attachment.isVoiceMessage)
|
||||
props.attachments.filter(attachment => attachment.isRecordedAudio)
|
||||
);
|
||||
|
||||
const onRemoveAttachment = itemIndex => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
|
||||
import { format, intervalToDuration } from 'date-fns';
|
||||
import { convertAudio } from './utils/audioConversionUtils';
|
||||
import { convertAudio } from './utils/mp3ConversionUtils';
|
||||
|
||||
const props = defineProps({
|
||||
audioRecordFormat: {
|
||||
@@ -18,7 +18,6 @@ const emit = defineEmits([
|
||||
'finishRecord',
|
||||
'pause',
|
||||
'play',
|
||||
'recordError',
|
||||
]);
|
||||
|
||||
const waveformContainer = ref(null);
|
||||
@@ -27,7 +26,6 @@ const record = ref(null);
|
||||
const isRecording = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const hasRecording = ref(false);
|
||||
const recordedAudioUrl = ref(null);
|
||||
|
||||
const formatTimeProgress = time => {
|
||||
const duration = intervalToDuration({ start: 0, end: time });
|
||||
@@ -37,28 +35,6 @@ const formatTimeProgress = time => {
|
||||
);
|
||||
};
|
||||
|
||||
const AUDIO_EXTENSION_MAP = {
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/mp3': 'mp3',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/wav': 'wav',
|
||||
'audio/webm': 'webm',
|
||||
};
|
||||
|
||||
const getRecordPluginOptions = audioFormat => {
|
||||
const options = {
|
||||
scrollingWaveform: true,
|
||||
renderRecordedAudio: false,
|
||||
};
|
||||
if (
|
||||
audioFormat === 'audio/ogg' &&
|
||||
MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
||||
) {
|
||||
options.mimeType = 'audio/ogg;codecs=opus';
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
const initWaveSurfer = () => {
|
||||
wavesurfer.value = WaveSurfer.create({
|
||||
container: waveformContainer.value,
|
||||
@@ -69,7 +45,10 @@ const initWaveSurfer = () => {
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
plugins: [
|
||||
RecordPlugin.create(getRecordPluginOptions(props.audioRecordFormat)),
|
||||
RecordPlugin.create({
|
||||
scrollingWaveform: true,
|
||||
renderRecordedAudio: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -83,34 +62,21 @@ const initWaveSurfer = () => {
|
||||
});
|
||||
|
||||
record.value.on('record-end', async blob => {
|
||||
try {
|
||||
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
|
||||
// Use the converted blob's actual type, which may differ from the
|
||||
// requested format when the browser can't produce it (e.g. Safari falls
|
||||
// back to MP3 instead of OGG). This keeps the filename, content type, and
|
||||
// voice-note flag consistent with the real bytes.
|
||||
const audioType = audioBlob.type || props.audioRecordFormat;
|
||||
const ext = AUDIO_EXTENSION_MAP[audioType] || 'mp3';
|
||||
const fileName = `${getUuid()}.${ext}`;
|
||||
const file = new File([audioBlob], fileName, {
|
||||
type: audioType,
|
||||
});
|
||||
if (recordedAudioUrl.value) URL.revokeObjectURL(recordedAudioUrl.value);
|
||||
recordedAudioUrl.value = URL.createObjectURL(audioBlob);
|
||||
wavesurfer.value.load(recordedAudioUrl.value);
|
||||
emit('finishRecord', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
hasRecording.value = true;
|
||||
isRecording.value = false;
|
||||
} catch (error) {
|
||||
isRecording.value = false;
|
||||
hasRecording.value = false;
|
||||
emit('recordError', { error });
|
||||
}
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
|
||||
const fileName = `${getUuid()}.mp3`;
|
||||
const file = new File([audioBlob], fileName, {
|
||||
type: props.audioRecordFormat,
|
||||
});
|
||||
wavesurfer.value.load(audioUrl);
|
||||
emit('finishRecord', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
hasRecording.value = true;
|
||||
isRecording.value = false;
|
||||
});
|
||||
|
||||
record.value.on('record-progress', time => {
|
||||
@@ -143,10 +109,6 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (recordedAudioUrl.value) {
|
||||
URL.revokeObjectURL(recordedAudioUrl.value);
|
||||
recordedAudioUrl.value = null;
|
||||
}
|
||||
if (wavesurfer.value) {
|
||||
wavesurfer.value.destroy();
|
||||
}
|
||||
|
||||
-16
@@ -1,7 +1,5 @@
|
||||
import lamejs from '@breezystack/lamejs';
|
||||
|
||||
import { remuxWebmToOgg } from './webmOpusToOgg';
|
||||
|
||||
const writeString = (view, offset, string) => {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
@@ -141,20 +139,6 @@ export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
|
||||
audio = await convertToWav(inputBlob);
|
||||
} else if (outputFormat === 'audio/mp3') {
|
||||
audio = await convertToMp3(inputBlob, bitrate);
|
||||
} else if (outputFormat === 'audio/ogg') {
|
||||
const inputType = inputBlob.type.split(';')[0].trim();
|
||||
if (inputType === 'audio/webm' || inputType === 'video/webm') {
|
||||
audio = await remuxWebmToOgg(inputBlob);
|
||||
} else if (inputType === 'audio/ogg') {
|
||||
audio = inputBlob;
|
||||
} else {
|
||||
// Browsers that record neither WebM nor OGG (e.g. Safari records
|
||||
// audio/mp4) cannot produce OGG/Opus. Fall back to MP3 so the recording
|
||||
// still sends as a regular audio message instead of failing. The caller
|
||||
// keys the voice-note flag off the returned blob type, so an MP3 result
|
||||
// is never mislabeled as an OGG/Opus voice note.
|
||||
audio = await convertToMp3(inputBlob, bitrate);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unsupported output format');
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
/**
|
||||
* WebM/Opus → OGG/Opus remuxer
|
||||
*
|
||||
* Chrome's MediaRecorder produces WebM containers even when
|
||||
* `audio/ogg;codecs=opus` is requested. WhatsApp Cloud API requires
|
||||
* proper OGG/Opus files for voice messages.
|
||||
*
|
||||
* This module extracts raw Opus packets from the WebM (EBML) container
|
||||
* and repackages them into a valid OGG bitstream. The audio data itself
|
||||
* is never re-encoded — only the container format changes.
|
||||
*
|
||||
* References:
|
||||
* EBML (container for WebM): RFC 8794 — https://www.rfc-editor.org/rfc/rfc8794
|
||||
* Matroska/WebM elements: https://www.matroska.org/technical/elements.html
|
||||
* OGG bitstream framing: RFC 3533 — https://www.rfc-editor.org/rfc/rfc3533
|
||||
* Opus codec: RFC 6716 — https://www.rfc-editor.org/rfc/rfc6716
|
||||
* Opus in OGG (OpusHead/Tags): RFC 7845 — https://www.rfc-editor.org/rfc/rfc7845
|
||||
*/
|
||||
|
||||
// ======================== EBML / WebM parser ========================
|
||||
|
||||
const EBML_IDS = {
|
||||
Segment: 0x18538067,
|
||||
SegmentInfo: 0x1549a966,
|
||||
Tracks: 0x1654ae6b,
|
||||
TrackEntry: 0xae,
|
||||
CodecPrivate: 0x63a2,
|
||||
Audio: 0xe1,
|
||||
SamplingFrequency: 0xb5,
|
||||
Channels: 0x9f,
|
||||
Cluster: 0x1f43b675,
|
||||
Timecode: 0xe7,
|
||||
SimpleBlock: 0xa3,
|
||||
BlockGroup: 0xa0,
|
||||
Block: 0xa1,
|
||||
};
|
||||
|
||||
const MASTER_ELEMENTS = new Set([
|
||||
0x1a45dfa3, // EBML header
|
||||
EBML_IDS.Segment,
|
||||
EBML_IDS.SegmentInfo,
|
||||
EBML_IDS.Tracks,
|
||||
EBML_IDS.TrackEntry,
|
||||
EBML_IDS.Audio,
|
||||
EBML_IDS.Cluster,
|
||||
EBML_IDS.BlockGroup,
|
||||
]);
|
||||
|
||||
/** Read an EBML variable-length integer (data size). */
|
||||
function readVint(data, pos) {
|
||||
if (pos >= data.length) return null;
|
||||
const first = data[pos];
|
||||
if (first === 0) return null;
|
||||
|
||||
let len = 1;
|
||||
let mask = 0x80;
|
||||
while (len <= 8 && !(first & mask)) {
|
||||
len += 1;
|
||||
mask >>= 1;
|
||||
}
|
||||
if (len > 8 || pos + len > data.length) return null;
|
||||
|
||||
let value = first & (mask - 1);
|
||||
for (let i = 1; i < len; i += 1) {
|
||||
value = value * 256 + data[pos + i];
|
||||
}
|
||||
return { value, length: len };
|
||||
}
|
||||
|
||||
/** Read an EBML element ID (leading marker bits are kept). */
|
||||
function readElementId(data, pos) {
|
||||
if (pos >= data.length) return null;
|
||||
const first = data[pos];
|
||||
if (first === 0) return null;
|
||||
|
||||
let len = 1;
|
||||
let mask = 0x80;
|
||||
while (len <= 4 && !(first & mask)) {
|
||||
len += 1;
|
||||
mask >>= 1;
|
||||
}
|
||||
if (len > 4 || pos + len > data.length) return null;
|
||||
|
||||
let id = first;
|
||||
for (let i = 1; i < len; i += 1) {
|
||||
id = id * 256 + data[pos + i];
|
||||
}
|
||||
return { id, length: len };
|
||||
}
|
||||
|
||||
function readUintBE(data, offset, length) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < length; i += 1) v = v * 256 + data[offset + i];
|
||||
return v;
|
||||
}
|
||||
|
||||
function readFloatBE(data, offset, length) {
|
||||
if (length !== 4 && length !== 8) return NaN;
|
||||
const buf = new ArrayBuffer(length);
|
||||
const u8 = new Uint8Array(buf);
|
||||
for (let i = 0; i < length; i += 1) u8[i] = data[offset + i];
|
||||
const view = new DataView(buf);
|
||||
return length === 4 ? view.getFloat32(0) : view.getFloat64(0);
|
||||
}
|
||||
|
||||
/** Extract the raw Opus frame from a SimpleBlock / Block element. */
|
||||
function extractFrameFromBlock(data, offset, end) {
|
||||
const trackVint = readVint(data, offset);
|
||||
if (!trackVint) return null;
|
||||
let pos = offset + trackVint.length;
|
||||
|
||||
// int16 relative timecode (big-endian, signed) – skip
|
||||
pos += 2;
|
||||
// Flags byte – skip. Lacing (Xiph/EBML/fixed-size) is NOT supported;
|
||||
// this assumes single-frame blocks as produced by MediaRecorder.
|
||||
const flags = data[pos];
|
||||
const lacingBits = (flags >> 1) & 0x03;
|
||||
if (lacingBits !== 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'webmOpusToOgg: laced SimpleBlock detected (unsupported), frame may be invalid'
|
||||
);
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
if (pos >= end) return null;
|
||||
return data.slice(pos, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the EBML tree and collect metadata + Opus frames.
|
||||
* We only descend into master elements and only extract the fields we need.
|
||||
*/
|
||||
function parseWebM(buffer) {
|
||||
const data = new Uint8Array(buffer);
|
||||
const result = {
|
||||
channels: 1,
|
||||
sampleRate: 48000,
|
||||
codecPrivate: null,
|
||||
frames: [],
|
||||
};
|
||||
|
||||
function walk(start, end) {
|
||||
let pos = start;
|
||||
while (pos < end) {
|
||||
const idRes = readElementId(data, pos);
|
||||
if (!idRes) break;
|
||||
pos += idRes.length;
|
||||
|
||||
const sizeRes = readVint(data, pos);
|
||||
if (!sizeRes) break;
|
||||
pos += sizeRes.length;
|
||||
|
||||
// Handle "unknown size" (all-ones VINT) by treating it as the rest of the parent
|
||||
// Use Math.pow instead of bit-shift to avoid 32-bit overflow for 5+ byte VINTs
|
||||
const maxVint = 2 ** (7 * sizeRes.length) - 1;
|
||||
const elEnd =
|
||||
sizeRes.value === maxVint ? end : Math.min(pos + sizeRes.value, end);
|
||||
|
||||
if (MASTER_ELEMENTS.has(idRes.id)) {
|
||||
walk(pos, elEnd);
|
||||
} else {
|
||||
switch (idRes.id) {
|
||||
case EBML_IDS.Channels:
|
||||
result.channels = readUintBE(data, pos, sizeRes.value);
|
||||
break;
|
||||
case EBML_IDS.SamplingFrequency:
|
||||
result.sampleRate = readFloatBE(data, pos, sizeRes.value);
|
||||
break;
|
||||
case EBML_IDS.CodecPrivate:
|
||||
result.codecPrivate = data.slice(pos, elEnd);
|
||||
break;
|
||||
case EBML_IDS.SimpleBlock:
|
||||
case EBML_IDS.Block: {
|
||||
const frame = extractFrameFromBlock(data, pos, elEnd);
|
||||
if (frame && frame.length > 0) result.frames.push(frame);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos = elEnd;
|
||||
}
|
||||
}
|
||||
|
||||
walk(0, data.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ======================== OGG writer ========================
|
||||
|
||||
/** OGG CRC-32 table (polynomial 0x04C11DB7). */
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i << 24;
|
||||
for (let j = 0; j < 8; j += 1) {
|
||||
c = ((c << 1) ^ (c & 0x80000000 ? 0x04c11db7 : 0)) >>> 0;
|
||||
}
|
||||
t[i] = c;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
function oggCrc32(bytes) {
|
||||
let crc = 0;
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
crc = (CRC_TABLE[((crc >>> 24) ^ bytes[i]) & 0xff] ^ (crc << 8)) >>> 0;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one OGG page.
|
||||
*
|
||||
* @param {number} headerType 0x02 = BOS, 0x04 = EOS, 0x00 = normal
|
||||
* @param {number} granulePosition 48 kHz sample count
|
||||
* @param {number} serialNumber logical stream id
|
||||
* @param {number} pageSeq page sequence counter
|
||||
* @param {Uint8Array[]} packets one or more complete Opus packets
|
||||
*/
|
||||
function createOggPage(
|
||||
headerType,
|
||||
granulePosition,
|
||||
serialNumber,
|
||||
pageSeq,
|
||||
packets
|
||||
) {
|
||||
// Build the lacing / segment table
|
||||
const segTable = [];
|
||||
let dataLen = 0;
|
||||
packets.forEach(pkt => {
|
||||
let rem = pkt.length;
|
||||
while (rem >= 255) {
|
||||
segTable.push(255);
|
||||
rem -= 255;
|
||||
}
|
||||
segTable.push(rem); // final segment (0 when pkt.length is a multiple of 255)
|
||||
dataLen += pkt.length;
|
||||
});
|
||||
|
||||
const hdrLen = 27 + segTable.length;
|
||||
const page = new Uint8Array(hdrLen + dataLen);
|
||||
const dv = new DataView(page.buffer);
|
||||
|
||||
// Capture pattern
|
||||
page.set([0x4f, 0x67, 0x67, 0x53]); // "OggS"
|
||||
page[4] = 0; // version
|
||||
page[5] = headerType;
|
||||
|
||||
// Granule position (int64 LE)
|
||||
dv.setUint32(6, granulePosition & 0xffffffff, true);
|
||||
dv.setUint32(
|
||||
10,
|
||||
Math.floor(granulePosition / 0x100000000) & 0xffffffff,
|
||||
true
|
||||
);
|
||||
|
||||
dv.setUint32(14, serialNumber, true); // serial
|
||||
dv.setUint32(18, pageSeq, true); // page sequence
|
||||
dv.setUint32(22, 0, true); // CRC placeholder
|
||||
|
||||
page[26] = segTable.length;
|
||||
for (let i = 0; i < segTable.length; i += 1) page[27 + i] = segTable[i];
|
||||
|
||||
let off = hdrLen;
|
||||
packets.forEach(pkt => {
|
||||
page.set(pkt, off);
|
||||
off += pkt.length;
|
||||
});
|
||||
|
||||
// Fill in the CRC
|
||||
dv.setUint32(22, oggCrc32(page), true);
|
||||
return page;
|
||||
}
|
||||
|
||||
// ======================== Opus helpers ========================
|
||||
|
||||
/** Lookup table: frame duration in ms for each Opus TOC config index (0-31). */
|
||||
const OPUS_FRAME_MS = [
|
||||
10,
|
||||
20,
|
||||
40,
|
||||
60, // 0-3 SILK NB
|
||||
10,
|
||||
20,
|
||||
40,
|
||||
60, // 4-7 SILK MB
|
||||
10,
|
||||
20,
|
||||
40,
|
||||
60, // 8-11 SILK WB
|
||||
10,
|
||||
20, // 12-13 Hybrid SWB
|
||||
10,
|
||||
20, // 14-15 Hybrid FB
|
||||
2.5,
|
||||
5,
|
||||
10,
|
||||
20, // 16-19 CELT NB
|
||||
2.5,
|
||||
5,
|
||||
10,
|
||||
20, // 20-23 CELT WB
|
||||
2.5,
|
||||
5,
|
||||
10,
|
||||
20, // 24-27 CELT SWB
|
||||
2.5,
|
||||
5,
|
||||
10,
|
||||
20, // 28-31 CELT FB
|
||||
];
|
||||
|
||||
/** Return the total number of 48 kHz PCM samples represented by an Opus packet. */
|
||||
function opusPacketSamples(pkt) {
|
||||
if (!pkt || pkt.length === 0) return 960; // default 20 ms
|
||||
const toc = pkt[0];
|
||||
const config = (toc >> 3) & 0x1f;
|
||||
const code = toc & 0x03;
|
||||
|
||||
const samplesPerFrame = ((OPUS_FRAME_MS[config] || 20) * 48000) / 1000;
|
||||
let frameCount;
|
||||
if (code <= 1) frameCount = code + 1;
|
||||
else if (code === 2) frameCount = 2;
|
||||
else frameCount = pkt.length >= 2 ? pkt[1] & 0x3f : 1;
|
||||
|
||||
return samplesPerFrame * frameCount;
|
||||
}
|
||||
|
||||
function buildOpusHead(channels, sampleRate, preSkip) {
|
||||
const buf = new Uint8Array(19);
|
||||
const dv = new DataView(buf.buffer);
|
||||
buf.set(new TextEncoder().encode('OpusHead'));
|
||||
buf[8] = 1; // version
|
||||
buf[9] = channels;
|
||||
dv.setUint16(10, preSkip, true);
|
||||
dv.setUint32(12, sampleRate, true);
|
||||
dv.setInt16(16, 0, true); // output gain
|
||||
buf[18] = 0; // channel mapping family
|
||||
return buf;
|
||||
}
|
||||
|
||||
function buildOpusTags() {
|
||||
const vendor = new TextEncoder().encode('chatwoot');
|
||||
const buf = new Uint8Array(8 + 4 + vendor.length + 4);
|
||||
const dv = new DataView(buf.buffer);
|
||||
buf.set(new TextEncoder().encode('OpusTags'));
|
||||
dv.setUint32(8, vendor.length, true);
|
||||
buf.set(vendor, 12);
|
||||
dv.setUint32(12 + vendor.length, 0, true); // 0 user comments
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ======================== Public API ========================
|
||||
|
||||
const MAX_FRAMES_PER_PAGE = 50; // ~1 s at 20 ms/frame
|
||||
const MAX_SEGMENTS_PER_PAGE = 255;
|
||||
|
||||
/**
|
||||
* Remux a WebM/Opus blob into an OGG/Opus blob.
|
||||
* If the input is already OGG (starts with "OggS"), it is returned as-is.
|
||||
*
|
||||
* @param {Blob} webmBlob
|
||||
* @returns {Promise<Blob>} OGG/Opus blob
|
||||
*/
|
||||
export async function remuxWebmToOgg(webmBlob) {
|
||||
const buffer = await webmBlob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
// Already OGG? Return unchanged.
|
||||
if (
|
||||
bytes.length >= 4 &&
|
||||
bytes[0] === 0x4f &&
|
||||
bytes[1] === 0x67 &&
|
||||
bytes[2] === 0x67 &&
|
||||
bytes[3] === 0x53
|
||||
) {
|
||||
return webmBlob;
|
||||
}
|
||||
|
||||
const { channels, sampleRate, codecPrivate, frames } = parseWebM(buffer);
|
||||
if (frames.length === 0) {
|
||||
throw new Error('No Opus frames found in WebM input');
|
||||
}
|
||||
|
||||
// Extract pre-skip from the WebM CodecPrivate (which IS the OpusHead)
|
||||
let preSkip = 312;
|
||||
if (codecPrivate && codecPrivate.length >= 12) {
|
||||
const magic = new TextDecoder().decode(codecPrivate.slice(0, 8));
|
||||
if (magic === 'OpusHead') {
|
||||
preSkip = new DataView(
|
||||
codecPrivate.buffer,
|
||||
codecPrivate.byteOffset,
|
||||
codecPrivate.length
|
||||
).getUint16(10, true);
|
||||
}
|
||||
}
|
||||
|
||||
const serial = (Math.random() * 0x100000000) >>> 0;
|
||||
let pageSeq = 0;
|
||||
const pages = [];
|
||||
|
||||
// Page 0 – OpusHead (BOS)
|
||||
pages.push(
|
||||
createOggPage(0x02, 0, serial, pageSeq, [
|
||||
buildOpusHead(channels, sampleRate, preSkip),
|
||||
])
|
||||
);
|
||||
pageSeq += 1;
|
||||
|
||||
// Page 1 – OpusTags
|
||||
pages.push(createOggPage(0x00, 0, serial, pageSeq, [buildOpusTags()]));
|
||||
pageSeq += 1;
|
||||
|
||||
// Audio pages
|
||||
let granule = 0;
|
||||
let idx = 0;
|
||||
|
||||
while (idx < frames.length) {
|
||||
const packets = [];
|
||||
let segs = 0;
|
||||
|
||||
while (idx < frames.length && packets.length < MAX_FRAMES_PER_PAGE) {
|
||||
const pkt = frames[idx];
|
||||
// createOggPage always appends a terminating lacing value, so a packet
|
||||
// spans floor(len/255)+1 segments (including the extra 0 when len is an
|
||||
// exact multiple of 255). Math.ceil would undercount those cases.
|
||||
const pktSegs = Math.floor(pkt.length / 255) + 1;
|
||||
if (segs + pktSegs > MAX_SEGMENTS_PER_PAGE && packets.length > 0) break;
|
||||
|
||||
packets.push(pkt);
|
||||
segs += pktSegs;
|
||||
granule += opusPacketSamples(pkt);
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
const isLast = idx >= frames.length;
|
||||
pages.push(
|
||||
createOggPage(isLast ? 0x04 : 0x00, granule, serial, pageSeq, packets)
|
||||
);
|
||||
pageSeq += 1;
|
||||
}
|
||||
|
||||
// Concatenate pages into a single buffer
|
||||
const total = pages.reduce((s, p) => s + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
pages.forEach(p => {
|
||||
out.set(p, off);
|
||||
off += p.length;
|
||||
});
|
||||
|
||||
return new Blob([out], { type: 'audio/ogg' });
|
||||
}
|
||||
@@ -375,10 +375,7 @@ export default {
|
||||
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
},
|
||||
audioRecordFormat() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return AUDIO_FORMATS.OGG;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
|
||||
return AUDIO_FORMATS.MP3;
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
@@ -1011,18 +1008,14 @@ export default {
|
||||
onFinishRecorder(file) {
|
||||
this.recordingAudioState = 'stopped';
|
||||
this.hasRecordedAudio = true;
|
||||
// Added a new key isVoiceMessage to the file to identify recorded audio
|
||||
// Added a new key isRecordedAudio to the file to find it's and recorded audio
|
||||
// Because to filter and show only non recorded audio and other attachments
|
||||
const autoRecordedFile = {
|
||||
...file,
|
||||
isVoiceMessage: true,
|
||||
isRecordedAudio: true,
|
||||
};
|
||||
return file && this.onFileUpload(autoRecordedFile);
|
||||
},
|
||||
onRecordError() {
|
||||
this.toggleAudioRecorder();
|
||||
useAlert(this.$t('CONVERSATION.REPLYBOX.AUDIO_CONVERSION_FAILED'));
|
||||
},
|
||||
toggleTyping(status) {
|
||||
const conversationId = this.currentChat.id;
|
||||
const isPrivate = this.isPrivate;
|
||||
@@ -1049,7 +1042,7 @@ export default {
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
blobSignedId: blob ? blob.signed_id : undefined,
|
||||
isVoiceMessage: file?.isVoiceMessage || false,
|
||||
isRecordedAudio: file?.isRecordedAudio || false,
|
||||
});
|
||||
};
|
||||
},
|
||||
@@ -1085,7 +1078,6 @@ export default {
|
||||
private: false,
|
||||
message: caption,
|
||||
sender: this.sender,
|
||||
isVoiceMessage: attachment.isVoiceMessage || false,
|
||||
};
|
||||
|
||||
attachmentPayload = this.setReplyToInPayload(attachmentPayload);
|
||||
@@ -1135,9 +1127,6 @@ export default {
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
messagePayload.files.push(attachment.blobSignedId);
|
||||
if (attachment.isVoiceMessage) {
|
||||
messagePayload.isVoiceMessage = true;
|
||||
}
|
||||
} else {
|
||||
messagePayload.files.push(attachment.resource.file);
|
||||
}
|
||||
@@ -1226,7 +1215,7 @@ export default {
|
||||
this.hasRecordedAudio = false;
|
||||
// Only clear the recorded audio when we click toggle button.
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
file => !file?.isVoiceMessage
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
toggleEditorSize() {
|
||||
@@ -1304,7 +1293,6 @@ export default {
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@record-error="onRecordError"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
|
||||
@@ -10,12 +10,10 @@ vi.mock('shared/helpers/mitt', () => ({
|
||||
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
|
||||
describe('useTrack', () => {
|
||||
|
||||
@@ -16,12 +16,10 @@ vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/captain/tasks');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
track: vi.fn(),
|
||||
},
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
CAPTAIN_EVENTS: {
|
||||
|
||||
@@ -52,10 +52,6 @@ export function useAccount() {
|
||||
});
|
||||
};
|
||||
|
||||
const finishOnboarding = async data => {
|
||||
await store.dispatch('accounts/finishOnboarding', data);
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
route,
|
||||
@@ -65,6 +61,5 @@ export function useAccount() {
|
||||
isCloudFeatureEnabled,
|
||||
isOnChatwootCloud,
|
||||
updateAccount,
|
||||
finishOnboarding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export const FEATURE_FLAGS = {
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
CONVERSATION_UNREAD_COUNTS: 'conversation_unread_counts',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'dashboard/composables/useWhatsappCallSession';
|
||||
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
|
||||
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const { isImpersonating } = useImpersonation();
|
||||
const UNREAD_COUNTS_REFETCH_THROTTLE_MS = 5000;
|
||||
@@ -171,10 +172,23 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
fetchConversationUnreadCounts = () => {
|
||||
if (!this.isConversationUnreadCountsEnabled()) return;
|
||||
|
||||
this.lastUnreadCountsFetchAt = Date.now();
|
||||
this.app.$store.dispatch('conversationUnreadCounts/get');
|
||||
};
|
||||
|
||||
isConversationUnreadCountsEnabled = () => {
|
||||
const accountId = this.app.$store.getters.getCurrentAccountId;
|
||||
const isFeatureEnabled =
|
||||
this.app.$store.getters['accounts/isFeatureEnabledonAccount'];
|
||||
|
||||
return isFeatureEnabled?.(
|
||||
accountId,
|
||||
FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
||||
);
|
||||
};
|
||||
|
||||
onTypingOn = ({ conversation, user }) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
dispatch: mockDispatch,
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -88,6 +89,21 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
||||
});
|
||||
|
||||
it('does not refetch unread counts when unread count feature is disabled', () => {
|
||||
store.$store.getters[
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
].mockReturnValue(false);
|
||||
|
||||
actionCable.onReceived({
|
||||
event: 'conversation.unread_count_changed',
|
||||
data: { account_id: 1 },
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalledWith(
|
||||
'conversationUnreadCounts/get'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throttle unread count refetches for repeated events', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
|
||||
@@ -88,7 +88,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
@@ -147,7 +146,6 @@
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"PROCESSING": "Processing",
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
"TIP_AUDIORECORDER_PERMISSION": "Allow access to audio",
|
||||
"TIP_AUDIORECORDER_ERROR": "Could not open the audio",
|
||||
"AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.",
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -195,12 +195,6 @@ const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
const normalizeWebsiteUrl = raw => {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (!trimmed) return '';
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
@@ -217,27 +211,9 @@ const handleSubmit = async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect which enrichable fields the user actually edited *before*
|
||||
// normalizing — otherwise an untouched auto-filled domain
|
||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
const fieldsChanged = Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key);
|
||||
|
||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||
website.value = normalizeWebsiteUrl(website.value);
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await finishOnboarding({
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
website: website.value,
|
||||
@@ -248,11 +224,20 @@ const handleSubmit = async () => {
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: fieldsChanged,
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import AccountAPI from '../../api/account';
|
||||
import OnboardingAPI from '../../api/onboarding';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import EnterpriseAccountAPI from '../../api/enterprise/account';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
@@ -84,15 +83,6 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
finishOnboarding: async ({ commit }, payload) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
const response = await OnboardingAPI.update(payload);
|
||||
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||
}
|
||||
},
|
||||
delete: async ({ commit }, { id }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||
try {
|
||||
|
||||
@@ -48,6 +48,9 @@ export const actions = {
|
||||
// Ignore errors so the sidebar can continue rendering without badges.
|
||||
}
|
||||
},
|
||||
clear({ commit }) {
|
||||
commit(types.SET_CONVERSATION_UNREAD_COUNTS, {});
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
|
||||
@@ -39,4 +39,15 @@ describe('#actions', () => {
|
||||
expect(commit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clear', () => {
|
||||
it('clears unread counts', () => {
|
||||
actions.clear({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(
|
||||
types.SET_CONVERSATION_UNREAD_COUNTS,
|
||||
{}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Rails from '@rails/ujs';
|
||||
import { Turbo } from '@hotwired/turbo-rails';
|
||||
import Turbolinks from 'turbolinks';
|
||||
import '../portal/application.scss';
|
||||
import { InitializationHelpers } from '../portal/portalHelpers';
|
||||
|
||||
Rails.start();
|
||||
Turbo.start();
|
||||
Turbolinks.start();
|
||||
|
||||
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
|
||||
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
|
||||
|
||||
@@ -55,6 +55,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.turbo-progress-bar {
|
||||
.turbolinks-progress-bar {
|
||||
background-color: var(--dynamic-portal-color);
|
||||
}
|
||||
|
||||
@@ -138,40 +138,21 @@ export default {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
handleSubmit() {
|
||||
const query = this.normalizedSearchTerm;
|
||||
if (!query) return;
|
||||
|
||||
const searchParams = new URLSearchParams({ query });
|
||||
const { theme, isPlainLayoutEnabled } = window.portalConfig;
|
||||
|
||||
if (theme) searchParams.set('theme', theme);
|
||||
if (isPlainLayoutEnabled === 'true') {
|
||||
searchParams.set('show_plain_layout', 'true');
|
||||
}
|
||||
|
||||
window.location.href = `/hc/${this.portalSlug}/${this.localeCode}/search?${searchParams.toString()}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<PublicSearchInput
|
||||
ref="searchInput"
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
:size="size"
|
||||
:kbd="kbdLabel"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<button type="submit" class="sr-only">
|
||||
{{ searchTranslations.submit }}
|
||||
</button>
|
||||
</form>
|
||||
<PublicSearchInput
|
||||
ref="searchInput"
|
||||
:search-term="searchTerm"
|
||||
:search-placeholder="searchTranslations.searchPlaceholder"
|
||||
:size="size"
|
||||
:kbd="kbdLabel"
|
||||
@update:search-term="onUpdateSearchTerm"
|
||||
@focus="openSearch"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowSearchBox"
|
||||
class="absolute w-full top-14"
|
||||
|
||||
@@ -112,8 +112,8 @@ export default {
|
||||
<p class="py-1 px-3" :class="getClassName(element)">
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbo="false"
|
||||
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
|
||||
data-turbolinks="false"
|
||||
class="font-medium text-sm cursor-pointer"
|
||||
:class="elementTextStyles(element)"
|
||||
>
|
||||
{{ element.title }}
|
||||
|
||||
@@ -24,9 +24,10 @@ export const getHeadingsfromTheArticle = () => {
|
||||
permalink.className = 'permalink text-slate-600 ml-3';
|
||||
permalink.href = `#${slug}`;
|
||||
permalink.title = headingText;
|
||||
permalink.dataset.turbo = 'false';
|
||||
permalink.dataset.turbolinks = 'false';
|
||||
permalink.textContent = '#';
|
||||
element.appendChild(permalink);
|
||||
|
||||
rows.push({
|
||||
slug,
|
||||
title: headingText,
|
||||
@@ -187,7 +188,7 @@ export const InitializationHelpers = {
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = window.location.hash;
|
||||
a['data-turbo'] = false;
|
||||
a['data-turbolinks'] = false;
|
||||
a.click();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,7 +92,7 @@ class ActionCableListener < BaseListener
|
||||
|
||||
def conversation_unread_count_changed(event)
|
||||
account, inbox_members = ::Conversations::UnreadCounts::BroadcastScope.new(event).perform
|
||||
return if account.blank?
|
||||
return if account.blank? || !account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
tokens = user_tokens(account, inbox_members)
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ class Account < ApplicationRecord
|
||||
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
|
||||
@@ -55,14 +55,10 @@ class Article < ApplicationRecord
|
||||
before_validation :ensure_article_slug
|
||||
before_validation :ensure_locale_in_article
|
||||
|
||||
# Slugs that collide with help center routes (e.g. /hc/:slug/:locale/search)
|
||||
RESERVED_SLUGS = %w[search articles categories].freeze
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :author_id, presence: true
|
||||
validates :title, presence: true
|
||||
validates :content, presence: true, if: :published?
|
||||
validates :slug, exclusion: { in: RESERVED_SLUGS }
|
||||
|
||||
# ensuring that the position is always set correctly
|
||||
before_create :add_position_to_article
|
||||
|
||||
+3
-17
@@ -47,7 +47,7 @@ class Campaign < ApplicationRecord
|
||||
|
||||
enum campaign_type: { ongoing: 0, one_off: 1 }
|
||||
# TODO : enabled attribute is unneccessary . lets move that to the campaign status with additional statuses like draft, disabled etc.
|
||||
enum campaign_status: { active: 0, completed: 1, processing: 2 }
|
||||
enum campaign_status: { active: 0, completed: 1 }
|
||||
|
||||
has_many :conversations, dependent: :nullify, autosave: true
|
||||
|
||||
@@ -56,27 +56,13 @@ class Campaign < ApplicationRecord
|
||||
|
||||
def trigger!
|
||||
return unless one_off?
|
||||
return unless feature_enabled?
|
||||
return unless mark_processing!
|
||||
return if completed?
|
||||
|
||||
execute_campaign
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def feature_enabled?
|
||||
inbox.inbox_type != 'Whatsapp' || account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def mark_processing!
|
||||
# Multiple scheduler jobs can pick the same active campaign; lock before flipping status to avoid duplicate sends.
|
||||
with_lock do
|
||||
next if completed? || processing?
|
||||
|
||||
processing!
|
||||
end
|
||||
end
|
||||
|
||||
def execute_campaign
|
||||
case inbox.inbox_type
|
||||
when 'Twilio SMS'
|
||||
@@ -84,7 +70,7 @@ class Campaign < ApplicationRecord
|
||||
when 'Sms'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Whatsapp'
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -51,5 +51,3 @@ class ContactPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
ContactPolicy.prepend_mod_with('ContactPolicy')
|
||||
|
||||
@@ -4,6 +4,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
def message_created(event)
|
||||
message, = extract_message_and_account(event)
|
||||
return unless message.incoming?
|
||||
return unless message.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
refresh(message.conversation)
|
||||
end
|
||||
@@ -35,7 +36,7 @@ class Conversations::UnreadCounts::Listener < BaseListener
|
||||
return if conversation_data.blank?
|
||||
|
||||
account = Account.find_by(id: conversation_data[:account_id])
|
||||
return if account.blank?
|
||||
return unless account&.feature_enabled?('conversation_unread_counts')
|
||||
return unless remove_deleted_conversation(account, conversation_data)
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation_data: conversation_data.to_h)
|
||||
|
||||
@@ -9,6 +9,8 @@ class Conversations::UnreadCounts::Notifier
|
||||
end
|
||||
|
||||
def perform
|
||||
return false unless conversation.account.feature_enabled?('conversation_unread_counts')
|
||||
|
||||
return false unless ::Conversations::UnreadCounts::Refresher.new(conversation, changed_attributes: changed_attributes).perform
|
||||
|
||||
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: conversation)
|
||||
|
||||
@@ -5,10 +5,12 @@ class Sms::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Sms' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -5,10 +5,12 @@ class Twilio::OneoffSmsCampaignService
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
process_audience(extract_audience_labels)
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
process_audience(extract_audience_labels)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -90,8 +90,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
end
|
||||
|
||||
# TODO: See if we can unify the API versions and for both paths and make it consistent with out facebook app API versions
|
||||
def phone_id_path(version = 'v13.0')
|
||||
"#{api_base_path}/#{version}/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||
def phone_id_path
|
||||
"#{api_base_path}/v13.0/#{whatsapp_channel.provider_config['phone_number_id']}"
|
||||
end
|
||||
|
||||
def business_account_path
|
||||
@@ -116,11 +116,14 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
def send_attachment_message(phone_number, message)
|
||||
attachment = message.attachments.first
|
||||
normalize_opus_content_type(attachment)
|
||||
type = %w[image audio video].include?(attachment.file_type) ? attachment.file_type : 'document'
|
||||
type_content = build_attachment_content(type, attachment, message)
|
||||
type_content = {
|
||||
'link': attachment.download_url
|
||||
}
|
||||
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
|
||||
type_content['filename'] = attachment.file.filename if type == 'document'
|
||||
response = HTTParty.post(
|
||||
"#{phone_id_path('v24.0')}/messages",
|
||||
"#{phone_id_path}/messages",
|
||||
headers: api_headers,
|
||||
body: {
|
||||
:messaging_product => 'whatsapp',
|
||||
@@ -139,33 +142,6 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
response.parsed_response&.dig('error', 'message')
|
||||
end
|
||||
|
||||
def voice_message?(type, attachment)
|
||||
type == 'audio' && attachment.meta&.dig('is_voice_message') && attachment.file.content_type == 'audio/ogg'
|
||||
end
|
||||
|
||||
# Marcel gem may re-detect OGG/Opus files as audio/opus after ActiveStorage
|
||||
# blob attachment, but WhatsApp Cloud API requires audio/ogg content type
|
||||
# for voice messages. Normalize so the download URL serves the correct
|
||||
# Content-Type header. No-op when the frontend already uploads as audio/ogg.
|
||||
def normalize_opus_content_type(attachment)
|
||||
return unless attachment.file.attached?
|
||||
|
||||
blob = attachment.file.blob
|
||||
return unless blob.content_type == 'audio/opus'
|
||||
|
||||
return if blob.update(content_type: 'audio/ogg')
|
||||
|
||||
Rails.logger.error("Failed to normalize blob #{blob.id} content_type from audio/opus to audio/ogg")
|
||||
end
|
||||
|
||||
def build_attachment_content(type, attachment, message)
|
||||
type_content = { 'link' => attachment.download_url }
|
||||
type_content['caption'] = message.outgoing_content unless %w[audio sticker].include?(type)
|
||||
type_content['filename'] = attachment.file.filename if type == 'document'
|
||||
type_content['voice'] = true if voice_message?(type, attachment)
|
||||
type_content
|
||||
end
|
||||
|
||||
def template_body_parameters(template_info)
|
||||
template_body = {
|
||||
name: template_info[:name],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1">
|
||||
<meta name="turbo-cache-control" content="no-cache">
|
||||
<meta name="turbolinks-cache-control" content="no-cache">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<%= vite_client_tag %>
|
||||
|
||||
@@ -82,7 +82,6 @@ html.light {
|
||||
emptyPlaceholder: '<%= I18n.t('public_portal.search.empty_placeholder') %>',
|
||||
loadingPlaceholder: '<%= I18n.t('public_portal.search.loading_placeholder') %>',
|
||||
resultsTitle: '<%= I18n.t('public_portal.search.results_title') %>',
|
||||
submit: '<%= I18n.t('public_portal.search.submit') %>',
|
||||
},
|
||||
isPlainLayoutEnabled: '<%= @is_plain_layout_enabled %>',
|
||||
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
<%= I18n.t('public_portal.hero.sub_title') %>
|
||||
</p>
|
||||
|
||||
<div class="mt-8 group/herosearch relative z-30">
|
||||
<form class="mt-8 group/herosearch relative z-30" onsubmit="return false">
|
||||
<div id="search-wrap-hero" class="block w-full"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% if popular_topics.any? %>
|
||||
<div class="mt-5 flex items-center gap-2 flex-wrap text-sm">
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<% portal.public_locale_codes.each do |code| %>
|
||||
<% is_current = code == locale %>
|
||||
<a href="/hc/<%= portal.slug %>/<%= code %>/"
|
||||
data-turbo="false"
|
||||
data-turbolinks="false"
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 text-sm rounded-md transition <%= is_current ? 'bg-n-portal-soft text-n-portal' : 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12' %>">
|
||||
<span class="text-xs font-semibold uppercase min-w-9 text-center text-n-slate-11 bg-n-slate-2 border border-solid border-n-weak rounded px-1.5 py-0.5 flex-shrink-0"><%= code %></span>
|
||||
<span class="flex-1 truncate <%= is_current ? 'font-medium' : '' %>"><%= language_name(code) %></span>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<%# locals: (input_class:) %>
|
||||
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
|
||||
<input type="hidden" name="locale" value="<%= params[:locale] %>">
|
||||
<% if @theme_from_params.present? %>
|
||||
<input type="hidden" name="theme" value="<%= @theme_from_params %>">
|
||||
<% end %>
|
||||
<% if @is_plain_layout_enabled %>
|
||||
<input type="hidden" name="show_plain_layout" value="true">
|
||||
<% end %>
|
||||
<input type="text"
|
||||
name="query"
|
||||
value="<%= @query %>"
|
||||
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
|
||||
data-search-input
|
||||
autofocus
|
||||
class="<%= input_class %>">
|
||||
<button type="submit" class="sr-only">
|
||||
<%= I18n.t('public_portal.search.submit') %>
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script>
|
||||
(function() {
|
||||
const searchInputs = document.querySelectorAll('[data-search-input]');
|
||||
|
||||
searchInputs.forEach(function(input) {
|
||||
let debounceTimer;
|
||||
|
||||
if (input.value.length > 0) {
|
||||
setTimeout(function() {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function() {
|
||||
const form = input.closest('[data-search-form]');
|
||||
const query = input.value.trim();
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
debounceTimer = setTimeout(function() {
|
||||
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
|
||||
if (query !== currentQuery) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
input.closest('[data-search-form]').addEventListener('submit', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -1,64 +0,0 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 md:px-10 py-8 md:py-10">
|
||||
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
|
||||
|
||||
<nav class="flex items-center gap-2 text-sm text-n-slate-11 mb-8 flex-wrap">
|
||||
<a
|
||||
href="<%= public_portal_locale_path(@portal.slug, @locale) %>"
|
||||
class="inline-flex items-center gap-1.5 hover:text-n-slate-12 transition"
|
||||
>
|
||||
<span class="i-lucide-house size-3.5" aria-hidden="true"></span>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="i-lucide-chevron-right size-3 text-n-slate-9" aria-hidden="true"></span>
|
||||
<span class="text-n-slate-12 font-medium truncate"><%= I18n.t('public_portal.search.results') %></span>
|
||||
</nav>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl md:text-4xl leading-snug font-620 tracking-tight text-n-slate-12 text-balance">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
<%= render 'public/api/v1/portals/search/form',
|
||||
input_class: 'w-full px-4 py-3 border border-n-weak rounded-lg bg-n-alpha-1 text-n-slate-12 placeholder-n-slate-10 focus:outline-none focus:ring-2 focus:ring-n-portal focus:border-transparent' %>
|
||||
</div>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||
|
||||
<% if @articles.empty? %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/empty_state',
|
||||
message: I18n.t('public_portal.search.no_results', query: @query) %>
|
||||
<% else %>
|
||||
<p class="mb-6 text-sm text-n-slate-11">
|
||||
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<% @articles.each do |article| %>
|
||||
<%= render 'public/api/v1/portals/documentation_layout/article_card',
|
||||
portal: @portal,
|
||||
article: article %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
|
||||
<div class="flex justify-center mt-6">
|
||||
<nav class="inline-flex">
|
||||
<% if @articles.prev_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-n-weak rounded-l-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
|
||||
<%= I18n.t('public_portal.common.previous') %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.next_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-n-weak rounded-r-md text-sm font-medium text-n-slate-11 hover:bg-n-alpha-2">
|
||||
<%= I18n.t('public_portal.common.next') %>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,118 +0,0 @@
|
||||
<% content_for :head do %>
|
||||
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
|
||||
<% end %>
|
||||
|
||||
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<%= I18n.t('public_portal.search.results') %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= I18n.t('public_portal.search.results_for', query: @query) %>
|
||||
</h1>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/form', input_class: search_input_class %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% pagination_params = params.permit(:query, :locale, :theme, :show_plain_layout) %>
|
||||
|
||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||
|
||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||
<% if @articles.empty? %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
<%= I18n.t('public_portal.search.found_results', count: @articles.total_count) %>
|
||||
</p>
|
||||
|
||||
<% @articles.each do |article| %>
|
||||
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
|
||||
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
|
||||
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
|
||||
<%= article.title %>
|
||||
</h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
|
||||
<%= render_category_content(article.content) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<% if article.category.present? %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= article.category.name %>
|
||||
</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<% end %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
|
||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
|
||||
<div class="flex justify-center mt-6">
|
||||
<nav class="inline-flex">
|
||||
<% if @articles.prev_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.prev_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.previous') %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<% if @articles.next_page %>
|
||||
<a href="<%= url_for(pagination_params.merge(page: @articles.next_page)) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<%= I18n.t('public_portal.common.next') %>
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
+1
-2
@@ -19,9 +19,8 @@
|
||||
help_url: https://chwt.app/hc/fb
|
||||
- name: conversation_unread_counts
|
||||
display_name: Conversation Unread Counts
|
||||
enabled: true
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
deprecated: true
|
||||
- name: ip_lookup
|
||||
display_name: IP Lookup
|
||||
enabled: false
|
||||
|
||||
@@ -81,6 +81,9 @@ en:
|
||||
saml:
|
||||
feature_not_enabled: SAML feature not enabled for this account
|
||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||
conversations:
|
||||
unread_counts:
|
||||
feature_not_enabled: Conversation unread counts feature not enabled for this account
|
||||
data_import:
|
||||
data_type:
|
||||
invalid: Invalid data type
|
||||
@@ -427,13 +430,6 @@ en:
|
||||
empty_placeholder: No results found.
|
||||
loading_placeholder: Searching...
|
||||
results_title: Search results
|
||||
results: Search Results
|
||||
results_for: "Search Results for '%{query}'"
|
||||
no_results: "No results found for '%{query}'"
|
||||
found_results:
|
||||
one: Found 1 result
|
||||
other: 'Found %{count} results'
|
||||
submit: Search
|
||||
toc_header: 'On this page'
|
||||
sidebar:
|
||||
help_center: Help Center
|
||||
@@ -465,8 +461,6 @@ en:
|
||||
others: others
|
||||
by: By
|
||||
no_articles: There are no articles here
|
||||
previous: Previous
|
||||
next: Next
|
||||
article_actions:
|
||||
label: Open in
|
||||
view_markdown: View as Markdown
|
||||
|
||||
@@ -29,7 +29,6 @@ Rails.application.routes.draw do
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
|
||||
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
|
||||
get '/app/accounts/:account_id/onboarding/inbox-setup', to: 'dashboard#index', as: 'app_onboarding_inbox_setup'
|
||||
|
||||
resource :widget, only: [:show]
|
||||
namespace :survey do
|
||||
@@ -55,7 +54,6 @@ Rails.application.routes.draw do
|
||||
resource :contact_merge, only: [:create]
|
||||
end
|
||||
resource :bulk_actions, only: [:create]
|
||||
resource :onboarding, only: [:update]
|
||||
resources :agents, only: [:index, :create, :update, :destroy] do
|
||||
post :bulk_create, on: :collection
|
||||
end
|
||||
@@ -590,7 +588,6 @@ Rails.application.routes.draw do
|
||||
get 'hc/:slug', to: 'public/api/v1/portals#show'
|
||||
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
|
||||
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show', as: :public_portal_locale
|
||||
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
|
||||
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
|
||||
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
|
||||
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show', as: :public_portal_category
|
||||
|
||||
@@ -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
|
||||
# .filter { |ae| ae.neighbor_distance <= distance_threshold }
|
||||
|
||||
limit = params.key?(:limit) ? params[:limit] : 5
|
||||
|
||||
article_embeddings = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
article_embeddings = article_embeddings.limit(limit) if limit.present?
|
||||
article_ids = article_embeddings.pluck(:article_id)
|
||||
article_ids = ArticleEmbedding.where(article_id: filtered_article_ids)
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(5)
|
||||
.pluck(:article_id)
|
||||
|
||||
# Fetch the articles by the IDs obtained from the nearest neighbors search
|
||||
where(id: article_ids).in_order_of(:id, article_ids)
|
||||
where(id: article_ids)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
module Enterprise::ContactPolicy
|
||||
def export?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
|
||||
def import?
|
||||
@account_user.custom_role&.permissions&.include?('contact_manage') || super
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v2'.freeze
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def self.configured?
|
||||
@@ -35,10 +35,10 @@ class Captain::Tools::FirecrawlService
|
||||
def crawl_payload(url, webhook_url, crawl_limit)
|
||||
{
|
||||
url: url,
|
||||
maxDiscoveryDepth: 50,
|
||||
sitemap: 'include',
|
||||
maxDepth: 50,
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: { url: webhook_url },
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: scrape_options
|
||||
}.to_json
|
||||
end
|
||||
@@ -50,7 +50,6 @@ class Captain::Tools::FirecrawlService
|
||||
def scrape_options
|
||||
{
|
||||
onlyMainContent: true,
|
||||
maxAge: 0,
|
||||
formats: ['markdown'],
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS
|
||||
}
|
||||
|
||||
@@ -19,15 +19,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
channel_voice
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
sla
|
||||
custom_roles
|
||||
csat_review_notes
|
||||
conversation_required_attributes
|
||||
advanced_assignment
|
||||
custom_tools
|
||||
companies
|
||||
].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
|
||||
+7
-6
@@ -12,7 +12,7 @@
|
||||
"start:test": "RAILS_ENV=test foreman start -f ./Procfile.test",
|
||||
"dev": "overmind start -f ./Procfile.dev",
|
||||
"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",
|
||||
"size": "size-limit",
|
||||
"story:dev": "histoire dev",
|
||||
@@ -40,7 +40,6 @@
|
||||
"@formkit/vue": "^1.7.2",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@hotwired/turbo-rails": "^8.0.13",
|
||||
"@iconify-json/fluent": "^1.2.32",
|
||||
"@iconify-json/material-symbols": "^1.2.10",
|
||||
"@lk77/vue3-color": "^3.0.6",
|
||||
@@ -53,7 +52,7 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue/compiler-sfc": "^3.5.8",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
@@ -93,6 +92,7 @@
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
"tinykeys": "^3.0.0",
|
||||
"turbolinks": "^5.2.0",
|
||||
"urlpattern-polyfill": "^10.0.0",
|
||||
"video.js": "7.21.1",
|
||||
"videojs-record": "4.5.0",
|
||||
@@ -146,8 +146,8 @@
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"size-limit": "^8.2.4",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "6.4.2",
|
||||
"vite-plugin-ruby": "^5.2.1",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-ruby": "^5.0.0",
|
||||
"vitest": "3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
@@ -161,7 +161,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "6.4.2",
|
||||
"vite-node": "2.0.1",
|
||||
"vite": "5.4.21",
|
||||
"vitest": "3.0.5",
|
||||
"minimatch@<4": "3.1.5",
|
||||
"minimatch@>=9.0.0 <9.0.7": "9.0.9",
|
||||
|
||||
Generated
+195
-455
File diff suppressed because it is too large
Load Diff
@@ -140,45 +140,6 @@ describe Messages::Facebook::MessageBuilder do
|
||||
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
|
||||
subject(:mocked_message_builder) do
|
||||
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
|
||||
|
||||
@@ -149,35 +149,6 @@ describe Messages::MessageBuilder do
|
||||
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
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
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)
|
||||
end
|
||||
|
||||
it 'returns unread conversation counts scoped to the signed-in user' do
|
||||
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
|
||||
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
|
||||
context 'when conversation unread counts feature is enabled' do
|
||||
before do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
end
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
it 'returns unread conversation counts scoped to the signed-in user' do
|
||||
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
|
||||
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
|
||||
|
||||
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' => {}
|
||||
)
|
||||
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']).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
|
||||
|
||||
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)
|
||||
|
||||
it 'returns forbidden when conversation unread counts feature is disabled' do
|
||||
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)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -833,6 +848,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
|
||||
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)
|
||||
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
|
||||
Conversations::UnreadCounts::Builder.new(account).build_base!
|
||||
@@ -920,6 +936,7 @@ RSpec.describe 'Conversations API', type: :request do
|
||||
end
|
||||
|
||||
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)
|
||||
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['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
|
||||
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')
|
||||
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
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
|
||||
@@ -34,25 +34,6 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
|
||||
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
|
||||
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
|
||||
inbox = create(:channel_email, account: account, email: email)&.inbox
|
||||
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
|
||||
{
|
||||
url: url,
|
||||
maxDiscoveryDepth: 50,
|
||||
sitemap: 'include',
|
||||
maxDepth: 50,
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: { url: webhook_url },
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: {
|
||||
onlyMainContent: true,
|
||||
maxAge: 0,
|
||||
formats: ['markdown'],
|
||||
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
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
@@ -86,7 +85,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
it 'makes a POST request with correct parameters' do
|
||||
service.perform(url, webhook_url, crawl_limit)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
@@ -96,7 +95,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
it 'uses default crawl limit when not specified' do
|
||||
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
|
||||
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
@@ -105,7 +104,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
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(
|
||||
body: default_payload,
|
||||
headers: expected_headers
|
||||
@@ -115,7 +114,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when the API call fails' 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'))
|
||||
end
|
||||
|
||||
@@ -127,14 +126,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
|
||||
|
||||
context 'when the API returns an error response' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}')
|
||||
end
|
||||
|
||||
it 'makes the request but does not raise an error' do
|
||||
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
|
||||
.with(
|
||||
body: expected_payload,
|
||||
headers: expected_headers
|
||||
|
||||
@@ -140,7 +140,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
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
|
||||
@@ -148,7 +148,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
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
|
||||
@@ -172,7 +172,7 @@ describe PortalHelper do
|
||||
theme: nil,
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true'
|
||||
'/hc/portal_slug/en/categories/category_slug'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -186,7 +186,7 @@ describe PortalHelper do
|
||||
theme: 'dark',
|
||||
is_plain_layout_enabled: true
|
||||
)).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
|
||||
@@ -210,7 +210,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
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
|
||||
@@ -218,7 +218,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
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
|
||||
|
||||
@@ -40,13 +40,5 @@ RSpec.describe TriggerScheduledItemsJob do
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
|
||||
described_class.perform_now
|
||||
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
|
||||
|
||||
@@ -237,6 +237,10 @@ describe ActionCableListener do
|
||||
let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
|
||||
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
|
||||
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
|
||||
|
||||
@@ -261,6 +265,14 @@ describe ActionCableListener do
|
||||
listener.conversation_unread_count_changed(event)
|
||||
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
|
||||
event = Events::Base.new(
|
||||
event_name,
|
||||
|
||||
@@ -50,6 +50,44 @@ RSpec.describe Account do
|
||||
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
|
||||
let(:account) { create(:account) }
|
||||
|
||||
|
||||
@@ -20,15 +20,6 @@ RSpec.describe Article do
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:content]).to include("can't be blank")
|
||||
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
|
||||
|
||||
describe 'associations' do
|
||||
|
||||
@@ -83,38 +83,6 @@ RSpec.describe Campaign do
|
||||
campaign.save!
|
||||
campaign.trigger!
|
||||
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
|
||||
|
||||
context 'when SMS campaign' do
|
||||
@@ -139,22 +107,6 @@ RSpec.describe Campaign do
|
||||
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
|
||||
let(:campaign) { build(:campaign) }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
|
||||
end
|
||||
|
||||
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)
|
||||
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)
|
||||
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
|
||||
changed_attributes = { 'status' => %w[open resolved] }
|
||||
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
|
||||
|
||||
it 'removes unread count memberships when a conversation is deleted' do
|
||||
account.enable_features!(:conversation_unread_counts)
|
||||
label = create(:label, account: account)
|
||||
team = create(:team, account: account)
|
||||
assignee = create(:user, account: account)
|
||||
|
||||
@@ -6,6 +6,7 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
|
||||
let(:refresh_result) { true }
|
||||
|
||||
before do
|
||||
conversation.account.enable_features!(:conversation_unread_counts)
|
||||
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher)
|
||||
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
end
|
||||
@@ -29,4 +30,19 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
|
||||
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
||||
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
|
||||
|
||||
@@ -45,19 +45,6 @@ describe Sms::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
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
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -61,24 +61,6 @@ describe Twilio::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
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
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -82,19 +82,6 @@ describe Whatsapp::OneoffCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
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
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels =
|
||||
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.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(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
|
||||
# ref: https://github.com/bblimke/webmock/issues/900
|
||||
# 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(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
@@ -91,41 +91,6 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
.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 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
|
||||
|
||||
|
||||
@@ -145,34 +145,6 @@ inbox_create_payload:
|
||||
$ref: ./request/inbox/create_payload.yml
|
||||
inbox_update_payload:
|
||||
$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_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
Reference in New Issue
Block a user