feat: TikTok channel (#12741)

fixes: #11834

This pull request introduces TikTok channel integration, enabling users
to connect and manage TikTok business accounts similarly to other
supported social channels. The changes span backend API endpoints,
authentication helpers, webhook handling, configuration, and frontend
components to support TikTok as a first-class channel.


**Key Notes**
* This integration is only compatible with TikTok Business Accounts
* Special permissions are required to access the TikTok [Business
Messaging
API](https://business-api.tiktok.com/portal/docs?id=1832183871604753).
* The Business Messaging API is region-restricted and is currently
unavailable to users in the EU.
* Only TEXT, IMAGE, and POST_SHARE messages are currently supported due
to limitations in the TikTok Business Messaging API
* A message will be successfully sent only if it contains text alone or
one image attachment. Messages with multiple attachments or those
combining text and attachments will fail and receive a descriptive error
status.
* Messages sent directly from the TikTok App will be synced into the
system
* Initiating a new conversation from the system is not permitted due to
limitations from the TikTok Business Messaging API.


**Backend: TikTok Channel Integration**

* Added `Api::V1::Accounts::Tiktok::AuthorizationsController` to handle
TikTok OAuth authorization initiation, returning the TikTok
authorization URL.
* Implemented `Tiktok::CallbacksController` to handle TikTok OAuth
callback, process authorization results, create or update channel/inbox,
and handle errors or denied scopes.
* Added `Webhooks::TiktokController` to receive and verify TikTok
webhook events, including signature verification and event dispatching.
* Created `Tiktok::IntegrationHelper` module for JWT-based token
generation and verification for secure TikTok OAuth state management.

**Configuration and Feature Flags**

* Added TikTok app credentials (`TIKTOK_APP_ID`, `TIKTOK_APP_SECRET`) to
allowed configs and app config, and registered TikTok as a feature in
the super admin features YAML.
[[1]](diffhunk://#diff-5e46e1d248631a1147521477d84a54f8ba6846ea21c61eca5f70042d960467f4R43)
[[2]](diffhunk://#diff-8bf37a019cab1dedea458c437bd93e34af1d6e22b1672b1d43ef6eaa4dcb7732R69)
[[3]](diffhunk://#diff-123164bea29f3c096b0d018702b090d5ae670760c729141bd4169a36f5f5c1caR74-R79)

**Frontend: TikTok Channel UI and Messaging Support**

* Added `TiktokChannel` API client for frontend TikTok authorization
requests.
* Updated channel icon mappings and tests to include TikTok
(`Channel::Tiktok`).
[[1]](diffhunk://#diff-b852739ed45def61218d581d0de1ba73f213f55570aa5eec52aaa08f380d0e16R16)
[[2]](diffhunk://#diff-3cd3ae32e94ef85f1f2c4435abf0775cc0614fb37ee25d97945cd51573ef199eR64-R69)
* Enabled TikTok as a supported channel in contact forms, channel
widgets, and feature toggles.
[[1]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R47)
[[2]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R69)
[[3]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R26-R29)
[[4]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R51-R54)
[[5]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R68)
* Updated message meta logic to support TikTok-specific message statuses
(sent, delivered, read).
[[1]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696R23)
[[2]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L63-R65)
[[3]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L81-R84)
[[4]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L103-R107)
* Added support for embedded message attachments (e.g., TikTok embeds)
with a new `EmbedBubble` component and updated message rendering logic.
[[1]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR31)
[[2]](diffhunk://#diff-047859f9368a46d6d20177df7d6d623768488ecc38a5b1e284f958fad49add68R1-R19)
[[3]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR316)
[[4]](diffhunk://#diff-cbc85e7c4c8d56f2a847d0b01cd48ef36e5f87b43023bff0520fdfc707283085R52)
* Adjusted reply policy and UI messaging for TikTok's 48-hour reply
window.
[[1]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R208-R210)
[[2]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R224-R226)

These changes collectively enable end-to-end TikTok channel support,
from configuration and OAuth flow to webhook processing and frontend
message handling.


------------

# TikTok App Setup & Configuration
1. Grant access to the Business Messaging API
([Documentation](https://business-api.tiktok.com/portal/docs?id=1832184145137922))
2. Set the app authorization redirect URL to
`https://FRONTEND_URL/tiktok/callback`
3. Update the installation config with TikTok App ID and Secret
4. Create a Business Messaging Webhook configuration and set the
callback url to `https://FRONTEND_URL/webhooks/tiktok`
([Documentation](https://business-api.tiktok.com/portal/docs?id=1832190670631937))
. You can do this by calling
`Tiktok::AuthClient.update_webhook_callback` from rails console once you
finish Tiktok channel configuration in super admin ( will be automated
in future )
5. Enable TikTok channel feature in an account

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Mazen Khalil
2025-12-17 18:54:50 +03:00
committed by GitHub
parent 116ed54c7e
commit ca5e112a8c
84 changed files with 2189 additions and 96 deletions

View File

@@ -0,0 +1,145 @@
class Tiktok::AuthClient
REQUIRED_SCOPES = %w[user.info.basic user.info.username user.info.stats user.info.profile user.account.type user.insights message.list.read
message.list.send message.list.manage].freeze
class << self
def authorize_url(state: nil)
tiktok_client = ::OAuth2::Client.new(
client_id,
client_secret,
{
site: 'https://www.tiktok.com',
authorize_url: '/v2/auth/authorize',
auth_scheme: :basic_auth
}
)
tiktok_client.authorize_url(
{
response_type: 'code',
client_key: client_id,
redirect_uri: redirect_uri,
scope: REQUIRED_SCOPES.join(','),
state: state
}
)
end
# https://business-api.tiktok.com/portal/docs?id=1832184159540418
def obtain_short_term_access_token(auth_code) # rubocop:disable Metrics/MethodLength
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/'
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = {
client_id: client_id,
client_secret: client_secret,
grant_type: 'authorization_code',
auth_code: auth_code,
redirect_uri: redirect_uri
}
response = HTTParty.post(
endpoint,
body: body.to_json,
headers: headers
)
json = process_json_response(response, 'Failed to obtain TikTok short-term access token')
{
business_id: json['data']['open_id'],
scope: json['data']['scope'],
access_token: json['data']['access_token'],
refresh_token: json['data']['refresh_token'],
expires_at: Time.current + json['data']['expires_in'].seconds,
refresh_token_expires_at: Time.current + json['data']['refresh_token_expires_in'].seconds
}.with_indifferent_access
end
def renew_short_term_access_token(refresh_token) # rubocop:disable Metrics/MethodLength
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/refresh_token/'
headers = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
body = {
client_id: client_id,
client_secret: client_secret,
grant_type: 'refresh_token',
refresh_token: refresh_token
}
response = HTTParty.post(
endpoint,
body: body.to_json,
headers: headers
)
json = process_json_response(response, 'Failed to renew TikTok short-term access token')
{
access_token: json['data']['access_token'],
refresh_token: json['data']['refresh_token'],
expires_at: Time.current + json['data']['expires_in'].seconds,
refresh_token_expires_at: Time.current + json['data']['refresh_token_expires_in'].seconds
}.with_indifferent_access
end
def webhook_callback
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/list/'
headers = { Accept: 'application/json' }
params = {
app_id: client_id,
secret: client_secret,
event_type: 'DIRECT_MESSAGE'
}
response = HTTParty.get(endpoint, query: params, headers: headers)
process_json_response(response, 'Failed to fetch TikTok webhook callback')
end
def update_webhook_callback
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/webhook/update/'
headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
body = {
app_id: client_id,
secret: client_secret,
event_type: 'DIRECT_MESSAGE',
callback_url: webhook_url
}
response = HTTParty.post(endpoint, body: body.to_json, headers: headers)
process_json_response(response, 'Failed to update TikTok webhook callback')
end
private
def client_id
GlobalConfigService.load('TIKTOK_APP_ID', nil)
end
def client_secret
GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
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}"
end
res = JSON.parse(response.body)
raise "#{res['code']}: #{res['message']}" if res['code'] != 0
res
end
def redirect_uri
"#{base_url}/tiktok/callback"
end
def webhook_url
"#{base_url}/webhooks/tiktok"
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
end

View File

@@ -0,0 +1,100 @@
class Tiktok::Client
# Always use Tiktok::TokenService to get a valid access token
pattr_initialize [:business_id!, :access_token!]
def business_account_details
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/get/'
headers = { 'Access-Token': access_token }
params = { business_id: business_id, fields: %w[username display_name profile_image].to_s }
response = HTTParty.get(endpoint, query: params, headers: headers)
json = process_json_response(response, 'Failed to fetch TikTok user details')
{
username: json['data']['username'],
display_name: json['data']['display_name'],
profile_image: json['data']['profile_image']
}.with_indifferent_access
end
def file_download_url(conversation_id, message_id, media_id, media_type = 'IMAGE')
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/download/'
headers = { 'Access-Token': access_token, 'Content-Type': 'application/json', Accept: 'application/json' }
body = { business_id: business_id,
conversation_id: conversation_id,
message_id: message_id,
media_id: media_id,
media_type: media_type }
response = HTTParty.post(endpoint, body: body.to_json, headers: headers)
json = process_json_response(response, 'Failed to fetch TikTok media download URL')
json['data']['download_url']
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)
# 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)
end
private
def send_message(conversation_id, type, payload, referenced_message_id: nil)
# https://business-api.tiktok.com/portal/docs?id=1832184403754242
endpoint ='https://business-api.tiktok.com/open_api/v1.3/business/message/send/'
headers = { 'Access-Token': access_token, 'Content-Type': 'application/json' }
body = {
business_id: business_id,
recipient_type: 'CONVERSATION',
recipient: conversation_id
}
body[:referenced_message_info] = { referenced_message_id: referenced_message_id } if referenced_message_id.present?
if type == 'IMAGE'
body[:message_type] = 'IMAGE'
body[:image] = { media_id: payload }
else
body[:message_type] = 'TEXT'
body[:text] = { body: payload }
end
response = HTTParty.post(endpoint, body: body.to_json, headers: headers)
json = process_json_response(response, 'Failed to send TikTok message')
json['data']['message']['message_id']
end
def upload_media(file, media_type = 'IMAGE')
endpoint = 'https://business-api.tiktok.com/open_api/v1.3/business/message/media/upload/'
headers = { 'Access-Token': access_token, 'Content-Type': 'multipart/form-data' }
file.open do |temp_file|
body = {
business_id: business_id,
media_type: media_type,
file: temp_file
}
response = HTTParty.post(endpoint, body: body, headers: headers)
json = process_json_response(response, 'Failed to upload TikTok media')
json['data']['media_id']
end
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}"
end
res = JSON.parse(response.body)
raise "#{res['code']}: #{res['message']}" if res['code'] != 0
res
end
end

View File

@@ -0,0 +1,174 @@
class Tiktok::MessageService
include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!]
def perform
if outgoing_message?
# Skip processing echo messages
message = find_message(tt_conversation_id, tt_message_id)
return if message.present?
end
create_message
end
private
def contact_inbox
@contact_inbox ||= create_contact_inbox(channel, tt_conversation_id, incoming_message? ? from : to, incoming_message? ? from_id : to_id)
end
def contact
contact_inbox.contact
end
def conversation
@conversation ||= contact_inbox.conversations.first || create_conversation(channel, contact_inbox, tt_conversation_id)
end
def create_message
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
)
message.sender = contact_inbox.contact if incoming_message?
message.status = :delivered if outgoing_message?
create_message_attachments(message)
message.save!
end
def message_content
return unless text_message?
tt_text_body
end
def create_message_attachments(message)
create_image_message_attachment(message) if image_message?
create_share_post_message_attachment(message) if share_post_message?
end
def create_image_message_attachment(message)
return unless image_message?
attachment_file = fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id)
message.attachments.new(
account_id: message.account_id,
file_type: :image,
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def create_share_post_message_attachment(message)
return unless share_post_message?
message.attachments.new(
account_id: message.account_id,
file_type: :embed,
external_url: tt_share_post_embed_url
)
end
def supported_message?
text_message? || image_message? || share_post_message?
end
def message_content_attributes
attributes = {}
attributes[:in_reply_to_external_id] = tt_referenced_message_id if tt_referenced_message_id
attributes[:is_unsupported] = true unless supported_message?
attributes
end
def text_message?
tt_message_type == 'text'
end
def image_message?
tt_message_type == 'image'
end
def sticker_message?
tt_message_type == 'sticker'
end
def share_post_message?
tt_message_type == 'share_post'
end
def tt_text_body
return unless text_message?
content[:text][:body]
end
def tt_image_media_id
return unless image_message?
content[:image][:media_id]
end
def tt_share_post_embed_url
return unless share_post_message?
content[:share_post][:embed_url]
end
def tt_referenced_message_id
content[:referenced_message_info]&.[](:referenced_message_id)
end
def tt_message_type
content[:type]
end
def tt_message_id
content[:message_id]
end
def tt_message_time
Time.zone.at(content[:timestamp] / 1000).utc
end
def tt_conversation_id
content[:conversation_id]
end
def from
content[:from]
end
def from_id
content[:from_user][:id]
end
def to
content[:to]
end
def to_id
content[:to_user][:id]
end
def incoming_message?
channel.business_id == to_id
end
def outgoing_message?
!incoming_message?
end
end

View File

@@ -0,0 +1,68 @@
module Tiktok::MessagingHelpers
private
def create_contact_inbox(channel, tt_conversation_id, from, from_id)
::ContactInboxWithContactBuilder.new(
source_id: tt_conversation_id,
inbox: channel.inbox,
contact_attributes: contact_attributes(from, from_id)
).perform
end
def contact_attributes(from, from_id)
{
name: from,
additional_attributes: contact_additional_attributes(from, from_id)
}
end
def contact_additional_attributes(from, from_id)
{
# TODO: Remove this once we show the social_tiktok_user_name in the UI instead of the username
username: from,
social_tiktok_user_id: from_id,
social_tiktok_user_name: from
}
end
def find_conversation(channel, tt_conversation_id)
channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
end
def create_conversation(channel, contact_inbox, tt_conversation_id)
::Conversation.create!(conversation_params(channel, contact_inbox, tt_conversation_id))
end
def conversation_params(channel, contact_inbox, tt_conversation_id)
{
account_id: channel.inbox.account_id,
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)
}
end
def conversation_additional_attributes(tt_conversation_id)
{
conversation_id: tt_conversation_id
}
end
def find_message(tt_conversation_id, tt_message_id)
message = Message.find_by(source_id: tt_message_id)
message_conversation_id = message&.conversation&.[](:additional_attributes)&.[]('conversation_id')
return if message_conversation_id != tt_conversation_id
message
end
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)
Down.download(file_download_url)
end
def tiktok_client(channel)
Tiktok::Client.new(business_id: channel.business_id, access_token: channel.validated_access_token)
end
end

View File

@@ -0,0 +1,36 @@
class Tiktok::ReadStatusService
include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!]
def perform
return if channel.blank? || content.blank? || outbound_event?
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, last_read_timestamp) if conversation.present?
end
def conversation
@conversation ||= find_conversation(channel, tt_conversation_id)
end
def tt_conversation_id
content[:conversation_id]
end
def last_read_timestamp
tt = content[:read][:last_read_timestamp]
Time.zone.at(tt.to_i / 1000).utc
end
def business_id
channel.business_id
end
def from_user_id
content[:from_user][:id]
end
def outbound_event?
business_id.to_s == from_user_id.to_s
end
end

View File

@@ -0,0 +1,47 @@
class Tiktok::SendOnTiktokService < Base::SendOnChannelService
private
def channel_class
Channel::Tiktok
end
def perform_reply
validate_message_support!
message_id = send_message
message.update!(source_id: message_id)
Messages::StatusUpdateService.new(message, 'delivered').perform
rescue StandardError => e
Rails.logger.error "Failed to send Tiktok message: #{e.message}"
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
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?
end
def send_message
tt_conversation_id = message.conversation[:additional_attributes]['conversation_id']
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)
else
tiktok_client.send_text_message(tt_conversation_id, message.outgoing_content, referenced_message_id: tt_referenced_message_id)
end
end
def tiktok_client
@tiktok_client ||= Tiktok::Client.new(business_id: channel.business_id, access_token: channel.validated_access_token)
end
def inbox
@inbox ||= message.inbox
end
def channel
@channel ||= inbox.channel
end
end

View File

@@ -0,0 +1,77 @@
# Service to handle TikTok channel access token refresh logic
# TikTok access tokens are valid for 1 day and can be refreshed to extend validity
class Tiktok::TokenService
pattr_initialize [:channel!]
# Returns a valid access token, refreshing it if necessary and eligible
def access_token
return current_access_token if token_valid?
return refresh_access_token if refresh_token_valid?
channel.prompt_reauthorization! unless channel.reauthorization_required?
return current_access_token
end
private
def current_access_token
channel.access_token
end
def expires_at
channel.expires_at
end
def refresh_token
channel.refresh_token
end
def refresh_token_expires_at
channel.refresh_token_expires_at
end
# Checks if the current token is still valid (not expired)
def token_valid?
5.minutes.from_now < expires_at
end
def refresh_token_valid?
Time.current < refresh_token_expires_at
end
# Makes an API request to refresh the access token
# @return [String] Refreshed access token
def refresh_access_token
lock_manager = Redis::LockManager.new
begin
# Could not acquire lock, another process is likely refreshing the token
# return the current token as it should still be valid for the next 30 minutes
return current_access_token unless lock_manager.lock(lock_key, 30.seconds)
result = attempt_refresh_token
new_token = result[:access_token]
channel.update!(
access_token: new_token,
refresh_token: result[:refresh_token],
expires_at: result[:expires_at],
refresh_token_expires_at: result[:refresh_token_expires_at]
)
lock_manager.unlock(lock_key)
new_token
rescue StandardError => e
lock_manager.unlock(lock_key)
raise e
end
end
def lock_key
format(::Redis::Alfred::TIKTOK_REFRESH_TOKEN_MUTEX, channel_id: channel.id)
end
def attempt_refresh_token
Tiktok::AuthClient.renew_short_term_access_token(refresh_token)
end
end