diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index d18be0caa..872f68640 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -273,6 +273,10 @@ export default { return MESSAGE_MAX_LENGTH.GENERAL; }, showFileUpload() { + const { image_send: imageSend } = + this.currentChat?.additional_attributes?.tiktok_capabilities ?? {}; + const tiktokAttachmentSupported = imageSend ?? true; + return ( this.isAWebWidgetInbox || this.isAFacebookInbox || @@ -283,7 +287,7 @@ export default { this.isATelegramChannel || this.isALineChannel || this.isAnInstagramChannel || - this.isATiktokChannel + (this.isATiktokChannel && tiktokAttachmentSupported) ); }, replyButtonLabel() { @@ -706,6 +710,7 @@ export default { // Don't handle paste if editor is disabled if (this.isEditorDisabled) return; + if (!this.showFileUpload && !this.isOnPrivateNote) return; // Filter valid files (non-zero size) Array.from(e.clipboardData.files) @@ -1025,6 +1030,8 @@ export default { }); }, attachFile({ blob, file }) { + if (!this.showFileUpload && !this.isOnPrivateNote) return; + const reader = new FileReader(); reader.readAsDataURL(file.file); reader.onloadend = () => { diff --git a/app/services/tiktok/client.rb b/app/services/tiktok/client.rb index dfbe8ba1f..5b112d8d7 100644 --- a/app/services/tiktok/client.rb +++ b/app/services/tiktok/client.rb @@ -1,3 +1,6 @@ +require 'faraday' +require 'faraday/multipart' + class Tiktok::Client # Always use Tiktok::TokenService to get a valid access token pattr_initialize [:business_id!, :access_token!] @@ -31,14 +34,32 @@ class Tiktok::Client json['data']['download_url'] end + def image_send_capable?(conversation_id, conversation_type: 'SINGLE') + endpoint = "#{api_base_url}/business/message/capabilities/get/" + headers = { 'Access-Token': access_token } + query = { + business_id: business_id, + conversation_id: conversation_id, + conversation_type: conversation_type, + capability_types: ['IMAGE_SEND'].to_json + } + + response = HTTParty.get(endpoint, query: query, headers: headers) + json = process_json_response(response, 'Failed to fetch TikTok message capabilities') + capabilities = json.dig('data', 'capability_infos') || [] + image_send = capabilities.find { |capability| capability['capability_type'] == 'IMAGE_SEND' } + + image_send&.[]('capability_result') == true + end + def send_text_message(conversation_id, text, referenced_message_id: nil) send_message(conversation_id, 'TEXT', text, referenced_message_id: referenced_message_id) end - def send_media_message(conversation_id, attachment, referenced_message_id: nil) + def send_media_message(conversation_id, attachment) # As of now, only IMAGE media type is supported - media_id = upload_media(attachment.file, 'IMAGE') - send_message(conversation_id, 'IMAGE', media_id, referenced_message_id: referenced_message_id) + media_id = upload_media(attachment.file.blob, 'IMAGE') + send_message(conversation_id, 'IMAGE', media_id) end private @@ -69,31 +90,39 @@ class Tiktok::Client json['data']['message']['message_id'] end - def upload_media(file, media_type = 'IMAGE') + def upload_media(blob, media_type = 'IMAGE') endpoint = "#{api_base_url}/business/message/media/upload/" - headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' } - file.open do |temp_file| - body = { + blob.open do |temp_file| + temp_file.rewind + payload = { business_id: business_id, media_type: media_type, - file: temp_file + file: Faraday::Multipart::FilePart.new(temp_file, blob.content_type || 'application/octet-stream', blob.filename.to_s) } - response = HTTParty.post(endpoint, body: body, headers: headers) + response = multipart_connection.post(endpoint, payload) do |request| + request.headers['Access-Token'] = access_token + end json = process_json_response(response, 'Failed to upload TikTok media') json['data']['media_id'] end end + def multipart_connection + @multipart_connection ||= Faraday.new do |faraday| + faraday.request :multipart + end + end + def api_base_url "https://business-api.tiktok.com/open_api/#{GlobalConfigService.load('TIKTOK_API_VERSION', 'v1.3')}" end def process_json_response(response, error_prefix) unless response.success? - Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}" - raise "#{response.code}: #{response.body}" + Rails.logger.error "#{error_prefix}. Status: #{response_status(response)}, Body: #{response.body}" + raise "#{response_status(response)}: #{response.body}" end res = JSON.parse(response.body) @@ -101,4 +130,8 @@ class Tiktok::Client res end + + def response_status(response) + response.respond_to?(:code) ? response.code : response.status + end end diff --git a/app/services/tiktok/messaging_helpers.rb b/app/services/tiktok/messaging_helpers.rb index 0f9d9e0b3..ce2aea817 100644 --- a/app/services/tiktok/messaging_helpers.rb +++ b/app/services/tiktok/messaging_helpers.rb @@ -48,14 +48,26 @@ module Tiktok::MessagingHelpers inbox_id: channel.inbox.id, contact_id: contact_inbox.contact.id, contact_inbox_id: contact_inbox.id, - additional_attributes: conversation_additional_attributes(tt_conversation_id) + additional_attributes: conversation_additional_attributes(channel, tt_conversation_id) } end - def conversation_additional_attributes(tt_conversation_id) - { - conversation_id: tt_conversation_id - } + def conversation_additional_attributes(channel, tt_conversation_id) + attributes = { conversation_id: tt_conversation_id } + capabilities = tiktok_conversation_capabilities(channel, tt_conversation_id) + attributes[:tiktok_capabilities] = capabilities if capabilities.present? + attributes + end + + def tiktok_conversation_capabilities(channel, tt_conversation_id) + image_send = tiktok_client(channel).image_send_capable?(tt_conversation_id) + { image_send: image_send, updated_at: Time.current.iso8601 } + rescue StandardError => e + Rails.logger.error( + 'Failed to fetch TikTok conversation capabilities ' \ + "for tt_conversation_id=#{tt_conversation_id}, business_id=#{channel.business_id}: #{e.class}: #{e.message}" + ) + {} end def find_message(tt_conversation_id, tt_message_id) diff --git a/app/services/tiktok/send_on_tiktok_service.rb b/app/services/tiktok/send_on_tiktok_service.rb index d7db5f326..58771cd05 100644 --- a/app/services/tiktok/send_on_tiktok_service.rb +++ b/app/services/tiktok/send_on_tiktok_service.rb @@ -1,4 +1,7 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService + SUPPORTED_IMAGE_CONTENT_TYPES = %w[image/jpeg image/png].freeze + MAX_IMAGE_SIZE = 3.megabytes + private def channel_class @@ -18,8 +21,22 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService def validate_message_support! return unless message.attachments.any? + raise 'Sending attachments with text is not supported on TikTok.' if message.outgoing_content.present? raise 'Sending multiple attachments in a single TikTok message is not supported.' unless message.attachments.one? + + validate_attachment_support!(message.attachments.first) + end + + def validate_attachment_support!(attachment) + raise 'Sending image attachments is not supported for this TikTok conversation.' unless image_send_capable? + raise 'Only image attachments are supported on TikTok.' unless attachment.image? + raise 'TikTok supports only JPG and PNG images.' unless SUPPORTED_IMAGE_CONTENT_TYPES.include?(attachment.file.content_type) + raise 'TikTok image attachments must be smaller than 3 MB.' if attachment.file.byte_size > MAX_IMAGE_SIZE + end + + def image_send_capable? + message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send') != false end def send_message @@ -27,7 +44,7 @@ class Tiktok::SendOnTiktokService < Base::SendOnChannelService tt_referenced_message_id = message.content_attributes['in_reply_to_external_id'] if message.attachments.any? - tiktok_client.send_media_message(tt_conversation_id, message.attachments.first, referenced_message_id: tt_referenced_message_id) + tiktok_client.send_media_message(tt_conversation_id, message.attachments.first) else tiktok_client.send_text_message(tt_conversation_id, message.outgoing_content, referenced_message_id: tt_referenced_message_id) end diff --git a/spec/services/tiktok/client_spec.rb b/spec/services/tiktok/client_spec.rb new file mode 100644 index 000000000..9bf02be91 --- /dev/null +++ b/spec/services/tiktok/client_spec.rb @@ -0,0 +1,136 @@ +require 'rails_helper' + +RSpec.describe Tiktok::Client do + let(:client) { described_class.new(business_id: 'biz-123', access_token: 'token-123') } + let(:response) { instance_double(HTTParty::Response) } + + describe '#image_send_capable?' do + before do + allow(HTTParty).to receive(:get).and_return(response) + allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3') + end + + it 'returns true when IMAGE_SEND capability is enabled' do + allow(client).to receive(:process_json_response).with( + response, + 'Failed to fetch TikTok message capabilities' + ).and_return( + { + 'data' => { + 'capability_infos' => [ + { 'capability_type' => 'IMAGE_SEND', 'capability_result' => true } + ] + } + } + ) + + result = client.image_send_capable?('tt-conv-1') + + expect(result).to be(true) + expect(HTTParty).to have_received(:get).with( + 'https://business-api.tiktok.com/open_api/v1.3/business/message/capabilities/get/', + query: { + business_id: 'biz-123', + conversation_id: 'tt-conv-1', + conversation_type: 'SINGLE', + capability_types: '["IMAGE_SEND"]' + }, + headers: { 'Access-Token': 'token-123' } + ) + end + + it 'returns false when IMAGE_SEND capability is not enabled' do + allow(client).to receive(:process_json_response).with( + response, + 'Failed to fetch TikTok message capabilities' + ).and_return( + { + 'data' => { + 'capability_infos' => [ + { 'capability_type' => 'IMAGE_SEND', 'capability_result' => false } + ] + } + } + ) + + result = client.image_send_capable?('tt-conv-1') + + expect(result).to be(false) + end + end + + describe '#upload_media' do + let(:connection) { instance_double(Faraday::Connection) } + let(:request) { instance_double(Faraday::Request, headers: {}) } + let(:response) { instance_double(Faraday::Response, success?: true, body: response_body) } + let(:response_body) do + { + code: 0, + message: 'OK', + data: { media_id: 'media-123' } + }.to_json + end + let(:blob) do + instance_double( + ActiveStorage::Blob, + content_type: 'image/png', + filename: ActiveStorage::Filename.new('avatar.png') + ) + end + + before do + allow(GlobalConfigService).to receive(:load).with('TIKTOK_API_VERSION', 'v1.3').and_return('v1.3') + allow(Faraday).to receive(:new).and_return(connection) + allow(blob).to receive(:open) do |&block| + File.open(Rails.root.join('spec/assets/avatar.png'), 'rb', &block) + end + end + + it 'posts media upload with access token header' do + captured_endpoint = nil + allow(connection).to receive(:post) do |endpoint, _payload, &block| + captured_endpoint = endpoint + block.call(request) + response + end + + media_id = client.send(:upload_media, blob) + + expect(media_id).to eq('media-123') + expect(captured_endpoint).to eq('https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/') + expect(request.headers['Access-Token']).to eq('token-123') + end + + it 'uploads media as a multipart file with filename and content type' do + captured_payload = nil + allow(connection).to receive(:post) do |_endpoint, payload, &block| + captured_payload = payload + block.call(request) + response + end + + client.send(:upload_media, blob) + + expect(captured_payload[:business_id]).to eq('biz-123') + expect(captured_payload[:media_type]).to eq('IMAGE') + expect(captured_payload[:file]).to be_a(Faraday::Multipart::FilePart) + expect(captured_payload[:file].content_type).to eq('image/png') + expect(captured_payload[:file].original_filename).to eq('avatar.png') + end + end + + describe '#send_media_message' do + let(:file) { Struct.new(:blob).new('blob') } + let(:attachment) { instance_double(Attachment, file: file) } + + it 'sends image messages' do + allow(client).to receive(:upload_media).with('blob', 'IMAGE').and_return('media-123') + allow(client).to receive(:send_message).and_return('tt-msg-123') + + message_id = client.send_media_message('tt-conv-1', attachment) + + expect(message_id).to eq('tt-msg-123') + expect(client).to have_received(:send_message).with('tt-conv-1', 'IMAGE', 'media-123') + end + end +end diff --git a/spec/services/tiktok/message_service_spec.rb b/spec/services/tiktok/message_service_spec.rb index ee7b113ac..75878fe9f 100644 --- a/spec/services/tiktok/message_service_spec.rb +++ b/spec/services/tiktok/message_service_spec.rb @@ -6,10 +6,11 @@ RSpec.describe Tiktok::MessageService do let(:inbox) { channel.inbox } let(:contact) { create(:contact, account: account) } let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') } + let(:tiktok_client) { instance_double(Tiktok::Client, image_send_capable?: true) } let(:text_content) do { type: 'text', - message_id: 'tt-msg-lock', + message_id: 'tt-msg-1', timestamp: 1_700_000_000_000, conversation_id: 'tt-conv-1', text: { body: 'Hello from TikTok' }, @@ -20,6 +21,10 @@ RSpec.describe Tiktok::MessageService do }.deep_symbolize_keys end + before do + allow(Tiktok::Client).to receive(:new).and_return(tiktok_client) + end + describe '#perform' do subject(:perform_text_message) do service = described_class.new(channel: channel, content: current_content) @@ -30,20 +35,8 @@ RSpec.describe Tiktok::MessageService do let(:current_content) { text_content } it 'creates an incoming text message' do - content = { - type: 'text', - message_id: 'tt-msg-1', - timestamp: 1_700_000_000_000, - conversation_id: 'tt-conv-1', - text: { body: 'Hello from TikTok' }, - from: 'Alice', - from_user: { id: 'user-1' }, - to: 'Biz', - to_user: { id: 'biz-123' } - }.deep_symbolize_keys - expect do - service = described_class.new(channel: channel, content: content) + service = described_class.new(channel: channel, content: text_content) allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) service.perform end.to change(Message, :count).by(1) @@ -57,6 +50,18 @@ RSpec.describe Tiktok::MessageService do expect(message.content_attributes['is_unsupported']).to be_nil end + it 'stores TikTok conversation capabilities when creating a new conversation' do + service = described_class.new(channel: channel, content: text_content) + allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) + + service.perform + + message = Message.last + expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'image_send')).to be(true) + expect(message.conversation.additional_attributes.dig('tiktok_capabilities', 'updated_at')).to be_present + expect(tiktok_client).to have_received(:image_send_capable?).with('tt-conv-1') + end + it 'creates an incoming unsupported message for non-supported types' do content = { type: 'sticker', @@ -135,6 +140,30 @@ RSpec.describe Tiktok::MessageService do tempfile.close! end + it 'creates a conversation even when capability lookup fails' do + allow(tiktok_client).to receive(:image_send_capable?).and_raise('TikTok capability API error') + + content = { + type: 'text', + message_id: 'tt-msg-5', + timestamp: 1_700_000_000_000, + conversation_id: 'tt-conv-1', + text: { body: 'Hello with capability failure' }, + 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) + + expect { service.perform }.to change(Message, :count).by(1) + + message = Message.last + expect(message.conversation.additional_attributes['tiktok_capabilities']).to be_nil + end + context 'when lock_to_single_conversation is enabled' do it 'reuses the last resolved conversation' do inbox.update!(lock_to_single_conversation: true) diff --git a/spec/services/tiktok/send_on_tiktok_service_spec.rb b/spec/services/tiktok/send_on_tiktok_service_spec.rb index 0543fa695..2ca1ffd03 100644 --- a/spec/services/tiktok/send_on_tiktok_service_spec.rb +++ b/spec/services/tiktok/send_on_tiktok_service_spec.rb @@ -48,10 +48,33 @@ RSpec.describe Tiktok::SendOnTiktokService do described_class.new(message: message).perform - expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil) + expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first) expect(message.reload.source_id).to eq('tt-msg-124') end + it 'sends outgoing image message without quote metadata' do + allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124') + allow(tiktok_client).to receive(:send_text_message) + + message = build( + :message, + message_type: :outgoing, + inbox: inbox, + conversation: conversation, + account: inbox.account, + content: nil, + content_attributes: { in_reply_to_external_id: 'quoted-message-id' } + ) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + message.save! + + described_class.new(message: message).perform + + expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first) + expect(tiktok_client).not_to have_received(:send_text_message) + end + it 'marks message as failed when sending multiple attachments' do allow(tiktok_client).to receive(:send_media_message) @@ -67,5 +90,67 @@ RSpec.describe Tiktok::SendOnTiktokService do expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String)) expect(tiktok_client).not_to have_received(:send_media_message) end + + it 'marks message as failed when conversation cannot send images' do + allow(tiktok_client).to receive(:send_media_message) + conversation.update!(additional_attributes: { conversation_id: 'tt-conv-1', tiktok_capabilities: { image_send: false } }) + + message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + message.save! + + described_class.new(message: message).perform + + expect(Messages::StatusUpdateService).to have_received(:new).with( + message, + 'failed', + 'Sending image attachments is not supported for this TikTok conversation.' + ) + expect(tiktok_client).not_to have_received(:send_media_message) + end + + it 'marks message as failed when attachment is not an image' do + allow(tiktok_client).to receive(:send_media_message) + + message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil) + attachment = message.attachments.new(account_id: message.account_id, file_type: :file) + attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv') + message.save! + + described_class.new(message: message).perform + + expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'Only image attachments are supported on TikTok.') + expect(tiktok_client).not_to have_received(:send_media_message) + end + + it 'marks message as failed when image format is unsupported' do + allow(tiktok_client).to receive(:send_media_message) + + message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/contacts.csv').open, filename: 'contacts.csv', content_type: 'text/csv') + message.save! + + described_class.new(message: message).perform + + expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok supports only JPG and PNG images.') + expect(tiktok_client).not_to have_received(:send_media_message) + end + + it 'marks message as failed when image is larger than 3 MB' do + allow(tiktok_client).to receive(:send_media_message) + + message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil) + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + message.save! + allow(message.attachments.first.file).to receive(:byte_size).and_return(4.megabytes) + + described_class.new(message: message).perform + + expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', 'TikTok image attachments must be smaller than 3 MB.') + expect(tiktok_client).not_to have_received(:send_media_message) + end end end