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