Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82377e4d58 | ||
|
|
adf39ce881 | ||
|
|
45c4119c7d | ||
|
|
1838f07814 | ||
|
|
1d4c2adef7 | ||
|
|
9c6b20f11e | ||
|
|
0506c477eb | ||
|
|
aba27c11f8 | ||
|
|
c7da5bd5e6 |
@@ -1,4 +1,6 @@
|
|||||||
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
|
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
|
||||||
|
include ::Messages::Instagram::StoryReplyAttachmentHandling
|
||||||
|
|
||||||
attr_reader :messaging
|
attr_reader :messaging
|
||||||
|
|
||||||
def initialize(messaging, inbox, outgoing_echo: false)
|
def initialize(messaging, inbox, outgoing_echo: false)
|
||||||
@@ -115,24 +117,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
create_story_reply_attachment(story_reply_attributes['url'])
|
create_story_reply_attachment(story_reply_attributes['url'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_story_reply_attachment(story_url)
|
|
||||||
return if story_url.blank?
|
|
||||||
|
|
||||||
attachment = @message.attachments.new(
|
|
||||||
file_type: :ig_story,
|
|
||||||
account_id: @message.account_id,
|
|
||||||
external_url: story_url
|
|
||||||
)
|
|
||||||
attachment.save!
|
|
||||||
begin
|
|
||||||
attach_file(attachment, story_url)
|
|
||||||
rescue Down::Error, StandardError => e
|
|
||||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
|
||||||
end
|
|
||||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
|
||||||
@message.save!
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_conversation
|
def build_conversation
|
||||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||||
Conversation.create!(conversation_params.merge(
|
Conversation.create!(conversation_params.merge(
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
module Messages::Instagram::StoryReplyAttachmentHandling
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_story_reply_attachment(story_url)
|
||||||
|
return if story_url.blank?
|
||||||
|
|
||||||
|
attach_story_reply_file(build_story_reply_attachment(story_url), story_url)
|
||||||
|
update_story_reply_message
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_story_reply_attachment(story_url)
|
||||||
|
@message.attachments.new(
|
||||||
|
file_type: :ig_story,
|
||||||
|
account_id: @message.account_id,
|
||||||
|
external_url: story_url
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_story_reply_file(attachment, story_url)
|
||||||
|
downloaded_file = nil
|
||||||
|
|
||||||
|
SafeFetch.fetch(story_url, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES) do |attachment_file|
|
||||||
|
downloaded_file = attachment_file
|
||||||
|
persist_story_reply_attachment(attachment, attachment_file)
|
||||||
|
end
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading Messenger attachment from #{story_url}: #{e.message}: Skipping"
|
||||||
|
discard_story_reply_attachment(attachment)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
||||||
|
discard_story_reply_attachment(attachment)
|
||||||
|
ensure
|
||||||
|
downloaded_file&.close!
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_story_reply_attachment(attachment, attachment_file)
|
||||||
|
attachment.file.attach(
|
||||||
|
io: attachment_file.tempfile,
|
||||||
|
filename: attachment_file.original_filename,
|
||||||
|
content_type: attachment_file.content_type
|
||||||
|
)
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def discard_story_reply_attachment(attachment)
|
||||||
|
return attachment.destroy if attachment.persisted?
|
||||||
|
|
||||||
|
@message.attachments.proxy_association.target.delete(attachment)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_story_reply_message
|
||||||
|
@message.content_attributes[:image_type] = 'ig_story_reply'
|
||||||
|
@message.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,14 +5,46 @@ class Messages::Messenger::MessageBuilder
|
|||||||
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
# This check handles very rare case if there are multiple files to attach with only one unsupported file
|
||||||
return if unsupported_file_type?(attachment['type'])
|
return if unsupported_file_type?(attachment['type'])
|
||||||
|
|
||||||
|
attachment_obj, downloaded_file = prepare_attachment(attachment)
|
||||||
|
return unless attachment_obj
|
||||||
|
|
||||||
|
finalize_attachment(attachment_obj)
|
||||||
|
ensure
|
||||||
|
downloaded_file&.close!
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_attachment(attachment)
|
||||||
params = attachment_params(attachment)
|
params = attachment_params(attachment)
|
||||||
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
|
||||||
|
downloaded_file = persist_attachment(attachment, attachment_obj, params[:remote_file_url])
|
||||||
|
return [nil, downloaded_file] if params[:remote_file_url].present? && downloaded_file.blank?
|
||||||
|
|
||||||
|
[attachment_obj, downloaded_file]
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_attachment(attachment, attachment_obj, remote_file_url)
|
||||||
|
return save_facebook_reel_attachment(attachment, attachment_obj) if facebook_reel?(attachment)
|
||||||
|
return save_remote_file_attachment(attachment_obj, remote_file_url) if remote_file_url.present?
|
||||||
|
|
||||||
attachment_obj.save!
|
attachment_obj.save!
|
||||||
if facebook_reel?(attachment)
|
nil
|
||||||
update_facebook_reel_content(attachment)
|
end
|
||||||
elsif params[:remote_file_url]
|
|
||||||
attach_file(attachment_obj, params[:remote_file_url])
|
def save_facebook_reel_attachment(attachment, attachment_obj)
|
||||||
end
|
attachment_obj.save!
|
||||||
|
update_facebook_reel_content(attachment)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_remote_file_attachment(attachment_obj, remote_file_url)
|
||||||
|
downloaded_file = attach_file(attachment_obj, remote_file_url)
|
||||||
|
return if downloaded_file.blank?
|
||||||
|
|
||||||
|
attachment_obj.save!
|
||||||
|
downloaded_file
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_attachment(attachment_obj)
|
||||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||||
@@ -20,14 +52,22 @@ class Messages::Messenger::MessageBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def attach_file(attachment, file_url)
|
def attach_file(attachment, file_url)
|
||||||
attachment_file = Down.download(
|
downloaded_file = nil
|
||||||
file_url
|
SafeFetch.fetch(
|
||||||
)
|
file_url,
|
||||||
attachment.file.attach(
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES
|
||||||
io: attachment_file,
|
) do |attachment_file|
|
||||||
filename: attachment_file.original_filename,
|
downloaded_file = attachment_file
|
||||||
content_type: attachment_file.content_type
|
attachment.file.attach(
|
||||||
)
|
io: attachment_file.tempfile,
|
||||||
|
filename: attachment_file.original_filename,
|
||||||
|
content_type: attachment_file.content_type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
downloaded_file
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading Messenger attachment from #{file_url}: #{e.message}: Skipping"
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def attachment_params(attachment)
|
def attachment_params(attachment)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
def create_from_url
|
def create_from_url
|
||||||
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||||
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||||
|
ensure
|
||||||
|
result.close!
|
||||||
end
|
end
|
||||||
rescue SafeFetch::HttpError => e
|
rescue SafeFetch::HttpError => e
|
||||||
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||||
|
|||||||
@@ -13,23 +13,12 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
|||||||
RATE_LIMIT_WINDOW = 1.minute
|
RATE_LIMIT_WINDOW = 1.minute
|
||||||
|
|
||||||
def perform(avatarable, avatar_url)
|
def perform(avatarable, avatar_url)
|
||||||
return unless avatarable.respond_to?(:avatar)
|
return unless syncable_avatar?(avatarable, avatar_url)
|
||||||
return unless url_valid?(avatar_url)
|
|
||||||
|
|
||||||
return unless should_sync_avatar?(avatarable, avatar_url)
|
fetch_avatar(avatarable, avatar_url)
|
||||||
|
rescue SafeFetch::HttpError => e
|
||||||
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
|
log_http_error(avatar_url, e)
|
||||||
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
|
rescue SafeFetch::Error => e
|
||||||
|
|
||||||
avatarable.avatar.attach(
|
|
||||||
io: avatar_file,
|
|
||||||
filename: avatar_file.original_filename,
|
|
||||||
content_type: avatar_file.content_type
|
|
||||||
)
|
|
||||||
|
|
||||||
rescue Down::NotFound
|
|
||||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
|
||||||
rescue Down::Error => e
|
|
||||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||||
ensure
|
ensure
|
||||||
update_avatar_sync_attributes(avatarable, avatar_url)
|
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||||
@@ -37,6 +26,43 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def syncable_avatar?(avatarable, avatar_url)
|
||||||
|
avatarable.respond_to?(:avatar) &&
|
||||||
|
url_valid?(avatar_url) &&
|
||||||
|
should_sync_avatar?(avatarable, avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_avatar(avatarable, avatar_url)
|
||||||
|
SafeFetch.fetch(
|
||||||
|
avatar_url,
|
||||||
|
max_bytes: MAX_DOWNLOAD_SIZE,
|
||||||
|
allowed_content_type_prefixes: [],
|
||||||
|
allowed_content_types: %w[image/jpeg image/png image/gif]
|
||||||
|
) do |avatar_file|
|
||||||
|
attach_avatar(avatarable, avatar_file)
|
||||||
|
ensure
|
||||||
|
avatar_file.close!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_avatar(avatarable, avatar_file)
|
||||||
|
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
|
||||||
|
|
||||||
|
avatarable.avatar.attach(
|
||||||
|
io: avatar_file.tempfile,
|
||||||
|
filename: avatar_file.original_filename,
|
||||||
|
content_type: avatar_file.content_type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def log_http_error(avatar_url, error)
|
||||||
|
if error.message.start_with?('404')
|
||||||
|
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||||
|
else
|
||||||
|
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def should_sync_avatar?(avatarable, avatar_url)
|
def should_sync_avatar?(avatarable, avatar_url)
|
||||||
# Only Contacts are rate-limited and hash-gated.
|
# Only Contacts are rate-limited and hash-gated.
|
||||||
return true unless avatarable.is_a?(Contact)
|
return true unless avatarable.is_a?(Contact)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Macro < ApplicationRecord
|
|||||||
enum visibility: { personal: 0, global: 1 }
|
enum visibility: { personal: 0, global: 1 }
|
||||||
|
|
||||||
validate :json_actions_format
|
validate :json_actions_format
|
||||||
|
validate :validate_webhook_action_urls
|
||||||
|
|
||||||
ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_agent
|
ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_agent
|
||||||
remove_assigned_team resolve_conversation snooze_conversation change_priority send_email_transcript
|
remove_assigned_team resolve_conversation snooze_conversation change_priority send_email_transcript
|
||||||
@@ -73,6 +74,20 @@ class Macro < ApplicationRecord
|
|||||||
|
|
||||||
errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any?
|
errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_webhook_action_urls
|
||||||
|
return if actions.blank?
|
||||||
|
|
||||||
|
actions.each do |action|
|
||||||
|
action = action.respond_to?(:with_indifferent_access) ? action.with_indifferent_access : {}
|
||||||
|
next unless action[:action_name] == 'send_webhook_event'
|
||||||
|
|
||||||
|
webhook_url = Array(action[:action_params]).first
|
||||||
|
SafeOutboundUrl.validate!(webhook_url)
|
||||||
|
rescue SafeOutboundUrl::Error
|
||||||
|
errors.add(:actions, 'Webhook URL for send_webhook_event must be a public http(s) URL.')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Macro.include_mod_with('Audit::Macro')
|
Macro.include_mod_with('Audit::Macro')
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
module DownloadedFileTracking
|
||||||
|
private
|
||||||
|
|
||||||
|
def with_downloaded_files
|
||||||
|
@downloaded_files = []
|
||||||
|
yield
|
||||||
|
ensure
|
||||||
|
Array(@downloaded_files).each(&:close!)
|
||||||
|
end
|
||||||
|
|
||||||
|
def track_downloaded_file(downloaded_file)
|
||||||
|
(@downloaded_files ||= []) << downloaded_file
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -64,7 +64,10 @@ class Macros::ExecutionService < ActionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_webhook_event(webhook_url)
|
def send_webhook_event(webhook_url)
|
||||||
|
url = Array(webhook_url).first
|
||||||
|
SafeOutboundUrl.validate!(url)
|
||||||
|
|
||||||
payload = @conversation.webhook_data.merge(event: 'macro.executed')
|
payload = @conversation.webhook_data.merge(event: 'macro.executed')
|
||||||
WebhookJob.perform_later(webhook_url.first, payload)
|
WebhookJob.perform_later(url, payload, :macro_webhook)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
class Sms::IncomingMessageService
|
class Sms::IncomingMessageService
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
include ::DownloadedFileTracking
|
||||||
|
|
||||||
pattr_initialize [:inbox!, :params!]
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
set_contact
|
with_downloaded_files do
|
||||||
set_conversation
|
set_contact
|
||||||
@message = @conversation.messages.create!(
|
set_conversation
|
||||||
content: params[:text],
|
@message = @conversation.messages.create!(
|
||||||
account_id: @inbox.account_id,
|
content: params[:text],
|
||||||
inbox_id: @inbox.id,
|
account_id: @inbox.account_id,
|
||||||
message_type: :incoming,
|
inbox_id: @inbox.id,
|
||||||
sender: @contact,
|
message_type: :incoming,
|
||||||
source_id: params[:id]
|
sender: @contact,
|
||||||
)
|
source_id: params[:id]
|
||||||
attach_files
|
)
|
||||||
@message.save!
|
attach_files
|
||||||
|
@message.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -83,20 +86,29 @@ class Sms::IncomingMessageService
|
|||||||
# we don't need to process this files since chatwoot doesn't support it
|
# we don't need to process this files since chatwoot doesn't support it
|
||||||
next if media_url.end_with?('.smil', '.xml')
|
next if media_url.end_with?('.smil', '.xml')
|
||||||
|
|
||||||
attachment_file = Down.download(
|
download_attachment_file(media_url) do |attachment_file|
|
||||||
media_url,
|
track_downloaded_file(attachment_file)
|
||||||
http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']]
|
@message.attachments.new(
|
||||||
)
|
account_id: @message.account_id,
|
||||||
|
file_type: file_type(attachment_file.content_type),
|
||||||
@message.attachments.new(
|
file: {
|
||||||
account_id: @message.account_id,
|
io: attachment_file.tempfile,
|
||||||
file_type: file_type(attachment_file.content_type),
|
filename: attachment_file.original_filename,
|
||||||
file: {
|
content_type: attachment_file.content_type
|
||||||
io: attachment_file,
|
}
|
||||||
filename: attachment_file.original_filename,
|
)
|
||||||
content_type: attachment_file.content_type
|
end
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def download_attachment_file(media_url, &)
|
||||||
|
SafeFetch.fetch(
|
||||||
|
media_url,
|
||||||
|
http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']],
|
||||||
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
|
||||||
|
&
|
||||||
|
)
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading SMS attachment from #{media_url}: #{e.message}: Skipping"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
module Telegram::AttachmentHandling
|
||||||
|
private
|
||||||
|
|
||||||
|
def file_content_type
|
||||||
|
return :image if image_message?
|
||||||
|
return :audio if audio_message?
|
||||||
|
return :video if video_message?
|
||||||
|
|
||||||
|
file_type(params[:message][:document][:mime_type])
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_message?
|
||||||
|
params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def audio_message?
|
||||||
|
params[:message][:voice].present? || params[:message][:audio].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def video_message?
|
||||||
|
params[:message][:video].present? || params[:message][:video_note].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_files
|
||||||
|
return unless file
|
||||||
|
|
||||||
|
file_download_path = telegram_file_download_path
|
||||||
|
return unless file_download_path
|
||||||
|
|
||||||
|
SafeFetch.fetch(file_download_path, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES) do |attachment_file|
|
||||||
|
build_file_attachment(attachment_file)
|
||||||
|
end
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading Telegram attachment from #{file_download_path}: #{e.message}: Skipping"
|
||||||
|
end
|
||||||
|
|
||||||
|
def telegram_file_download_path
|
||||||
|
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
|
||||||
|
return file_download_path if file_download_path.present?
|
||||||
|
|
||||||
|
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_file_attachment(attachment_file)
|
||||||
|
track_downloaded_file(attachment_file)
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: file_content_type,
|
||||||
|
file: {
|
||||||
|
io: attachment_file.tempfile,
|
||||||
|
filename: attachment_file.original_filename,
|
||||||
|
content_type: attachment_file.content_type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def file
|
||||||
|
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def location_fallback_title
|
||||||
|
return '' if venue.blank?
|
||||||
|
|
||||||
|
venue[:title] || ''
|
||||||
|
end
|
||||||
|
|
||||||
|
def venue
|
||||||
|
@venue ||= params.dig(:message, :venue).presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def visual_media_params
|
||||||
|
params[:message][:photo].presence&.last ||
|
||||||
|
params.dig(:message, :sticker, :thumb).presence ||
|
||||||
|
params[:message][:video].presence ||
|
||||||
|
params[:message][:video_note].presence
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,36 +3,40 @@
|
|||||||
|
|
||||||
class Telegram::IncomingMessageService
|
class Telegram::IncomingMessageService
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
include ::DownloadedFileTracking
|
||||||
include ::Telegram::ParamHelpers
|
include ::Telegram::ParamHelpers
|
||||||
|
include ::Telegram::AttachmentHandling
|
||||||
pattr_initialize [:inbox!, :params!]
|
pattr_initialize [:inbox!, :params!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
# chatwoot doesn't support group conversations at the moment
|
with_downloaded_files do
|
||||||
transform_business_message!
|
# chatwoot doesn't support group conversations at the moment
|
||||||
return unless private_message?
|
transform_business_message!
|
||||||
|
return unless private_message?
|
||||||
|
|
||||||
set_contact
|
set_contact
|
||||||
update_contact_avatar
|
update_contact_avatar
|
||||||
set_conversation
|
set_conversation
|
||||||
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
|
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
|
||||||
# Otherwise, the client will see their messages as unread.
|
# Otherwise, the client will see their messages as unread.
|
||||||
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
|
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
|
||||||
# We have two options:
|
# We have two options:
|
||||||
# 1. Send the read request to Telegram here, immediately when the message is created.
|
# 1. Send the read request to Telegram here, immediately when the message is created.
|
||||||
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
|
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
|
||||||
# See: https://core.telegram.org/bots/api#readbusinessmessage
|
# See: https://core.telegram.org/bots/api#readbusinessmessage
|
||||||
@message = @conversation.messages.build(
|
@message = @conversation.messages.build(
|
||||||
content: telegram_params_message_content,
|
content: telegram_params_message_content,
|
||||||
account_id: @inbox.account_id,
|
account_id: @inbox.account_id,
|
||||||
inbox_id: @inbox.id,
|
inbox_id: @inbox.id,
|
||||||
message_type: message_type,
|
message_type: message_type,
|
||||||
sender: message_sender,
|
sender: message_sender,
|
||||||
content_attributes: telegram_params_content_attributes,
|
content_attributes: telegram_params_content_attributes,
|
||||||
source_id: telegram_params_message_id.to_s
|
source_id: telegram_params_message_id.to_s
|
||||||
)
|
)
|
||||||
|
|
||||||
process_message_attachments if message_params?
|
process_message_attachments if message_params?
|
||||||
@message.save!
|
@message.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -121,50 +125,6 @@ class Telegram::IncomingMessageService
|
|||||||
business_message_outgoing? ? nil : @contact
|
business_message_outgoing? ? nil : @contact
|
||||||
end
|
end
|
||||||
|
|
||||||
def file_content_type
|
|
||||||
return :image if image_message?
|
|
||||||
return :audio if audio_message?
|
|
||||||
return :video if video_message?
|
|
||||||
|
|
||||||
file_type(params[:message][:document][:mime_type])
|
|
||||||
end
|
|
||||||
|
|
||||||
def image_message?
|
|
||||||
params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def audio_message?
|
|
||||||
params[:message][:voice].present? || params[:message][:audio].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def video_message?
|
|
||||||
params[:message][:video].present? || params[:message][:video_note].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_files
|
|
||||||
return unless file
|
|
||||||
|
|
||||||
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
|
|
||||||
if file_download_path.blank?
|
|
||||||
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
attachment_file = Down.download(
|
|
||||||
inbox.channel.get_telegram_file_path(file[:file_id])
|
|
||||||
)
|
|
||||||
|
|
||||||
@message.attachments.new(
|
|
||||||
account_id: @message.account_id,
|
|
||||||
file_type: file_content_type,
|
|
||||||
file: {
|
|
||||||
io: attachment_file,
|
|
||||||
filename: attachment_file.original_filename,
|
|
||||||
content_type: attachment_file.content_type
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_location
|
def attach_location
|
||||||
return unless location
|
return unless location
|
||||||
|
|
||||||
@@ -191,20 +151,6 @@ class Telegram::IncomingMessageService
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def file
|
|
||||||
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
|
|
||||||
end
|
|
||||||
|
|
||||||
def location_fallback_title
|
|
||||||
return '' if venue.blank?
|
|
||||||
|
|
||||||
venue[:title] || ''
|
|
||||||
end
|
|
||||||
|
|
||||||
def venue
|
|
||||||
@venue ||= params.dig(:message, :venue).presence
|
|
||||||
end
|
|
||||||
|
|
||||||
def location
|
def location
|
||||||
@location ||= params.dig(:message, :location).presence
|
@location ||= params.dig(:message, :location).presence
|
||||||
end
|
end
|
||||||
@@ -213,13 +159,6 @@ class Telegram::IncomingMessageService
|
|||||||
@contact_card ||= params.dig(:message, :contact).presence
|
@contact_card ||= params.dig(:message, :contact).presence
|
||||||
end
|
end
|
||||||
|
|
||||||
def visual_media_params
|
|
||||||
params[:message][:photo].presence&.last ||
|
|
||||||
params.dig(:message, :sticker, :thumb).presence ||
|
|
||||||
params[:message][:video].presence ||
|
|
||||||
params[:message][:video_note].presence
|
|
||||||
end
|
|
||||||
|
|
||||||
def transform_business_message!
|
def transform_business_message!
|
||||||
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
|
params[:message] = params[:business_message] if params[:business_message] && !params[:message]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class Tiktok::MessageService
|
class Tiktok::MessageService
|
||||||
|
include DownloadedFileTracking
|
||||||
include Tiktok::MessagingHelpers
|
include Tiktok::MessagingHelpers
|
||||||
|
|
||||||
pattr_initialize [:channel!, :content!, :outgoing_echo]
|
pattr_initialize [:channel!, :content!, :outgoing_echo]
|
||||||
@@ -32,22 +33,12 @@ class Tiktok::MessageService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_message
|
def create_message
|
||||||
message = conversation.messages.build(
|
with_downloaded_files do
|
||||||
content: message_content,
|
message = build_message
|
||||||
account_id: channel.inbox.account_id,
|
apply_message_direction(message)
|
||||||
inbox_id: channel.inbox.id,
|
create_message_attachments(message)
|
||||||
message_type: incoming_message? ? :incoming : :outgoing,
|
message.save!
|
||||||
content_attributes: message_content_attributes,
|
end
|
||||||
source_id: tt_message_id,
|
|
||||||
created_at: tt_message_time,
|
|
||||||
updated_at: tt_message_time
|
|
||||||
)
|
|
||||||
|
|
||||||
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
|
|
||||||
message.status = :delivered if outgoing_message?
|
|
||||||
|
|
||||||
create_message_attachments(message)
|
|
||||||
message.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def message_content
|
def message_content
|
||||||
@@ -64,17 +55,18 @@ class Tiktok::MessageService
|
|||||||
def create_image_message_attachment(message)
|
def create_image_message_attachment(message)
|
||||||
return unless image_message?
|
return unless image_message?
|
||||||
|
|
||||||
attachment_file = fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
|
fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id) do |attachment_file|
|
||||||
|
track_downloaded_file(attachment_file)
|
||||||
message.attachments.new(
|
message.attachments.new(
|
||||||
account_id: message.account_id,
|
account_id: message.account_id,
|
||||||
file_type: :image,
|
file_type: :image,
|
||||||
file: {
|
file: {
|
||||||
io: attachment_file,
|
io: attachment_file.tempfile,
|
||||||
filename: attachment_file.original_filename,
|
filename: attachment_file.original_filename,
|
||||||
content_type: attachment_file.content_type
|
content_type: attachment_file.content_type
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_share_post_message_attachment(message)
|
def create_share_post_message_attachment(message)
|
||||||
@@ -99,6 +91,24 @@ class Tiktok::MessageService
|
|||||||
attributes
|
attributes
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_message
|
||||||
|
conversation.messages.build(
|
||||||
|
content: message_content,
|
||||||
|
account_id: channel.inbox.account_id,
|
||||||
|
inbox_id: channel.inbox.id,
|
||||||
|
message_type: incoming_message? ? :incoming : :outgoing,
|
||||||
|
content_attributes: message_content_attributes,
|
||||||
|
source_id: tt_message_id,
|
||||||
|
created_at: tt_message_time,
|
||||||
|
updated_at: tt_message_time
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_message_direction(message)
|
||||||
|
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
|
||||||
|
message.status = :delivered if outgoing_message?
|
||||||
|
end
|
||||||
|
|
||||||
def text_message?
|
def text_message?
|
||||||
tt_message_type == 'text'
|
tt_message_type == 'text'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -66,9 +66,16 @@ module Tiktok::MessagingHelpers
|
|||||||
message
|
message
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
|
def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id, &)
|
||||||
file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id)
|
file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id)
|
||||||
Down.download(file_download_url, headers: { 'x-user' => channel.validated_access_token })
|
SafeFetch.fetch(
|
||||||
|
file_download_url,
|
||||||
|
headers: { 'x-user' => channel.validated_access_token },
|
||||||
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
|
||||||
|
&
|
||||||
|
)
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading TikTok attachment from #{file_download_url}: #{e.message}: Skipping"
|
||||||
end
|
end
|
||||||
|
|
||||||
def tiktok_client(channel)
|
def tiktok_client(channel)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
module Twilio::AttachmentHandling
|
||||||
|
private
|
||||||
|
|
||||||
|
def attach_files
|
||||||
|
num_media = params[:NumMedia].to_i
|
||||||
|
return if num_media.zero?
|
||||||
|
|
||||||
|
num_media.times do |index|
|
||||||
|
media_url = params[:"MediaUrl#{index}"]
|
||||||
|
attach_single_file(media_url) if media_url.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_single_file(media_url)
|
||||||
|
download_attachment_file(media_url) do |attachment_file|
|
||||||
|
track_downloaded_file(attachment_file)
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: file_type(attachment_file.content_type),
|
||||||
|
file: {
|
||||||
|
io: attachment_file.tempfile,
|
||||||
|
filename: attachment_file.original_filename,
|
||||||
|
content_type: attachment_file.content_type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_attachment_file(media_url, &)
|
||||||
|
download_with_auth(media_url, &)
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
handle_download_attachment_error(e, media_url, &)
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_with_auth(media_url, &)
|
||||||
|
SafeFetch.fetch(
|
||||||
|
media_url,
|
||||||
|
http_basic_authentication: attachment_auth_credentials,
|
||||||
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
|
||||||
|
&
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_auth_credentials
|
||||||
|
return [twilio_channel.api_key_sid, twilio_channel.auth_token] if twilio_channel.api_key_sid.present?
|
||||||
|
|
||||||
|
[twilio_channel.account_sid, twilio_channel.auth_token]
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_download_attachment_error(error, media_url, &)
|
||||||
|
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
|
||||||
|
SafeFetch.fetch(media_url, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES, &)
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||||
|
end
|
||||||
|
|
||||||
|
def location_message?
|
||||||
|
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_location
|
||||||
|
@message.attachments.new(
|
||||||
|
account_id: @message.account_id,
|
||||||
|
file_type: :location,
|
||||||
|
coordinates_lat: params[:Latitude].to_f,
|
||||||
|
coordinates_long: params[:Longitude].to_f
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
class Twilio::IncomingMessageService
|
class Twilio::IncomingMessageService
|
||||||
include ::FileTypeHelper
|
include ::FileTypeHelper
|
||||||
|
include ::DownloadedFileTracking
|
||||||
|
include ::Twilio::AttachmentHandling
|
||||||
|
|
||||||
pattr_initialize [:params!]
|
pattr_initialize [:params!]
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
return if twilio_channel.blank?
|
with_downloaded_files do
|
||||||
|
return if twilio_channel.blank?
|
||||||
|
|
||||||
set_contact
|
set_contact
|
||||||
set_conversation
|
set_conversation
|
||||||
@message = @conversation.messages.build(
|
@message = @conversation.messages.build(
|
||||||
content: message_body,
|
content: message_body,
|
||||||
account_id: @inbox.account_id,
|
account_id: @inbox.account_id,
|
||||||
inbox_id: @inbox.id,
|
inbox_id: @inbox.id,
|
||||||
message_type: :incoming,
|
message_type: :incoming,
|
||||||
sender: @contact,
|
sender: @contact,
|
||||||
source_id: params[:SmsSid]
|
source_id: params[:SmsSid]
|
||||||
)
|
)
|
||||||
attach_files
|
attach_files
|
||||||
attach_location if location_message?
|
attach_location if location_message?
|
||||||
@message.save!
|
@message.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -132,70 +136,6 @@ class Twilio::IncomingMessageService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def attach_files
|
|
||||||
num_media = params[:NumMedia].to_i
|
|
||||||
return if num_media.zero?
|
|
||||||
|
|
||||||
num_media.times do |i|
|
|
||||||
media_url = params[:"MediaUrl#{i}"]
|
|
||||||
attach_single_file(media_url) if media_url.present?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_single_file(media_url)
|
|
||||||
attachment_file = download_attachment_file(media_url)
|
|
||||||
return if attachment_file.blank?
|
|
||||||
|
|
||||||
@message.attachments.new(
|
|
||||||
account_id: @message.account_id,
|
|
||||||
file_type: file_type(attachment_file.content_type),
|
|
||||||
file: {
|
|
||||||
io: attachment_file,
|
|
||||||
filename: attachment_file.original_filename,
|
|
||||||
content_type: attachment_file.content_type
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def download_attachment_file(media_url)
|
|
||||||
download_with_auth(media_url)
|
|
||||||
rescue Down::Error, Down::ClientError => e
|
|
||||||
handle_download_attachment_error(e, media_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
def download_with_auth(media_url)
|
|
||||||
auth_credentials = if twilio_channel.api_key_sid.present?
|
|
||||||
# When using api_key_sid, the auth token should be the api_secret_key
|
|
||||||
[twilio_channel.api_key_sid, twilio_channel.auth_token]
|
|
||||||
else
|
|
||||||
# When using account_sid, the auth token is the account's auth token
|
|
||||||
[twilio_channel.account_sid, twilio_channel.auth_token]
|
|
||||||
end
|
|
||||||
|
|
||||||
Down.download(media_url, http_basic_authentication: auth_credentials)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_download_attachment_error(error, media_url)
|
|
||||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
|
|
||||||
Down.download(media_url)
|
|
||||||
rescue StandardError => e
|
|
||||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def location_message?
|
|
||||||
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def attach_location
|
|
||||||
@message.attachments.new(
|
|
||||||
account_id: @message.account_id,
|
|
||||||
file_type: :location,
|
|
||||||
coordinates_lat: params[:Latitude].to_f,
|
|
||||||
coordinates_long: params[:Longitude].to_f
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_contact_name_if_needed
|
def update_contact_name_if_needed
|
||||||
return if params[:ProfileName].blank?
|
return if params[:ProfileName].blank?
|
||||||
return if @contact.name == params[:ProfileName]
|
return if @contact.name == params[:ProfileName]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
|
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
|
||||||
# https://developers.facebook.com/docs/whatsapp/api/media/
|
# https://developers.facebook.com/docs/whatsapp/api/media/
|
||||||
class Whatsapp::IncomingMessageBaseService
|
class Whatsapp::IncomingMessageBaseService
|
||||||
|
include ::DownloadedFileTracking
|
||||||
include ::Whatsapp::IncomingMessageServiceHelpers
|
include ::Whatsapp::IncomingMessageServiceHelpers
|
||||||
|
|
||||||
pattr_initialize [:inbox!, :params!, :outgoing_echo]
|
pattr_initialize [:inbox!, :params!, :outgoing_echo]
|
||||||
@@ -17,31 +18,31 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Returns messages array for both regular messages and echo events
|
# Returns messages array for both regular messages and echo events
|
||||||
def messages_data
|
def messages_data = @processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
|
||||||
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def process_messages
|
def process_messages
|
||||||
# We don't support reactions & ephemeral message now, we need to skip processing the message
|
with_downloaded_files do
|
||||||
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
# We don't support reactions & ephemeral message now, we need to skip processing the message
|
||||||
return if unprocessable_message_type?(message_type)
|
# if the webhook event is a reaction or an ephermal message or an unsupported message.
|
||||||
|
return if unprocessable_message_type?(message_type)
|
||||||
|
|
||||||
# Multiple webhook events can be received for the same message due to
|
# Multiple webhook events can be received for the same message due to
|
||||||
# misconfigurations in the Meta business manager account.
|
# misconfigurations in the Meta business manager account.
|
||||||
# We use an atomic Redis SET NX to prevent concurrent workers from both
|
# We use an atomic Redis SET NX to prevent concurrent workers from both
|
||||||
# processing the same message simultaneously.
|
# processing the same message simultaneously.
|
||||||
return if find_message_by_source_id(messages_data.first[:id])
|
return if find_message_by_source_id(messages_data.first[:id])
|
||||||
return unless lock_message_source_id!
|
return unless lock_message_source_id!
|
||||||
|
|
||||||
set_contact
|
set_contact
|
||||||
return unless @contact
|
return unless @contact
|
||||||
return if @contact.blocked? && !outgoing_echo
|
return if @contact.blocked? && !outgoing_echo
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
set_conversation
|
set_conversation
|
||||||
create_messages
|
create_messages
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -148,18 +149,20 @@ class Whatsapp::IncomingMessageBaseService
|
|||||||
attachment_payload = messages_data.first[message_type.to_sym]
|
attachment_payload = messages_data.first[message_type.to_sym]
|
||||||
@message.content ||= attachment_payload[:caption]
|
@message.content ||= attachment_payload[:caption]
|
||||||
|
|
||||||
attachment_file = download_attachment_file(attachment_payload)
|
download_attachment_file(attachment_payload) do |attachment_file|
|
||||||
return if attachment_file.blank?
|
track_downloaded_file(attachment_file)
|
||||||
|
@message.attachments.new(
|
||||||
@message.attachments.new(
|
account_id: @message.account_id,
|
||||||
account_id: @message.account_id,
|
file_type: file_content_type(message_type),
|
||||||
file_type: file_content_type(message_type),
|
file: {
|
||||||
file: {
|
io: attachment_file.tempfile,
|
||||||
io: attachment_file,
|
filename: attachment_file.original_filename,
|
||||||
filename: attachment_file.original_filename,
|
content_type: attachment_file.content_type
|
||||||
content_type: attachment_file.content_type
|
}
|
||||||
}
|
)
|
||||||
)
|
end
|
||||||
|
rescue SafeFetch::Error => e
|
||||||
|
Rails.logger.info "Error downloading WhatsApp attachment: #{e.message}: Skipping"
|
||||||
end
|
end
|
||||||
|
|
||||||
def attach_location
|
def attach_location
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
module Whatsapp::IncomingMessageServiceHelpers
|
module Whatsapp::IncomingMessageServiceHelpers
|
||||||
def download_attachment_file(attachment_payload)
|
def download_attachment_file(attachment_payload, &)
|
||||||
Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers)
|
SafeFetch.fetch(
|
||||||
|
inbox.channel.media_url(attachment_payload[:id]),
|
||||||
|
headers: inbox.channel.api_headers,
|
||||||
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
|
||||||
|
&
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_params
|
def conversation_params
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
|
|||||||
@processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value')
|
@processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value')
|
||||||
end
|
end
|
||||||
|
|
||||||
def download_attachment_file(attachment_payload)
|
def download_attachment_file(attachment_payload, &)
|
||||||
url_response = HTTParty.get(
|
url_response = HTTParty.get(
|
||||||
inbox.channel.media_url(attachment_payload[:id]),
|
inbox.channel.media_url(attachment_payload[:id]),
|
||||||
headers: inbox.channel.api_headers
|
headers: inbox.channel.api_headers
|
||||||
)
|
)
|
||||||
# This url response will be failure if the access token has expired.
|
# This url response will be failure if the access token has expired.
|
||||||
inbox.channel.authorization_error! if url_response.unauthorized?
|
inbox.channel.authorization_error! if url_response.unauthorized?
|
||||||
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success?
|
return unless url_response.success?
|
||||||
|
|
||||||
|
SafeFetch.fetch(
|
||||||
|
url_response.parsed_response['url'],
|
||||||
|
headers: inbox.channel.api_headers,
|
||||||
|
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
|
||||||
|
&
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
require 'agents'
|
require 'agents'
|
||||||
|
|
||||||
class Captain::Tools::HttpTool < Agents::Tool
|
class Captain::Tools::HttpTool < Agents::Tool
|
||||||
|
MAX_RESPONSE_SIZE = 1.megabyte
|
||||||
|
OPEN_TIMEOUT = 10
|
||||||
|
READ_TIMEOUT = 30
|
||||||
|
|
||||||
def initialize(assistant, custom_tool)
|
def initialize(assistant, custom_tool)
|
||||||
@assistant = assistant
|
@assistant = assistant
|
||||||
@custom_tool = custom_tool
|
@custom_tool = custom_tool
|
||||||
@@ -15,8 +19,8 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
url = @custom_tool.build_request_url(params)
|
url = @custom_tool.build_request_url(params)
|
||||||
body = @custom_tool.build_request_body(params)
|
body = @custom_tool.build_request_body(params)
|
||||||
|
|
||||||
response = execute_http_request(url, body, tool_context)
|
response_body = execute_http_request(url, body, tool_context)
|
||||||
@custom_tool.format_response(response.body)
|
@custom_tool.format_response(response_body)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||||
'An error occurred while executing the request'
|
'An error occurred while executing the request'
|
||||||
@@ -24,89 +28,31 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
PRIVATE_IP_RANGES = [
|
|
||||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
|
||||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
|
||||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
|
||||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
|
||||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
|
||||||
IPAddr.new('::1'), # IPv6 Loopback
|
|
||||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
|
||||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
|
||||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
|
||||||
MAX_RESPONSE_SIZE = 1.megabyte
|
|
||||||
|
|
||||||
def execute_http_request(url, body, tool_context)
|
def execute_http_request(url, body, tool_context)
|
||||||
uri = URI.parse(url)
|
response_body = nil
|
||||||
|
|
||||||
# Check if resolved IP is private
|
SafeFetch.fetch(
|
||||||
check_private_ip!(uri.host)
|
url,
|
||||||
|
method: @custom_tool.http_method,
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
body: body,
|
||||||
http.use_ssl = uri.scheme == 'https'
|
max_bytes: MAX_RESPONSE_SIZE,
|
||||||
http.read_timeout = 30
|
open_timeout: OPEN_TIMEOUT,
|
||||||
http.open_timeout = 10
|
read_timeout: READ_TIMEOUT,
|
||||||
http.max_retries = 0 # Disable redirects
|
headers: request_headers(tool_context, body),
|
||||||
|
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
|
||||||
request = build_http_request(uri, body)
|
validate_content_type: false
|
||||||
apply_authentication(request)
|
) do |response|
|
||||||
apply_metadata_headers(request, tool_context)
|
response_body = response.tempfile.read
|
||||||
|
ensure
|
||||||
response = http.request(request)
|
response.close!
|
||||||
|
|
||||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
||||||
|
|
||||||
validate_response!(response)
|
|
||||||
|
|
||||||
response
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_private_ip!(hostname)
|
|
||||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
|
||||||
|
|
||||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
|
||||||
rescue Resolv::ResolvError, SocketError => e
|
|
||||||
raise "DNS resolution failed: #{e.message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_response!(response)
|
|
||||||
content_length = response['content-length']&.to_i
|
|
||||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
|
||||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
response_body
|
||||||
|
|
||||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_http_request(uri, body)
|
def request_headers(tool_context, body)
|
||||||
if @custom_tool.http_method == 'POST'
|
headers = @custom_tool.build_auth_headers.merge(@custom_tool.build_metadata_headers(tool_context&.state || {}))
|
||||||
request = Net::HTTP::Post.new(uri.request_uri)
|
headers['Content-Type'] = 'application/json' if @custom_tool.http_method == 'POST' && body.present?
|
||||||
if body
|
headers
|
||||||
request.body = body
|
|
||||||
request['Content-Type'] = 'application/json'
|
|
||||||
end
|
|
||||||
else
|
|
||||||
request = Net::HTTP::Get.new(uri.request_uri)
|
|
||||||
end
|
|
||||||
request
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply_authentication(request)
|
|
||||||
headers = @custom_tool.build_auth_headers
|
|
||||||
headers.each { |key, value| request[key] = value }
|
|
||||||
|
|
||||||
credentials = @custom_tool.build_basic_auth_credentials
|
|
||||||
request.basic_auth(*credentials) if credentials
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply_metadata_headers(request, tool_context)
|
|
||||||
state = tool_context&.state || {}
|
|
||||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
|
||||||
metadata_headers.each { |key, value| request[key] = value }
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+38
-72
@@ -1,12 +1,40 @@
|
|||||||
require 'ssrf_filter'
|
require 'ssrf_filter'
|
||||||
|
|
||||||
module SafeFetch
|
module SafeFetch
|
||||||
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
|
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/ audio/].freeze
|
||||||
|
DEFAULT_ALLOWED_CONTENT_TYPES = %w[
|
||||||
|
text/csv
|
||||||
|
text/plain
|
||||||
|
text/rtf
|
||||||
|
application/json
|
||||||
|
application/pdf
|
||||||
|
application/zip
|
||||||
|
application/x-7z-compressed
|
||||||
|
application/vnd.rar
|
||||||
|
application/x-tar
|
||||||
|
application/msword
|
||||||
|
application/vnd.ms-excel
|
||||||
|
application/vnd.ms-powerpoint
|
||||||
|
application/rtf
|
||||||
|
application/vnd.oasis.opendocument.text
|
||||||
|
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||||
|
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||||
|
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||||
|
].freeze
|
||||||
|
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie].freeze
|
||||||
DEFAULT_OPEN_TIMEOUT = 2
|
DEFAULT_OPEN_TIMEOUT = 2
|
||||||
DEFAULT_READ_TIMEOUT = 20
|
DEFAULT_READ_TIMEOUT = 20
|
||||||
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
||||||
|
|
||||||
Result = Data.define(:tempfile, :filename, :content_type)
|
Result = Data.define(:tempfile, :filename, :content_type) do
|
||||||
|
def original_filename
|
||||||
|
filename
|
||||||
|
end
|
||||||
|
|
||||||
|
def close!
|
||||||
|
tempfile.close! if tempfile.respond_to?(:close!)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
class InvalidUrlError < Error; end
|
class InvalidUrlError < Error; end
|
||||||
@@ -15,84 +43,22 @@ module SafeFetch
|
|||||||
class HttpError < Error; end
|
class HttpError < Error; end
|
||||||
class FileTooLargeError < Error; end
|
class FileTooLargeError < Error; end
|
||||||
class UnsupportedContentTypeError < Error; end
|
class UnsupportedContentTypeError < Error; end
|
||||||
|
class UnsupportedMethodError < Error; end
|
||||||
|
end
|
||||||
|
|
||||||
def self.fetch(url,
|
require_relative 'safe_fetch/request_options'
|
||||||
max_bytes: nil,
|
require_relative 'safe_fetch/fetcher'
|
||||||
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
|
|
||||||
|
module SafeFetch
|
||||||
|
def self.fetch(url, **, &)
|
||||||
raise ArgumentError, 'block required' unless block_given?
|
raise ArgumentError, 'block required' unless block_given?
|
||||||
|
|
||||||
effective_max_bytes = max_bytes || default_max_bytes
|
Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
|
||||||
uri = parse_and_validate_url!(url)
|
|
||||||
filename = filename_for(uri)
|
|
||||||
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
|
||||||
|
|
||||||
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
|
|
||||||
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
||||||
|
|
||||||
tempfile.rewind
|
|
||||||
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
|
|
||||||
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||||
raise InvalidUrlError, e.message
|
raise InvalidUrlError, e.message
|
||||||
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||||
raise UnsafeUrlError, e.message
|
raise UnsafeUrlError, e.message
|
||||||
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
||||||
raise FetchError, e.message
|
raise FetchError, e.message
|
||||||
ensure
|
|
||||||
tempfile&.close!
|
|
||||||
end
|
|
||||||
|
|
||||||
class << self
|
|
||||||
private
|
|
||||||
|
|
||||||
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
|
|
||||||
response = nil
|
|
||||||
bytes_written = 0
|
|
||||||
|
|
||||||
SsrfFilter.get(
|
|
||||||
url,
|
|
||||||
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
|
|
||||||
) do |res|
|
|
||||||
response = res
|
|
||||||
next unless res.is_a?(Net::HTTPSuccess)
|
|
||||||
|
|
||||||
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
|
|
||||||
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
|
|
||||||
end
|
|
||||||
|
|
||||||
res.read_body do |chunk|
|
|
||||||
bytes_written += chunk.bytesize
|
|
||||||
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
|
|
||||||
|
|
||||||
tempfile.write(chunk)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
response
|
|
||||||
end
|
|
||||||
|
|
||||||
def filename_for(uri)
|
|
||||||
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def default_max_bytes
|
|
||||||
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
|
||||||
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
|
||||||
limit_mb.megabytes
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_and_validate_url!(url)
|
|
||||||
uri = URI.parse(url)
|
|
||||||
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
||||||
raise InvalidUrlError, 'missing host' if uri.host.blank?
|
|
||||||
|
|
||||||
uri
|
|
||||||
end
|
|
||||||
|
|
||||||
def allowed_content_type?(value, prefixes)
|
|
||||||
mime = value.to_s.split(';').first&.strip&.downcase
|
|
||||||
return false if mime.blank?
|
|
||||||
|
|
||||||
prefixes.any? { |prefix| mime.start_with?(prefix) }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
class SafeFetch::Fetcher
|
||||||
|
def initialize(options)
|
||||||
|
@options = options
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch
|
||||||
|
with_tempfile do |tempfile|
|
||||||
|
response = stream_response(tempfile)
|
||||||
|
raise SafeFetch::HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
yield SafeFetch::Result.new(
|
||||||
|
tempfile: duplicate_tempfile(tempfile),
|
||||||
|
filename: options.filename,
|
||||||
|
content_type: normalize_content_type(response['content-type'])
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :options
|
||||||
|
|
||||||
|
def with_tempfile
|
||||||
|
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||||
|
yield tempfile
|
||||||
|
ensure
|
||||||
|
tempfile&.close!
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream_response(tempfile)
|
||||||
|
response = nil
|
||||||
|
bytes_written = 0
|
||||||
|
|
||||||
|
SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
|
||||||
|
response = res
|
||||||
|
next unless res.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
validate_content_type!(res['content-type'])
|
||||||
|
bytes_written = write_response_body(res, tempfile, bytes_written)
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_content_type!(content_type)
|
||||||
|
return unless options.validate_content_type?
|
||||||
|
return if allowed_content_type?(content_type)
|
||||||
|
|
||||||
|
raise SafeFetch::UnsupportedContentTypeError, "content-type not allowed: #{content_type}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_response_body(response, tempfile, bytes_written)
|
||||||
|
response.read_body do |chunk|
|
||||||
|
bytes_written += chunk.bytesize
|
||||||
|
raise SafeFetch::FileTooLargeError, "exceeded #{options.effective_max_bytes} bytes" if bytes_written > options.effective_max_bytes
|
||||||
|
|
||||||
|
tempfile.write(chunk)
|
||||||
|
end
|
||||||
|
|
||||||
|
bytes_written
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_content_type?(value)
|
||||||
|
mime = normalize_content_type(value)
|
||||||
|
return false if mime.blank?
|
||||||
|
|
||||||
|
options.allowed_content_type_prefixes.any? { |prefix| mime.start_with?(prefix) } ||
|
||||||
|
options.allowed_content_types.include?(mime)
|
||||||
|
end
|
||||||
|
|
||||||
|
def duplicate_tempfile(tempfile)
|
||||||
|
duplicated = tempfile.dup
|
||||||
|
duplicated.rewind
|
||||||
|
duplicated
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_content_type(value)
|
||||||
|
value.to_s.split(';').first&.strip&.downcase
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
class SafeFetch::RequestOptions
|
||||||
|
DEFAULTS = {
|
||||||
|
method: :get,
|
||||||
|
body: nil,
|
||||||
|
max_bytes: nil,
|
||||||
|
open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT,
|
||||||
|
read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT,
|
||||||
|
headers: nil,
|
||||||
|
http_basic_authentication: nil,
|
||||||
|
allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
|
||||||
|
allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES,
|
||||||
|
validate_content_type: true
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers,
|
||||||
|
:http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url
|
||||||
|
|
||||||
|
def initialize(url:, **options)
|
||||||
|
config = DEFAULTS.merge(options)
|
||||||
|
@url = url
|
||||||
|
@uri = parse_and_validate_url!(url)
|
||||||
|
@method = normalize_method(config[:method])
|
||||||
|
@body = config[:body]
|
||||||
|
@max_bytes = config[:max_bytes]
|
||||||
|
@open_timeout = config[:open_timeout]
|
||||||
|
@read_timeout = config[:read_timeout]
|
||||||
|
@headers = config[:headers]
|
||||||
|
@http_basic_authentication = config[:http_basic_authentication]
|
||||||
|
@allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes])
|
||||||
|
@allowed_content_types = Array(config[:allowed_content_types])
|
||||||
|
@validate_content_type = config[:validate_content_type]
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_max_bytes
|
||||||
|
@effective_max_bytes ||= @max_bytes || default_max_bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
def filename
|
||||||
|
@filename ||= File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_options
|
||||||
|
{
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
request_proc: request_proc,
|
||||||
|
sensitive_headers: sensitive_headers,
|
||||||
|
http_options: { open_timeout: open_timeout, read_timeout: read_timeout }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_content_type?
|
||||||
|
@validate_content_type
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_max_bytes
|
||||||
|
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||||
|
limit_mb = SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||||
|
limit_mb.megabytes
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_and_validate_url!(value)
|
||||||
|
parsed_uri = URI.parse(value)
|
||||||
|
raise SafeFetch::InvalidUrlError, 'scheme must be http or https' unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
raise SafeFetch::InvalidUrlError, 'missing host' if parsed_uri.host.blank?
|
||||||
|
|
||||||
|
parsed_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_method(value)
|
||||||
|
http_method = value.to_s.downcase.to_sym
|
||||||
|
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
|
||||||
|
|
||||||
|
raise SafeFetch::UnsupportedMethodError, "unsupported method: #{value}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_proc
|
||||||
|
return if http_basic_authentication.blank?
|
||||||
|
|
||||||
|
proc { |request| request.basic_auth(*http_basic_authentication) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def sensitive_headers
|
||||||
|
SafeFetch::DEFAULT_SENSITIVE_HEADERS | Array(headers).map { |header, _| header.to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
require 'ipaddr'
|
||||||
|
require 'resolv'
|
||||||
|
require 'ssrf_filter'
|
||||||
|
require 'uri'
|
||||||
|
|
||||||
|
module SafeOutboundUrl
|
||||||
|
class Error < StandardError; end
|
||||||
|
class InvalidUrlError < Error; end
|
||||||
|
class UnsafeUrlError < Error; end
|
||||||
|
|
||||||
|
def self.validate!(url, resolver: SsrfFilter::DEFAULT_RESOLVER)
|
||||||
|
uri = parse_http_url!(url)
|
||||||
|
ip_addresses = resolve_addresses(uri.hostname, resolver)
|
||||||
|
|
||||||
|
raise UnsafeUrlError, "Could not resolve hostname '#{uri.hostname}'" if ip_addresses.empty?
|
||||||
|
raise UnsafeUrlError, "Hostname '#{uri.hostname}' has no public ip addresses" if ip_addresses.all? { |ip| unsafe_ip_address?(ip) }
|
||||||
|
|
||||||
|
uri
|
||||||
|
rescue URI::InvalidURIError => e
|
||||||
|
raise InvalidUrlError, e.message
|
||||||
|
rescue IPAddr::InvalidAddressError, Resolv::ResolvError => e
|
||||||
|
raise UnsafeUrlError, e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse_http_url!(url)
|
||||||
|
uri = URI.parse(url.to_s)
|
||||||
|
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
raise InvalidUrlError, 'missing host' if uri.hostname.blank?
|
||||||
|
|
||||||
|
uri
|
||||||
|
end
|
||||||
|
private_class_method :parse_http_url!
|
||||||
|
|
||||||
|
def self.resolve_addresses(hostname, resolver)
|
||||||
|
Array(resolver.call(hostname)).map { |ip| ip.is_a?(IPAddr) ? ip : IPAddr.new(ip) }
|
||||||
|
end
|
||||||
|
private_class_method :resolve_addresses
|
||||||
|
|
||||||
|
def self.unsafe_ip_address?(ip_address)
|
||||||
|
SsrfFilter.send(:unsafe_ip_address?, ip_address)
|
||||||
|
end
|
||||||
|
private_class_method :unsafe_ip_address?
|
||||||
|
end
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require 'ssrf_filter'
|
||||||
|
|
||||||
class Webhooks::Trigger
|
class Webhooks::Trigger
|
||||||
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
|
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ class Webhooks::Trigger
|
|||||||
|
|
||||||
def perform_request
|
def perform_request
|
||||||
body = @payload.to_json
|
body = @payload.to_json
|
||||||
|
return perform_macro_request(body) if @webhook_type == :macro_webhook
|
||||||
|
|
||||||
RestClient::Request.execute(
|
RestClient::Request.execute(
|
||||||
method: :post,
|
method: :post,
|
||||||
url: @url,
|
url: @url,
|
||||||
@@ -41,6 +45,19 @@ class Webhooks::Trigger
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def perform_macro_request(body)
|
||||||
|
headers = safe_request_headers(body)
|
||||||
|
response = SsrfFilter.post(
|
||||||
|
@url,
|
||||||
|
body: body,
|
||||||
|
headers: headers,
|
||||||
|
sensitive_headers: headers.keys,
|
||||||
|
http_options: { open_timeout: webhook_timeout, read_timeout: webhook_timeout }
|
||||||
|
)
|
||||||
|
|
||||||
|
raise StandardError, "Webhook request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
end
|
||||||
|
|
||||||
def request_headers(body)
|
def request_headers(body)
|
||||||
headers = { content_type: :json, accept: :json }
|
headers = { content_type: :json, accept: :json }
|
||||||
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
|
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
|
||||||
@@ -52,6 +69,17 @@ class Webhooks::Trigger
|
|||||||
headers
|
headers
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def safe_request_headers(body)
|
||||||
|
headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
|
||||||
|
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
|
||||||
|
if @secret.present?
|
||||||
|
ts = Time.now.to_i.to_s
|
||||||
|
headers['X-Chatwoot-Timestamp'] = ts
|
||||||
|
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
|
||||||
|
end
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
def handle_error(error)
|
def handle_error(error)
|
||||||
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
|
||||||
return unless message
|
return unless message
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ describe Messages::Instagram::MessageBuilder do
|
|||||||
subject(:instagram_direct_message_builder) { described_class }
|
subject(:instagram_direct_message_builder) { described_class }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('www.example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.35'])
|
||||||
stub_request(:post, /graph\.instagram\.com/)
|
stub_request(:post, /graph\.instagram\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/jpeg' })
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
@@ -120,6 +123,45 @@ describe Messages::Instagram::MessageBuilder do
|
|||||||
expect(message.attachments.first.external_url).to eq(story_url)
|
expect(message.attachments.first.external_url).to eq(story_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'skips story reply attachments for blocked URLs' do
|
||||||
|
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||||
|
messaging['message']['reply_to']['story']['url'] = 'http://127.0.0.1/blocked.png'
|
||||||
|
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(messaging, instagram_inbox).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.first
|
||||||
|
expect(message.content).to eq('This is the story reply')
|
||||||
|
expect(message.attachments).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps the message when story reply attachment persistence raises a non-fetch error' do
|
||||||
|
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||||
|
story_url = messaging['message']['reply_to']['story']['url']
|
||||||
|
builder = described_class.new(messaging, instagram_inbox)
|
||||||
|
attachment_proxy = instance_double(Attachment)
|
||||||
|
file_proxy = instance_double(ActiveStorage::Attached::One)
|
||||||
|
|
||||||
|
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
|
||||||
|
stub_request(:get, story_url)
|
||||||
|
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
|
||||||
|
allow(builder).to receive(:build_story_reply_attachment).and_return(attachment_proxy)
|
||||||
|
allow(attachment_proxy).to receive(:persisted?).and_return(false)
|
||||||
|
allow(attachment_proxy).to receive(:file).and_return(file_proxy)
|
||||||
|
allow(file_proxy).to receive(:attach).and_raise(StandardError, 'boom')
|
||||||
|
|
||||||
|
expect do
|
||||||
|
builder.perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
message = instagram_inbox.messages.first
|
||||||
|
expect(message.content).to eq('This is the story reply')
|
||||||
|
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
|
||||||
|
expect(message.attachments).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates message with reply to mid' do
|
it 'creates message with reply to mid' do
|
||||||
# Create first message to ensure reply to is valid
|
# Create first message to ensure reply to is valid
|
||||||
first_messaging = dm_params[:entry][0]['messaging'][0]
|
first_messaging = dm_params[:entry][0]['messaging'][0]
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
|||||||
subject(:instagram_message_builder) { described_class }
|
subject(:instagram_message_builder) { described_class }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('www.example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.35'])
|
||||||
stub_request(:post, /graph\.facebook\.com/)
|
stub_request(:post, /graph\.facebook\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
|
.to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/jpeg' })
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
@@ -132,6 +136,68 @@ describe Messages::Instagram::Messenger::MessageBuilder do
|
|||||||
expect(message.attachments.first.external_url).to eq(story_url)
|
expect(message.attachments.first.external_url).to eq(story_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'skips story reply attachments for blocked URLs' do
|
||||||
|
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||||
|
sender_id = messaging['sender']['id']
|
||||||
|
messaging['message']['reply_to']['story']['url'] = 'http://127.0.0.1/blocked.png'
|
||||||
|
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
|
{
|
||||||
|
name: 'Jane',
|
||||||
|
id: sender_id,
|
||||||
|
account_id: instagram_messenger_inbox.account_id,
|
||||||
|
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||||
|
}.with_indifferent_access
|
||||||
|
)
|
||||||
|
|
||||||
|
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(messaging, instagram_messenger_inbox).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
message = instagram_messenger_channel.inbox.messages.first
|
||||||
|
expect(message.content).to eq('This is the story reply')
|
||||||
|
expect(message.attachments).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps the message when story reply attachment persistence raises a non-fetch error' do
|
||||||
|
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
|
||||||
|
sender_id = messaging['sender']['id']
|
||||||
|
story_url = messaging['message']['reply_to']['story']['url']
|
||||||
|
builder = described_class.new(messaging, instagram_messenger_inbox)
|
||||||
|
attachment_proxy = instance_double(Attachment)
|
||||||
|
file_proxy = instance_double(ActiveStorage::Attached::One)
|
||||||
|
|
||||||
|
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||||
|
allow(fb_object).to receive(:get_object).and_return(
|
||||||
|
{
|
||||||
|
name: 'Jane',
|
||||||
|
id: sender_id,
|
||||||
|
account_id: instagram_messenger_inbox.account_id,
|
||||||
|
profile_pic: 'https://chatwoot-assets.local/sample.png'
|
||||||
|
}.with_indifferent_access
|
||||||
|
)
|
||||||
|
stub_request(:get, story_url)
|
||||||
|
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
|
||||||
|
allow(builder).to receive(:build_story_reply_attachment).and_return(attachment_proxy)
|
||||||
|
allow(attachment_proxy).to receive(:persisted?).and_return(false)
|
||||||
|
allow(attachment_proxy).to receive(:file).and_return(file_proxy)
|
||||||
|
allow(file_proxy).to receive(:attach).and_raise(StandardError, 'boom')
|
||||||
|
|
||||||
|
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
builder.perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
message = instagram_messenger_channel.inbox.messages.first
|
||||||
|
expect(message.content).to eq('This is the story reply')
|
||||||
|
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
|
||||||
|
expect(message.attachments).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
it 'creates message with for reply with mid' do
|
it 'creates message with for reply with mid' do
|
||||||
# create first message to ensure reply to is valid
|
# create first message to ensure reply to is valid
|
||||||
first_message_data = dm_params[:entry][0]['messaging'][0]
|
first_message_data = dm_params[:entry][0]['messaging'][0]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'stringio'
|
||||||
|
|
||||||
RSpec.describe Captain::Tools::HttpTool, type: :model do
|
RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
@@ -7,6 +8,12 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
|||||||
let(:tool) { described_class.new(assistant, custom_tool) }
|
let(:tool) { described_class.new(assistant, custom_tool) }
|
||||||
let(:tool_context) { Struct.new(:state).new({}) }
|
let(:tool_context) { Struct.new(:state).new({}) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('internal.example.com').and_return(['10.0.0.1'])
|
||||||
|
end
|
||||||
|
|
||||||
describe '#active?' do
|
describe '#active?' do
|
||||||
it 'returns true when custom tool is enabled' do
|
it 'returns true when custom tool is enabled' do
|
||||||
custom_tool.update!(enabled: true)
|
custom_tool.update!(enabled: true)
|
||||||
@@ -39,6 +46,19 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
|||||||
expect(result).to eq('{"status": "success"}')
|
expect(result).to eq('{"status": "success"}')
|
||||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'preserves the previous timeout budget when fetching' do
|
||||||
|
allow(SafeFetch).to receive(:fetch) do |fetched_url, **options, &block|
|
||||||
|
expect(fetched_url).to eq('https://example.com/orders/123')
|
||||||
|
expect(options[:open_timeout]).to eq(10)
|
||||||
|
expect(options[:read_timeout]).to eq(30)
|
||||||
|
block.call(OpenStruct.new(tempfile: StringIO.new('{"status": "success"}')))
|
||||||
|
end
|
||||||
|
|
||||||
|
result = tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(result).to eq('{"status": "success"}')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with POST request' do
|
context 'with POST request' do
|
||||||
@@ -165,6 +185,15 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when handling errors' do
|
context 'when handling errors' do
|
||||||
|
it 'returns generic error message when hostname resolves to a private IP' do
|
||||||
|
custom_tool.update!(endpoint_url: 'https://internal.example.com/data')
|
||||||
|
|
||||||
|
result = tool.perform(tool_context)
|
||||||
|
|
||||||
|
expect(result).to eq('An error occurred while executing the request')
|
||||||
|
expect(WebMock).not_to have_requested(:any, 'https://internal.example.com/data')
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns generic error message on network failure' do
|
it 'returns generic error message on network failure' do
|
||||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||||
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Avatar::AvatarFromUrlJob do
|
RSpec.describe Avatar::AvatarFromUrlJob do
|
||||||
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
|
|
||||||
let(:valid_url) { 'https://example.com/avatar.png' }
|
let(:valid_url) { 'https://example.com/avatar.png' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||||
|
end
|
||||||
|
|
||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
contact = create(:contact)
|
contact = create(:contact)
|
||||||
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
|
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
|
||||||
@@ -14,7 +18,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
let(:avatarable) { create(:contact) }
|
let(:avatarable) { create(:contact) }
|
||||||
|
|
||||||
it 'attaches and updates sync attributes' do
|
it 'attaches and updates sync attributes' do
|
||||||
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
|
stub_request(:get, valid_url)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read(Rails.root.join('spec/assets/avatar.png')),
|
||||||
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
|
)
|
||||||
|
|
||||||
described_class.perform_now(avatarable, valid_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
avatarable.reload
|
avatarable.reload
|
||||||
expect(avatarable.avatar).to be_attached
|
expect(avatarable.avatar).to be_attached
|
||||||
@@ -25,7 +35,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
it 'returns early when rate limited' do
|
it 'returns early when rate limited' do
|
||||||
ts = 30.seconds.ago.iso8601
|
ts = 30.seconds.ago.iso8601
|
||||||
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
|
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
|
||||||
expect(Down).not_to receive(:download)
|
stub_request(:get, valid_url)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read(Rails.root.join('spec/assets/avatar.png')),
|
||||||
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
|
)
|
||||||
|
|
||||||
described_class.perform_now(avatarable, valid_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
avatarable.reload
|
avatarable.reload
|
||||||
expect(avatarable.avatar).not_to be_attached
|
expect(avatarable.avatar).not_to be_attached
|
||||||
@@ -33,21 +49,28 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
|
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
|
||||||
.to be > Time.zone.parse(ts)
|
.to be > Time.zone.parse(ts)
|
||||||
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
||||||
|
expect(WebMock).not_to have_requested(:get, valid_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns early when hash unchanged' do
|
it 'returns early when hash unchanged' do
|
||||||
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
|
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
|
||||||
expect(Down).not_to receive(:download)
|
stub_request(:get, valid_url)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read(Rails.root.join('spec/assets/avatar.png')),
|
||||||
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
|
)
|
||||||
|
|
||||||
described_class.perform_now(avatarable, valid_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
expect(avatarable.avatar).not_to be_attached
|
expect(avatarable.avatar).not_to be_attached
|
||||||
avatarable.reload
|
avatarable.reload
|
||||||
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
||||||
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
||||||
|
expect(WebMock).not_to have_requested(:get, valid_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates sync attributes even when URL is invalid' do
|
it 'updates sync attributes even when URL is invalid' do
|
||||||
invalid_url = 'invalid_url'
|
invalid_url = 'invalid_url'
|
||||||
expect(Down).not_to receive(:download)
|
|
||||||
described_class.perform_now(avatarable, invalid_url)
|
described_class.perform_now(avatarable, invalid_url)
|
||||||
avatarable.reload
|
avatarable.reload
|
||||||
expect(avatarable.avatar).not_to be_attached
|
expect(avatarable.avatar).not_to be_attached
|
||||||
@@ -56,17 +79,12 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'updates sync attributes when file download is valid but content type is unsupported' do
|
it 'updates sync attributes when file download is valid but content type is unsupported' do
|
||||||
temp_file = Tempfile.new(['invalid', '.xml'])
|
stub_request(:get, valid_url)
|
||||||
temp_file.write('<invalid>content</invalid>')
|
.to_return(
|
||||||
temp_file.rewind
|
status: 200,
|
||||||
|
body: '<invalid>content</invalid>',
|
||||||
uploaded = ActionDispatch::Http::UploadedFile.new(
|
headers: { 'Content-Type' => 'application/xml' }
|
||||||
tempfile: temp_file,
|
)
|
||||||
filename: 'invalid.xml',
|
|
||||||
type: 'application/xml'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(uploaded)
|
|
||||||
|
|
||||||
described_class.perform_now(avatarable, valid_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
avatarable.reload
|
avatarable.reload
|
||||||
@@ -74,9 +92,19 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
expect(avatarable.avatar).not_to be_attached
|
expect(avatarable.avatar).not_to be_attached
|
||||||
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
||||||
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
||||||
|
end
|
||||||
|
|
||||||
temp_file.close
|
it 'updates sync attributes when the avatar URL is blocked by SSRF protection' do
|
||||||
temp_file.unlink
|
blocked_url = 'http://127.0.0.1/avatar.png'
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.perform_now(avatarable, blocked_url)
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
avatarable.reload
|
||||||
|
expect(avatarable.avatar).not_to be_attached
|
||||||
|
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
||||||
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(blocked_url))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -84,7 +112,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
let(:avatarable) { create(:agent_bot) }
|
let(:avatarable) { create(:agent_bot) }
|
||||||
|
|
||||||
it 'downloads and attaches avatar' do
|
it 'downloads and attaches avatar' do
|
||||||
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
|
stub_request(:get, valid_url)
|
||||||
|
.to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read(Rails.root.join('spec/assets/avatar.png')),
|
||||||
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
|
)
|
||||||
|
|
||||||
described_class.perform_now(avatarable, valid_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
expect(avatarable.avatar).to be_attached
|
expect(avatarable.avatar).to be_attached
|
||||||
end
|
end
|
||||||
@@ -93,23 +127,30 @@ RSpec.describe Avatar::AvatarFromUrlJob do
|
|||||||
# ref: https://github.com/chatwoot/chatwoot/issues/10449
|
# ref: https://github.com/chatwoot/chatwoot/issues/10449
|
||||||
it 'does not raise error when downloaded file has no filename (invalid content)' do
|
it 'does not raise error when downloaded file has no filename (invalid content)' do
|
||||||
contact = create(:contact)
|
contact = create(:contact)
|
||||||
temp_file = Tempfile.new(['invalid', '.xml'])
|
invalid_file = Tempfile.new('avatar-without-name')
|
||||||
temp_file.write('<invalid>content</invalid>')
|
|
||||||
temp_file.rewind
|
|
||||||
|
|
||||||
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE)
|
allow(SafeFetch).to receive(:fetch)
|
||||||
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
|
.with(
|
||||||
|
valid_url,
|
||||||
|
max_bytes: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE,
|
||||||
|
allowed_content_type_prefixes: [],
|
||||||
|
allowed_content_types: %w[image/jpeg image/png image/gif]
|
||||||
|
).and_yield(
|
||||||
|
SafeFetch::Result.new(
|
||||||
|
tempfile: invalid_file,
|
||||||
|
filename: nil,
|
||||||
|
content_type: 'image/png'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
|
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
|
||||||
|
expect(contact.reload.avatar).not_to be_attached
|
||||||
temp_file.close
|
ensure
|
||||||
temp_file.unlink
|
invalid_file.close!
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'skips sync attribute updates when URL is nil' do
|
it 'skips sync attribute updates when URL is nil' do
|
||||||
contact = create(:contact)
|
contact = create(:contact)
|
||||||
expect(Down).not_to receive(:download)
|
|
||||||
|
|
||||||
expect { described_class.perform_now(contact, nil) }.not_to raise_error
|
expect { described_class.perform_now(contact, nil) }.not_to raise_error
|
||||||
|
|
||||||
contact.reload
|
contact.reload
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ describe Webhooks::InstagramEventsJob do
|
|||||||
subject(:instagram_webhook) { described_class }
|
subject(:instagram_webhook) { described_class }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
image_body = File.binread(Rails.root.join('spec/assets/sample.png'))
|
||||||
|
|
||||||
stub_request(:post, /graph\.facebook\.com/)
|
stub_request(:post, /graph\.facebook\.com/)
|
||||||
stub_request(:get, 'https://www.example.com/test.jpeg')
|
stub_request(:get, 'https://www.example.com/test.jpeg')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/jpeg' })
|
||||||
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test')
|
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/png' })
|
||||||
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test')
|
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test')
|
||||||
.to_return(status: 200, body: '', headers: {})
|
.to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/png' })
|
||||||
end
|
end
|
||||||
|
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
|
|||||||
+131
-4
@@ -9,6 +9,8 @@ RSpec.describe SafeFetch do
|
|||||||
before do
|
before do
|
||||||
allow(Resolv).to receive(:getaddresses).and_call_original
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('redirect.example.com').and_return(['93.184.216.35'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('cdn.example.com').and_return(['93.184.216.36'])
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.fetch' do
|
describe '.fetch' do
|
||||||
@@ -30,13 +32,17 @@ RSpec.describe SafeFetch do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'closes the tempfile after the block returns' do
|
it 'keeps the yielded tempfile readable after the block returns' do
|
||||||
captured = nil
|
captured = nil
|
||||||
described_class.fetch(url) { |result| captured = result.tempfile }
|
described_class.fetch(url) { |result| captured = result.tempfile }
|
||||||
expect(captured.closed?).to be true
|
expect(captured.closed?).to be false
|
||||||
|
captured.rewind
|
||||||
|
expect(captured.read.bytesize).to be > 0
|
||||||
|
ensure
|
||||||
|
captured&.close!
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'closes the tempfile even when the block raises' do
|
it 'keeps the yielded tempfile readable even when the block raises' do
|
||||||
captured = nil
|
captured = nil
|
||||||
expect do
|
expect do
|
||||||
described_class.fetch(url) do |result|
|
described_class.fetch(url) do |result|
|
||||||
@@ -44,7 +50,11 @@ RSpec.describe SafeFetch do
|
|||||||
raise 'boom'
|
raise 'boom'
|
||||||
end
|
end
|
||||||
end.to raise_error('boom')
|
end.to raise_error('boom')
|
||||||
expect(captured.closed?).to be true
|
expect(captured.closed?).to be false
|
||||||
|
captured.rewind
|
||||||
|
expect(captured.read.bytesize).to be > 0
|
||||||
|
ensure
|
||||||
|
captured&.close!
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
|
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
|
||||||
@@ -63,6 +73,38 @@ RSpec.describe SafeFetch do
|
|||||||
it 'requires a block' do
|
it 'requires a block' do
|
||||||
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
|
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'forwards custom headers to the upstream request' do
|
||||||
|
described_class.fetch(url, headers: { 'x-user' => 'secret-token' }) { nil }
|
||||||
|
|
||||||
|
expect(WebMock).to(have_requested(:get, url)
|
||||||
|
.with { |request| request.headers['X-User'] == 'secret-token' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'supports basic authentication' do
|
||||||
|
described_class.fetch(url, http_basic_authentication: %w[user password]) { nil }
|
||||||
|
|
||||||
|
expect(WebMock).to(have_requested(:get, url)
|
||||||
|
.with { |request| request.headers['Authorization']&.start_with?('Basic ') })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'supports POST requests with a request body' do
|
||||||
|
post_url = 'http://example.com/orders'
|
||||||
|
stub_request(:post, post_url)
|
||||||
|
.with(body: '{"order_id":"123"}', headers: { 'Content-Type' => 'application/json' })
|
||||||
|
.to_return(status: 200, body: '{"created":true}', headers: { 'Content-Type' => 'application/json' })
|
||||||
|
|
||||||
|
described_class.fetch(
|
||||||
|
post_url,
|
||||||
|
method: :post,
|
||||||
|
body: '{"order_id":"123"}',
|
||||||
|
headers: { 'Content-Type' => 'application/json' },
|
||||||
|
allowed_content_types: ['application/json']
|
||||||
|
) { nil }
|
||||||
|
|
||||||
|
expect(WebMock).to have_requested(:post, post_url)
|
||||||
|
.with(body: '{"order_id":"123"}', headers: { 'Content-Type' => 'application/json' })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with URL validation' do
|
context 'with URL validation' do
|
||||||
@@ -153,6 +195,46 @@ RSpec.describe SafeFetch do
|
|||||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'allows audio/mpeg responses' do
|
||||||
|
stub_request(:get, url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.new(Rails.root.join('spec/assets/sample.mp3')),
|
||||||
|
headers: { 'Content-Type' => 'audio/mpeg' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows application/pdf responses' do
|
||||||
|
stub_request(:get, url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.new(Rails.root.join('spec/assets/sample.pdf')),
|
||||||
|
headers: { 'Content-Type' => 'application/pdf' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows text/plain responses' do
|
||||||
|
stub_request(:get, url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: 'plain-text',
|
||||||
|
headers: { 'Content-Type' => 'text/plain' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows text/csv responses' do
|
||||||
|
stub_request(:get, url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: "id,name\n1,Chatwoot\n",
|
||||||
|
headers: { 'Content-Type' => 'text/csv' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
it 'strips charset/boundary parameters before comparing' do
|
it 'strips charset/boundary parameters before comparing' do
|
||||||
stub_request(:get, url).to_return(
|
stub_request(:get, url).to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -169,6 +251,51 @@ RSpec.describe SafeFetch do
|
|||||||
expect { described_class.fetch(url) { nil } }
|
expect { described_class.fetch(url) { nil } }
|
||||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'allows exact content-type matches when prefixes are empty' do
|
||||||
|
pdf_url = 'http://example.com/file.pdf'
|
||||||
|
stub_request(:get, pdf_url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: 'pdf-data',
|
||||||
|
headers: { 'Content-Type' => 'application/pdf' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.fetch(
|
||||||
|
pdf_url,
|
||||||
|
allowed_content_type_prefixes: [],
|
||||||
|
allowed_content_types: ['application/pdf']
|
||||||
|
) { nil }
|
||||||
|
end.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips content-type validation when disabled' do
|
||||||
|
stub_request(:get, url).to_return(status: 200, body: 'archive-data', headers: {})
|
||||||
|
|
||||||
|
expect { described_class.fetch(url, validate_content_type: false) { nil } }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with redirects and sensitive headers' do
|
||||||
|
it 'strips custom headers on cross-origin redirects' do
|
||||||
|
redirect_url = 'http://redirect.example.com/image.png'
|
||||||
|
final_url = 'http://cdn.example.com/image.png'
|
||||||
|
|
||||||
|
stub_request(:get, redirect_url)
|
||||||
|
.with { |request| request.headers['X-User'] == 'secret-token' }
|
||||||
|
.to_return(status: 302, headers: { 'Location' => final_url })
|
||||||
|
|
||||||
|
stub_request(:get, final_url)
|
||||||
|
.with { |request| request.headers['X-User'].blank? }
|
||||||
|
.to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/png' })
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.fetch(redirect_url, headers: { 'x-user' => 'secret-token' }) { nil }
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
expect(WebMock).to(have_requested(:get, final_url)
|
||||||
|
.with { |request| request.headers['X-User'].blank? })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with body size cap' do
|
context 'with body size cap' do
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SafeOutboundUrl do
|
||||||
|
describe '.validate!' do
|
||||||
|
let(:public_resolver) { ->(_hostname) { [IPAddr.new('93.184.216.34')] } }
|
||||||
|
let(:private_resolver) { ->(_hostname) { [IPAddr.new('127.0.0.1')] } }
|
||||||
|
|
||||||
|
it 'accepts public http urls' do
|
||||||
|
uri = described_class.validate!('https://example.com/webhook', resolver: public_resolver)
|
||||||
|
|
||||||
|
expect(uri).to be_a(URI::HTTPS)
|
||||||
|
expect(uri.host).to eq('example.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects non-http schemes' do
|
||||||
|
expect do
|
||||||
|
described_class.validate!('javascript:alert(1)', resolver: public_resolver)
|
||||||
|
end.to raise_error(described_class::InvalidUrlError, 'scheme must be http or https')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects hosts that resolve only to private addresses' do
|
||||||
|
expect do
|
||||||
|
described_class.validate!('http://internal.example.test/webhook', resolver: private_resolver)
|
||||||
|
end.to raise_error(described_class::UnsafeUrlError, "Hostname 'internal.example.test' has no public ip addresses")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -43,6 +43,25 @@ describe Webhooks::Trigger do
|
|||||||
trigger.execute(url, payload, webhook_type)
|
trigger.execute(url, payload, webhook_type)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when webhook type is macro' do
|
||||||
|
let(:webhook_type) { :macro_webhook }
|
||||||
|
|
||||||
|
it 'uses ssrf-protected delivery for the request' do
|
||||||
|
payload = { hello: :hello }
|
||||||
|
response = Net::HTTPOK.new('1.1', '200', 'OK')
|
||||||
|
|
||||||
|
expect(SsrfFilter).to receive(:post).with(
|
||||||
|
url,
|
||||||
|
body: payload.to_json,
|
||||||
|
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' },
|
||||||
|
sensitive_headers: %w[Content-Type Accept],
|
||||||
|
http_options: { open_timeout: webhook_timeout, read_timeout: webhook_timeout }
|
||||||
|
).and_return(response)
|
||||||
|
|
||||||
|
trigger.execute(url, payload, webhook_type)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'updates message status if webhook fails for message-created event' do
|
it 'updates message status if webhook fails for message-created event' do
|
||||||
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ RSpec.describe Macro do
|
|||||||
expect(macro).not_to be_valid
|
expect(macro).not_to be_valid
|
||||||
expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.'])
|
expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects unsafe webhook action urls' do
|
||||||
|
allow(SafeOutboundUrl).to receive(:validate!).and_raise(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
|
||||||
|
|
||||||
|
macro = FactoryBot.build(
|
||||||
|
:macro,
|
||||||
|
account: account,
|
||||||
|
created_by: admin,
|
||||||
|
updated_by: admin,
|
||||||
|
actions: [{ action_name: 'send_webhook_event', action_params: ['http://169.254.169.254/latest/meta-data'] }]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(macro).not_to be_valid
|
||||||
|
expect(macro.errors.full_messages).to include('Actions Webhook URL for send_webhook_event must be a public http(s) URL.')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#set_visibility' do
|
describe '#set_visibility' do
|
||||||
|
|||||||
@@ -171,8 +171,24 @@ RSpec.describe Macros::ExecutionService, type: :service do
|
|||||||
|
|
||||||
describe '#send_webhook_event' do
|
describe '#send_webhook_event' do
|
||||||
it 'sends a webhook event' do
|
it 'sends a webhook event' do
|
||||||
expect(WebhookJob).to receive(:perform_later)
|
allow(SafeOutboundUrl).to receive(:validate!).and_return(URI.parse('https://example.com/webhook'))
|
||||||
|
|
||||||
|
expect(WebhookJob).to receive(:perform_later).with(
|
||||||
|
'https://example.com/webhook',
|
||||||
|
hash_including(event: 'macro.executed'),
|
||||||
|
:macro_webhook
|
||||||
|
)
|
||||||
|
|
||||||
service.send(:send_webhook_event, ['https://example.com/webhook'])
|
service.send(:send_webhook_event, ['https://example.com/webhook'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'raises when the webhook url is unsafe' do
|
||||||
|
allow(SafeOutboundUrl).to receive(:validate!).and_raise(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
|
||||||
|
|
||||||
|
expect(WebhookJob).not_to receive(:perform_later)
|
||||||
|
expect do
|
||||||
|
service.send(:send_webhook_event, ['http://169.254.169.254/latest/meta-data'])
|
||||||
|
end.to raise_error(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ require 'rails_helper'
|
|||||||
|
|
||||||
describe Sms::IncomingMessageService do
|
describe Sms::IncomingMessageService do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('test.com').and_return(['93.184.216.34'])
|
||||||
|
end
|
||||||
|
|
||||||
let!(:sms_channel) { create(:channel_sms) }
|
let!(:sms_channel) { create(:channel_sms) }
|
||||||
let(:params) do
|
let(:params) do
|
||||||
{
|
{
|
||||||
@@ -77,8 +82,10 @@ describe Sms::IncomingMessageService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates attachment messages and ignores .smil files' do
|
it 'creates attachment messages and ignores .smil files' do
|
||||||
stub_request(:get, 'http://test.com/test.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {})
|
stub_request(:get, 'http://test.com/test.png')
|
||||||
stub_request(:get, 'http://test.com/test2.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {})
|
.to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
|
||||||
|
stub_request(:get, 'http://test.com/test2.png')
|
||||||
|
.to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
|
||||||
|
|
||||||
media_params = { 'media': [
|
media_params = { 'media': [
|
||||||
'http://test.com/test.smil',
|
'http://test.com/test.smil',
|
||||||
@@ -92,6 +99,29 @@ describe Sms::IncomingMessageService do
|
|||||||
expect(sms_channel.inbox.messages.first.content).to eq('test message')
|
expect(sms_channel.inbox.messages.first.content).to eq('test message')
|
||||||
expect(sms_channel.inbox.messages.first.attachments.present?).to be true
|
expect(sms_channel.inbox.messages.first.attachments.present?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates audio attachment messages' do
|
||||||
|
stub_request(:get, 'http://test.com/test.mp3')
|
||||||
|
.to_return(status: 200, body: File.read('spec/assets/sample.mp3'), headers: { 'Content-Type' => 'audio/mpeg' })
|
||||||
|
|
||||||
|
media_params = { 'media': ['http://test.com/test.mp3'] }.with_indifferent_access
|
||||||
|
|
||||||
|
described_class.new(inbox: sms_channel.inbox, params: params.merge(media_params)).perform
|
||||||
|
|
||||||
|
attachment = sms_channel.inbox.messages.first.attachments.first
|
||||||
|
expect(attachment.file_type).to eq('audio')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips blocked attachment URLs' do
|
||||||
|
media_params = { 'media': ['http://127.0.0.1/blocked.png'] }.with_indifferent_access
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(inbox: sms_channel.inbox, params: params.merge(media_params)).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
expect(sms_channel.inbox.messages.first.content).to eq('test message')
|
||||||
|
expect(sms_channel.inbox.messages.first.attachments).to be_empty
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,31 +2,33 @@ require 'rails_helper'
|
|||||||
|
|
||||||
describe Telegram::IncomingMessageService do
|
describe Telegram::IncomingMessageService do
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
|
||||||
stub_request(:any, /api.telegram.org/).to_return(headers: { content_type: 'application/json' }, body: {}.to_json, status: 200)
|
stub_request(:any, /api.telegram.org/).to_return(headers: { content_type: 'application/json' }, body: {}.to_json, status: 200)
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.png'),
|
body: File.read('spec/assets/sample.png'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
)
|
)
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.mov').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.mov').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.mov'),
|
body: File.read('spec/assets/sample.mov'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'video/quicktime' }
|
||||||
)
|
)
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.mp3'),
|
body: File.read('spec/assets/sample.mp3'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'audio/mpeg' }
|
||||||
)
|
)
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.ogg').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.ogg').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.ogg'),
|
body: File.read('spec/assets/sample.ogg'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'audio/ogg' }
|
||||||
)
|
)
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.pdf').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.pdf').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.pdf'),
|
body: File.read('spec/assets/sample.pdf'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'application/pdf' }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -323,6 +325,29 @@ describe Telegram::IncomingMessageService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when the download path is an SSRF target' do
|
||||||
|
it 'skips the attachment' do
|
||||||
|
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('http://127.0.0.1/blocked.pdf')
|
||||||
|
params = {
|
||||||
|
'update_id' => 2_342_342_343_242,
|
||||||
|
'message' => {
|
||||||
|
'document' => {
|
||||||
|
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
|
||||||
|
'file_name' => 'blocked.pdf',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_size' => 536_392
|
||||||
|
}
|
||||||
|
}.merge(message_params)
|
||||||
|
}.with_indifferent_access
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
expect(telegram_channel.inbox.messages.first.attachments).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when valid location message params' do
|
context 'when valid location message params' do
|
||||||
it 'creates appropriate conversations, message and contacts' do
|
it 'creates appropriate conversations, message and contacts' do
|
||||||
params = {
|
params = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ RSpec.describe Tiktok::MessageService do
|
|||||||
let(:inbox) { channel.inbox }
|
let(:inbox) { channel.inbox }
|
||||||
let(:contact) { create(:contact, account: account) }
|
let(:contact) { create(:contact, account: account) }
|
||||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||||
|
let(:image_download_url) { 'https://chatwoot-assets.local/tiktok.png' }
|
||||||
let(:text_content) do
|
let(:text_content) do
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -20,6 +21,12 @@ RSpec.describe Tiktok::MessageService do
|
|||||||
}.deep_symbolize_keys
|
}.deep_symbolize_keys
|
||||||
end
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
|
||||||
|
allow(channel).to receive(:validated_access_token).and_return('valid-access-token')
|
||||||
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
subject(:perform_text_message) do
|
subject(:perform_text_message) do
|
||||||
service = described_class.new(channel: channel, content: current_content)
|
service = described_class.new(channel: channel, content: current_content)
|
||||||
@@ -115,15 +122,15 @@ RSpec.describe Tiktok::MessageService do
|
|||||||
to_user: { id: 'biz-123' }
|
to_user: { id: 'biz-123' }
|
||||||
}.deep_symbolize_keys
|
}.deep_symbolize_keys
|
||||||
|
|
||||||
tempfile = Tempfile.new(['tiktok', '.png'])
|
stub_request(:get, image_download_url)
|
||||||
tempfile.write('fake-image')
|
.with(headers: { 'X-User' => 'valid-access-token' })
|
||||||
tempfile.rewind
|
.to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
|
||||||
tempfile.define_singleton_method(:original_filename) { 'tiktok.png' }
|
|
||||||
tempfile.define_singleton_method(:content_type) { 'image/png' }
|
|
||||||
|
|
||||||
service = described_class.new(channel: channel, content: content)
|
service = described_class.new(channel: channel, content: content)
|
||||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||||
allow(service).to receive(:fetch_attachment).and_return(tempfile)
|
allow(service).to receive(:tiktok_client)
|
||||||
|
.with(channel)
|
||||||
|
.and_return(instance_double(Tiktok::Client, file_download_url: image_download_url))
|
||||||
|
|
||||||
service.perform
|
service.perform
|
||||||
|
|
||||||
@@ -131,8 +138,33 @@ RSpec.describe Tiktok::MessageService do
|
|||||||
expect(message.attachments.count).to eq(1)
|
expect(message.attachments.count).to eq(1)
|
||||||
expect(message.attachments.last.file_type).to eq('image')
|
expect(message.attachments.last.file_type).to eq('image')
|
||||||
expect(message.attachments.last.file).to be_attached
|
expect(message.attachments.last.file).to be_attached
|
||||||
ensure
|
expect(WebMock).to(have_requested(:get, image_download_url)
|
||||||
tempfile.close!
|
.with { |request| request.headers['X-User'] == 'valid-access-token' })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips blocked image attachment URLs' do
|
||||||
|
content = {
|
||||||
|
type: 'image',
|
||||||
|
message_id: 'tt-msg-5',
|
||||||
|
timestamp: 1_700_000_000_000,
|
||||||
|
conversation_id: 'tt-conv-1',
|
||||||
|
image: { media_id: 'media-1' },
|
||||||
|
from: 'Alice',
|
||||||
|
from_user: { id: 'user-1' },
|
||||||
|
to: 'Biz',
|
||||||
|
to_user: { id: 'biz-123' }
|
||||||
|
}.deep_symbolize_keys
|
||||||
|
|
||||||
|
service = described_class.new(channel: channel, content: content)
|
||||||
|
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||||
|
allow(service).to receive(:tiktok_client)
|
||||||
|
.with(channel)
|
||||||
|
.and_return(instance_double(Tiktok::Client, file_download_url: 'http://127.0.0.1/blocked.png'))
|
||||||
|
|
||||||
|
expect { service.perform }.not_to raise_error
|
||||||
|
|
||||||
|
message = Message.last
|
||||||
|
expect(message.attachments).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when lock_to_single_conversation is enabled' do
|
context 'when lock_to_single_conversation is enabled' do
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ describe Twilio::IncomingMessageService do
|
|||||||
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
|
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
|
||||||
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
|
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
|
||||||
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
it 'creates a new message in existing conversation' do
|
it 'creates a new message in existing conversation' do
|
||||||
params = {
|
params = {
|
||||||
@@ -174,6 +179,8 @@ describe Twilio::IncomingMessageService do
|
|||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||||
|
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3')
|
||||||
|
.to_return(status: 200, body: File.read('spec/assets/sample.mp3'), headers: { 'Content-Type' => 'audio/mpeg' })
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:params_with_attachment) do
|
let(:params_with_attachment) do
|
||||||
@@ -195,14 +202,35 @@ describe Twilio::IncomingMessageService do
|
|||||||
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
||||||
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'downloads the attachment using basic auth' do
|
||||||
|
described_class.new(params: params_with_attachment).perform
|
||||||
|
|
||||||
|
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
|
.with { |request| request.headers['Authorization']&.start_with?('Basic ') })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates an audio attachment when the provider serves audio media' do
|
||||||
|
params_with_audio_attachment = params_with_attachment.merge(
|
||||||
|
MediaContentType0: 'audio/mpeg',
|
||||||
|
MediaUrl0: 'https://chatwoot-assets.local/sample.mp3'
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.new(params: params_with_audio_attachment).perform
|
||||||
|
|
||||||
|
attachment = conversation.reload.messages.last.attachments.first
|
||||||
|
expect(attachment.file_type).to eq('audio')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when there is an error downloading the attachment' do
|
context 'when there is an error downloading the attachment' do
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
.to_raise(Down::Error.new('Download error'))
|
.with { |request| request.headers['Authorization']&.start_with?('Basic ') }
|
||||||
|
.to_raise(SocketError.new('Download error'))
|
||||||
|
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
|
.with { |request| request.headers['Authorization'].blank? }
|
||||||
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -227,6 +255,8 @@ describe Twilio::IncomingMessageService do
|
|||||||
expect(conversation.reload.messages.last.content).to eq('testing3')
|
expect(conversation.reload.messages.last.content).to eq('testing3')
|
||||||
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
expect(conversation.reload.messages.last.attachments.count).to eq(1)
|
||||||
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
|
||||||
|
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
|
.with { |request| request.headers['Authorization'].blank? })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -283,6 +313,31 @@ describe Twilio::IncomingMessageService do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when an attachment URL is an SSRF target' do
|
||||||
|
let(:params_with_blocked_attachment) do
|
||||||
|
{
|
||||||
|
SmsSid: 'SMxx',
|
||||||
|
From: '+12345',
|
||||||
|
AccountSid: 'ACxxx',
|
||||||
|
MessagingServiceSid: twilio_channel.messaging_service_sid,
|
||||||
|
Body: 'blocked media',
|
||||||
|
NumMedia: '1',
|
||||||
|
MediaContentType0: 'image/jpeg',
|
||||||
|
MediaUrl0: 'http://127.0.0.1/blocked.png'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates the message and skips the attachment' do
|
||||||
|
expect do
|
||||||
|
described_class.new(params: params_with_blocked_attachment).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
message = conversation.reload.messages.last
|
||||||
|
expect(message.content).to eq('blocked media')
|
||||||
|
expect(message.attachments.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when ProfileName is provided for WhatsApp' do
|
context 'when ProfileName is provided for WhatsApp' do
|
||||||
it 'uses ProfileName as contact name' do
|
it 'uses ProfileName as contact name' do
|
||||||
params = {
|
params = {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ require 'rails_helper'
|
|||||||
describe Whatsapp::IncomingMessageService do
|
describe Whatsapp::IncomingMessageService do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('waba.360dialog.io').and_return(['93.184.216.34'])
|
||||||
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -214,7 +216,7 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
|
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.png'),
|
body: File.read('spec/assets/sample.png'),
|
||||||
headers: {}
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
)
|
)
|
||||||
params = {
|
params = {
|
||||||
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
|
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
|
||||||
@@ -231,6 +233,47 @@ describe Whatsapp::IncomingMessageService do
|
|||||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
||||||
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
|
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates audio attachments when the provider serves audio media' do
|
||||||
|
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read('spec/assets/sample.mp3'),
|
||||||
|
headers: { 'Content-Type' => 'audio/mpeg' }
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
|
||||||
|
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
|
||||||
|
'audio' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||||
|
'mime_type' => 'audio/mpeg',
|
||||||
|
'sha256' => '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db' },
|
||||||
|
'timestamp' => '1633034394', 'type' => 'audio' }]
|
||||||
|
}.with_indifferent_access
|
||||||
|
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
|
||||||
|
attachment = whatsapp_channel.inbox.messages.first.attachments.first
|
||||||
|
expect(attachment.file_type).to eq('audio')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips blocked attachment URLs' do
|
||||||
|
allow(whatsapp_channel).to receive(:media_url).and_return('http://127.0.0.1/blocked.png')
|
||||||
|
params = {
|
||||||
|
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
|
||||||
|
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
|
||||||
|
'image' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'sha256' => '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db',
|
||||||
|
'caption' => 'Check out my product!' },
|
||||||
|
'timestamp' => '1633034394', 'type' => 'image' }]
|
||||||
|
}.with_indifferent_access
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
||||||
|
expect(whatsapp_channel.inbox.messages.first.attachments).to be_empty
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when valid location message params' do
|
context 'when valid location message params' do
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ require 'rails_helper'
|
|||||||
|
|
||||||
describe Whatsapp::IncomingMessageWhatsappCloudService do
|
describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
|
||||||
|
end
|
||||||
|
|
||||||
after do
|
after do
|
||||||
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') { |key| Redis::Alfred.delete(key) }
|
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') { |key| Redis::Alfred.delete(key) }
|
||||||
end
|
end
|
||||||
@@ -42,6 +47,52 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
|||||||
expect_message_has_attachment
|
expect_message_has_attachment
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates audio attachments when the provider serves audio media' do
|
||||||
|
params[:entry][0][:changes][0][:value][:messages][0] = {
|
||||||
|
from: '2423423243',
|
||||||
|
audio: {
|
||||||
|
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||||
|
mime_type: 'audio/mpeg',
|
||||||
|
sha256: '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db'
|
||||||
|
},
|
||||||
|
timestamp: '1664799904',
|
||||||
|
type: 'audio'
|
||||||
|
}
|
||||||
|
stub_media_url_request(url: 'https://chatwoot-assets.local/sample.mp3')
|
||||||
|
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
|
||||||
|
status: 200,
|
||||||
|
body: File.read('spec/assets/sample.mp3'),
|
||||||
|
headers: { 'Content-Type' => 'audio/mpeg' }
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
|
||||||
|
attachment = whatsapp_channel.inbox.messages.first.attachments.first
|
||||||
|
expect(attachment.file_type).to eq('audio')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sends the provider headers when downloading the resolved media URL' do
|
||||||
|
stub_media_url_request
|
||||||
|
stub_sample_png_request
|
||||||
|
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
|
||||||
|
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
|
||||||
|
.with { |request| request.headers['Authorization']&.start_with?('Bearer ') })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips blocked resolved media URLs' do
|
||||||
|
stub_media_url_request(url: 'http://127.0.0.1/blocked.png')
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||||
|
end.not_to raise_error
|
||||||
|
|
||||||
|
expect_conversation_created
|
||||||
|
expect_message_content
|
||||||
|
expect(whatsapp_channel.inbox.messages.first.attachments).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
it 'increments reauthorization count if fetching attachment fails' do
|
it 'increments reauthorization count if fetching attachment fails' do
|
||||||
stub_request(
|
stub_request(
|
||||||
:get,
|
:get,
|
||||||
@@ -169,7 +220,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
|||||||
|
|
||||||
# Métodos auxiliares para reduzir o tamanho do exemplo
|
# Métodos auxiliares para reduzir o tamanho do exemplo
|
||||||
|
|
||||||
def stub_media_url_request
|
def stub_media_url_request(url: 'https://chatwoot-assets.local/sample.png')
|
||||||
stub_request(
|
stub_request(
|
||||||
:get,
|
:get,
|
||||||
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
|
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
|
||||||
@@ -177,7 +228,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
url: 'https://chatwoot-assets.local/sample.png',
|
url: url,
|
||||||
mime_type: 'image/jpeg',
|
mime_type: 'image/jpeg',
|
||||||
sha256: 'sha256',
|
sha256: 'sha256',
|
||||||
file_size: 'SIZE',
|
file_size: 'SIZE',
|
||||||
@@ -190,7 +241,8 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
|||||||
def stub_sample_png_request
|
def stub_sample_png_request
|
||||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||||
status: 200,
|
status: 200,
|
||||||
body: File.read('spec/assets/sample.png')
|
body: File.read('spec/assets/sample.png'),
|
||||||
|
headers: { 'Content-Type' => 'image/png' }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user