Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
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