Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Muhsin Keloth
2025-09-22 16:48:42 +05:30
committed by GitHub
101 changed files with 7899 additions and 119 deletions
@@ -151,13 +151,36 @@ class AutomationRules::ConditionsFilterService < FilterService
" #{table_name}.additional_attributes ->> '#{attribute_key}' #{filter_operator_value} #{query_operator} "
when 'standard'
if attribute_key == 'labels'
" tags.id #{filter_operator_value} #{query_operator} "
build_label_query_string(query_hash, current_index, query_operator)
else
" #{table_name}.#{attribute_key} #{filter_operator_value} #{query_operator} "
end
end
end
def build_label_query_string(query_hash, current_index, query_operator)
case query_hash['filter_operator']
when 'equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name = :#{value_placeholder} #{query_operator} "
when 'not_equal_to'
return " 1=0 #{query_operator} " if query_hash['values'].blank?
value_placeholder = "value_#{current_index}"
@filter_values[value_placeholder] = query_hash['values'].first
" tags.name != :#{value_placeholder} #{query_operator} "
when 'is_present'
" tags.id IS NOT NULL #{query_operator} "
when 'is_not_present'
" tags.id IS NULL #{query_operator} "
else
" tags.id #{filter_operation(query_hash, current_index)} #{query_operator} "
end
end
private
def base_relation
@@ -166,7 +189,21 @@ class AutomationRules::ConditionsFilterService < FilterService
).joins(
'LEFT OUTER JOIN messages on messages.conversation_id = conversations.id'
)
# Only add label joins when label conditions exist
if label_conditions?
records = records.joins(
'LEFT OUTER JOIN taggings ON taggings.taggable_id = conversations.id AND taggings.taggable_type = \'Conversation\''
).joins(
'LEFT OUTER JOIN tags ON taggings.tag_id = tags.id'
)
end
records = records.where(messages: { id: @options[:message].id }) if @options[:message].present?
records
end
def label_conditions?
@rule.conditions.any? { |condition| condition['attribute_key'] == 'labels' }
end
end
+27
View File
@@ -0,0 +1,27 @@
class BaseTokenService
pattr_initialize [:payload, :token]
def generate_token
JWT.encode(token_payload, secret_key, algorithm)
end
def decode_token
JWT.decode(token, secret_key, true, algorithm: algorithm).first.symbolize_keys
rescue JWT::ExpiredSignature, JWT::DecodeError
{}
end
private
def token_payload
payload || {}
end
def secret_key
Rails.application.secret_key_base
end
def algorithm
'HS256'
end
end
+16 -2
View File
@@ -76,7 +76,8 @@ class Line::IncomingMessageService
response = inbox.channel.client.get_message_content(message['id'])
file_name = "media-#{message['id']}.#{response.content_type.split('/')[1]}"
extension = get_file_extension(response)
file_name = message['fileName'] || "media-#{message['id']}.#{extension}"
temp_file = Tempfile.new(file_name)
temp_file.binmode
temp_file << response.body
@@ -93,12 +94,25 @@ class Line::IncomingMessageService
)
end
def get_file_extension(response)
if response.content_type&.include?('/')
response.content_type.split('/')[1]
else
'bin'
end
end
def event_type_message?(event)
event['type'] == 'message' || event['type'] == 'sticker'
end
def message_type_non_text?(type)
[Line::Bot::Event::MessageType::Video, Line::Bot::Event::MessageType::Audio, Line::Bot::Event::MessageType::Image].include?(type)
[
Line::Bot::Event::MessageType::Video,
Line::Bot::Event::MessageType::Audio,
Line::Bot::Event::MessageType::Image,
Line::Bot::Event::MessageType::File
].include?(type)
end
def account
@@ -0,0 +1,23 @@
class Mfa::AuthenticationService
pattr_initialize [:user!, :otp_code, :backup_code]
def authenticate
return false unless user
return authenticate_with_otp if otp_code.present?
return authenticate_with_backup_code if backup_code.present?
false
end
private
def authenticate_with_otp
user.validate_and_consume_otp!(otp_code)
end
def authenticate_with_backup_code
mfa_service = Mfa::ManagementService.new(user: user)
mfa_service.validate_backup_code!(backup_code)
end
end
+88
View File
@@ -0,0 +1,88 @@
class Mfa::ManagementService
pattr_initialize [:user!]
def enable_two_factor!
user.otp_secret = User.generate_otp_secret
user.save!
end
def disable_two_factor!
user.otp_secret = nil
user.otp_required_for_login = false
user.otp_backup_codes = nil
user.save!
end
def verify_and_activate!
ActiveRecord::Base.transaction do
user.update!(otp_required_for_login: true)
backup_codes_generated? ? nil : generate_backup_codes!
end
end
def two_factor_provisioning_uri
return nil if user.otp_secret.blank?
issuer = 'Chatwoot'
label = user.email
user.otp_provisioning_uri(label, issuer: issuer)
end
def generate_backup_codes!
codes = Array.new(10) { SecureRandom.hex(4).upcase }
user.otp_backup_codes = codes
user.save!
codes
end
def validate_backup_code!(code)
return false unless valid_backup_code_input?(code)
codes = user.otp_backup_codes
found_index = find_matching_code_index(codes, code)
return false if found_index.nil?
mark_code_as_used(codes, found_index)
end
private
def valid_backup_code_input?(code)
user.otp_backup_codes.present? && code.present?
end
def find_matching_code_index(codes, code)
found_index = nil
# Constant-time comparison to prevent timing attacks
codes.each_with_index do |stored_code, idx|
is_match = ActiveSupport::SecurityUtils.secure_compare(stored_code, code)
is_unused = stored_code != 'XXXXXXXX'
found_index = idx if is_match && is_unused
end
found_index
end
def mark_code_as_used(codes, index)
codes[index] = 'XXXXXXXX'
user.otp_backup_codes = codes
user.save!
true
end
public
def backup_codes_generated?
user.otp_backup_codes.present?
end
def mfa_enabled?
user.otp_required_for_login?
end
def two_factor_setup_pending?
user.otp_secret.present? && !user.otp_required_for_login?
end
end
+28
View File
@@ -0,0 +1,28 @@
class Mfa::TokenService < BaseTokenService
pattr_initialize [:user, :token]
MFA_TOKEN_EXPIRY = 5.minutes
def generate_token
@payload = build_payload
super
end
def verify_token
decoded = decode_token
return nil if decoded.blank?
User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound
nil
end
private
def build_payload
{
user_id: user.id,
exp: MFA_TOKEN_EXPIRY.from_now.to_i
}
end
end
@@ -30,12 +30,12 @@ class Whatsapp::PopulateTemplateParametersService
end
end
def build_media_parameter(url, media_type)
def build_media_parameter(url, media_type, media_name = nil)
return nil if url.blank?
sanitized_url = sanitize_parameter(url)
validate_url(sanitized_url)
build_media_type_parameter(sanitized_url, media_type.downcase)
build_media_type_parameter(sanitized_url, media_type.downcase, media_name)
end
def build_named_parameter(parameter_name, value)
@@ -89,14 +89,14 @@ class Whatsapp::PopulateTemplateParametersService
}
end
def build_media_type_parameter(sanitized_url, media_type)
def build_media_type_parameter(sanitized_url, media_type, media_name = nil)
case media_type
when 'image'
build_image_parameter(sanitized_url)
when 'video'
build_video_parameter(sanitized_url)
when 'document'
build_document_parameter(sanitized_url)
build_document_parameter(sanitized_url, media_name)
else
raise ArgumentError, "Unsupported media type: #{media_type}"
end
@@ -110,8 +110,11 @@ class Whatsapp::PopulateTemplateParametersService
{ type: 'video', video: { link: url } }
end
def build_document_parameter(url)
{ type: 'document', document: { link: url } }
def build_document_parameter(url, media_name = nil)
document_params = { link: url }
document_params[:filename] = media_name if media_name.present?
{ type: 'document', document: document_params }
end
def rich_formatting?(text)
@@ -141,7 +141,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
# {
# processed_params: {
# body: { '1': 'John', '2': '123 Main St' },
# header: { media_url: 'https://...', media_type: 'image' },
# header: {
# media_url: 'https://...',
# media_type: 'image',
# media_name: 'filename.pdf' # Optional, for document templates only
# },
# buttons: [{ type: 'url', parameter: 'otp123456' }]
# }
# }
@@ -60,9 +60,10 @@ class Whatsapp::TemplateProcessorService
next if value.blank?
if media_url_with_type?(key, header_data)
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
media_name = header_data['media_name']
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'], media_name)
header_params << media_param if media_param
elsif key != 'media_type'
elsif key != 'media_type' && key != 'media_name'
header_params << parameter_builder.build_parameter(value)
end
end
+4 -18
View File
@@ -1,24 +1,14 @@
class Widget::TokenService
class Widget::TokenService < BaseTokenService
DEFAULT_EXPIRY_DAYS = 180
pattr_initialize [:payload, :token]
def generate_token
JWT.encode payload_with_expiry, secret_key, 'HS256'
end
def decode_token
JWT.decode(
token, secret_key, true, algorithm: 'HS256'
).first.symbolize_keys
rescue StandardError
{}
JWT.encode(token_payload, secret_key, algorithm)
end
private
def payload_with_expiry
payload.merge(exp: exp, iat: iat)
def token_payload
(payload || {}).merge(exp: exp, iat: iat)
end
def iat
@@ -34,8 +24,4 @@ class Widget::TokenService
token_expiry_value = InstallationConfig.find_by(name: 'WIDGET_TOKEN_EXPIRY')&.value
(token_expiry_value.presence || DEFAULT_EXPIRY_DAYS).to_i
end
def secret_key
Rails.application.secret_key_base
end
end