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:
@@ -22,6 +22,8 @@ class Conversations::MessageWindowService
|
||||
messenger_messaging_window
|
||||
when 'Channel::Instagram'
|
||||
instagram_messaging_window
|
||||
when 'Channel::Tiktok'
|
||||
tiktok_messaging_window
|
||||
when 'Channel::Whatsapp'
|
||||
MESSAGING_WINDOW_24_HOURS
|
||||
when 'Channel::TwilioSms'
|
||||
@@ -54,6 +56,10 @@ class Conversations::MessageWindowService
|
||||
meta_messaging_window('ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT')
|
||||
end
|
||||
|
||||
def tiktok_messaging_window
|
||||
48.hours
|
||||
end
|
||||
|
||||
def meta_messaging_window(config_key)
|
||||
GlobalConfigService.load(config_key, nil) ? MESSAGING_WINDOW_7_DAYS : MESSAGING_WINDOW_24_HOURS
|
||||
end
|
||||
|
||||
145
app/services/tiktok/auth_client.rb
Normal file
145
app/services/tiktok/auth_client.rb
Normal 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
|
||||
100
app/services/tiktok/client.rb
Normal file
100
app/services/tiktok/client.rb
Normal 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
|
||||
174
app/services/tiktok/message_service.rb
Normal file
174
app/services/tiktok/message_service.rb
Normal 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
|
||||
68
app/services/tiktok/messaging_helpers.rb
Normal file
68
app/services/tiktok/messaging_helpers.rb
Normal 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
|
||||
36
app/services/tiktok/read_status_service.rb
Normal file
36
app/services/tiktok/read_status_service.rb
Normal 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
|
||||
47
app/services/tiktok/send_on_tiktok_service.rb
Normal file
47
app/services/tiktok/send_on_tiktok_service.rb
Normal 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
|
||||
77
app/services/tiktok/token_service.rb
Normal file
77
app/services/tiktok/token_service.rb
Normal 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
|
||||
Reference in New Issue
Block a user