fix(tiktok): Resolve media upload failures and gate attachments by conversation capability (#13643)

This PR fixes TikTok attachment send failures and adds a
capability-based guard so attachments are only enabled for conversations
that support media sending.

- Fixed TikTok media upload request formatting so TikTok accepts image
uploads reliably.
- Added TikTok capability check (IMAGE_SEND) during conversation
creation.
- Stored capability in
conversation.additional_attributes.tiktok_capabilities.
- Updated reply composer UI to disable/hide attachment upload for TikTok
conversations where image_send is false.

Fixes
https://linear.app/chatwoot/issue/CW-6532/enable-attachments-based-on-the-conversation-capability
and
https://linear.app/chatwoot/issue/CW-6996/unable-to-send-image-attachments-to-tiktok-customer-400-parsing-error

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-05-06 11:21:15 +04:00
committed by GitHub
co-authored by Copilot Muhsin
parent cc5974da9b
commit b8108b71c1
7 changed files with 352 additions and 33 deletions
@@ -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 = () => {
+44 -11
View File
@@ -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
+17 -5
View File
@@ -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)
+18 -1
View File
@@ -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
+136
View File
@@ -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
+43 -14
View File
@@ -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)
@@ -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