diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb new file mode 100644 index 000000000..7c7320393 --- /dev/null +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -0,0 +1,15 @@ +class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController + include Tiktok::IntegrationHelper + + def create + redirect_url = Tiktok::AuthClient.authorize_url( + state: generate_tiktok_token(Current.account.id) + ) + + if redirect_url + render json: { success: true, url: redirect_url } + else + render json: { success: false }, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 5003c4c70..abf42517c 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -73,6 +73,7 @@ class DashboardController < ActionController::Base ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''), + TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'), WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''), WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 1a9539bb6..ec51305b5 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -46,6 +46,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET], 'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET], 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], + 'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb new file mode 100644 index 000000000..e484905c3 --- /dev/null +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -0,0 +1,144 @@ +class Tiktok::CallbacksController < ApplicationController + include Tiktok::IntegrationHelper + + def show + return handle_authorization_error if params[:error].present? + return handle_ungranted_scopes_error unless all_scopes_granted? + + process_successful_authorization + rescue StandardError => e + handle_error(e) + end + + private + + def all_scopes_granted? + granted_scopes = short_term_access_token[:scope].to_s.split(',') + (Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank? + end + + def process_successful_authorization + inbox, already_exists = find_or_create_inbox + + if already_exists + redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) + else + redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id) + end + end + + def handle_error(error) + Rails.logger.error("TikTok Channel creation Error: #{error.message}") + ChatwootExceptionTracker.new(error).capture_exception + + redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message) + end + + # Handles the case when a user denies permissions or cancels the authorization flow + def handle_authorization_error + redirect_to_error_page( + error_type: params[:error] || 'access_denied', + code: params[:error_code], + error_message: params[:error_description] || 'User cancelled the Authorization' + ) + end + + # Handles the case when a user partially accepted the required scopes + def handle_ungranted_scopes_error + redirect_to_error_page( + error_type: 'ungranted_scopes', + code: 400, + error_message: 'User did not grant all the required scopes' + ) + end + + # Centralized method to redirect to error page with appropriate parameters + # This ensures consistent error handling across different error scenarios + # Frontend will handle the error page based on the error_type + def redirect_to_error_page(error_type:, code:, error_message:) + redirect_to app_new_tiktok_inbox_url( + account_id: account_id, + error_type: error_type, + code: code, + error_message: error_message + ) + end + + def find_or_create_inbox + business_details = tiktok_client.business_account_details + channel_tiktok = find_channel + channel_exists = channel_tiktok.present? + + if channel_tiktok + update_channel(channel_tiktok, business_details) + else + channel_tiktok = create_channel_with_inbox(business_details) + end + + # reauthorized will also update cache keys for the associated inbox + channel_tiktok.reauthorized! + + set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present? + + [channel_tiktok.inbox, channel_exists] + end + + def create_channel_with_inbox(business_details) + ActiveRecord::Base.transaction do + channel_tiktok = Channel::Tiktok.create!( + account: account, + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + account.inboxes.create!( + account: account, + channel: channel_tiktok, + name: business_details[:display_name].presence || business_details[:username] + ) + + channel_tiktok + end + end + + def find_channel + Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account) + end + + def update_channel(channel_tiktok, business_details) + channel_tiktok.update!( + access_token: short_term_access_token[:access_token], + refresh_token: short_term_access_token[:refresh_token], + expires_at: short_term_access_token[:expires_at], + refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at] + ) + + channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username]) + end + + def set_avatar(inbox, avatar_url) + Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url) + end + + def account_id + @account_id ||= verify_tiktok_token(params[:state]) + end + + def account + @account ||= Account.find(account_id) + end + + def short_term_access_token + @short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code]) + end + + def tiktok_client + @tiktok_client ||= Tiktok::Client.new( + business_id: short_term_access_token[:business_id], + access_token: short_term_access_token[:access_token] + ) + end +end diff --git a/app/controllers/webhooks/tiktok_controller.rb b/app/controllers/webhooks/tiktok_controller.rb new file mode 100644 index 000000000..efaa1830c --- /dev/null +++ b/app/controllers/webhooks/tiktok_controller.rb @@ -0,0 +1,53 @@ +class Webhooks::TiktokController < ActionController::API + before_action :verify_signature! + + def events + event = JSON.parse(request_payload) + if echo_event? + # Add delay to prevent race condition where echo arrives before send message API completes + # This avoids duplicate messages when echo comes early during API processing + ::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event) + else + ::Webhooks::TiktokEventsJob.perform_later(event) + end + + head :ok + end + + private + + def request_payload + @request_payload ||= request.body.read + end + + def verify_signature! + signature_header = request.headers['Tiktok-Signature'] + client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + received_timestamp, received_signature = extract_signature_parts(signature_header) + + return head :unauthorized unless client_secret && received_timestamp && received_signature + + signature_payload = "#{received_timestamp}.#{request_payload}" + computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload) + + return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature) + + # Check timestamp delay (acceptable delay: 5 seconds) + current_timestamp = Time.current.to_i + delay = current_timestamp - received_timestamp + + return head :unauthorized if delay > 5 + end + + def extract_signature_parts(signature_header) + return [nil, nil] if signature_header.blank? + + keys = signature_header.split(',') + signature_parts = keys.map { |part| part.split('=') }.to_h + [signature_parts['t']&.to_i, signature_parts['s']] + end + + def echo_event? + params[:event] == 'im_send_msg' + end +end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index b05c603cd..34c7a8138 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -78,6 +78,12 @@ instagram: enabled: true icon: 'icon-instagram' config_key: 'instagram' +tiktok: + name: 'TikTok' + description: 'Stay connected with your customers on TikTok' + enabled: true + icon: 'icon-tiktok' + config_key: 'tiktok' whatsapp: name: 'WhatsApp' description: 'Manage your WhatsApp business interactions from Chatwoot.' diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb new file mode 100644 index 000000000..b2de4a092 --- /dev/null +++ b/app/helpers/tiktok/integration_helper.rb @@ -0,0 +1,47 @@ +module Tiktok::IntegrationHelper + # Generates a signed JWT token for Tiktok integration + # + # @param account_id [Integer] The account ID to encode in the token + # @return [String, nil] The encoded JWT token or nil if client secret is missing + def generate_tiktok_token(account_id) + return if client_secret.blank? + + JWT.encode(token_payload(account_id), client_secret, 'HS256') + rescue StandardError => e + Rails.logger.error("Failed to generate TikTok token: #{e.message}") + nil + end + + # Verifies and decodes a Tiktok JWT token + # + # @param token [String] The JWT token to verify + # @return [Integer, nil] The account ID from the token or nil if invalid + def verify_tiktok_token(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret) + end + + private + + def client_secret + @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) + end + + def token_payload(account_id) + { + sub: account_id, + iat: Time.current.to_i + } + end + + def decode_token(token, secret) + JWT.decode(token, secret, true, { + algorithm: 'HS256', + verify_expiration: true + }).first['sub'] + rescue StandardError => e + Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/channel/tiktokClient.js b/app/javascript/dashboard/api/channel/tiktokClient.js new file mode 100644 index 000000000..389eb2699 --- /dev/null +++ b/app/javascript/dashboard/api/channel/tiktokClient.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class TiktokChannel extends ApiClient { + constructor() { + super('tiktok', { accountScoped: true }); + } + + generateAuthorization(payload) { + return axios.post(`${this.url}/authorization`, payload); + } +} + +export default new TiktokChannel(); diff --git a/app/javascript/dashboard/api/specs/tiktokClient.spec.js b/app/javascript/dashboard/api/specs/tiktokClient.spec.js new file mode 100644 index 000000000..5250e2c7b --- /dev/null +++ b/app/javascript/dashboard/api/specs/tiktokClient.spec.js @@ -0,0 +1,35 @@ +import ApiClient from '../ApiClient'; +import tiktokClient from '../channel/tiktokClient'; + +describe('#TiktokClient', () => { + it('creates correct instance', () => { + expect(tiktokClient).toBeInstanceOf(ApiClient); + expect(tiktokClient).toHaveProperty('generateAuthorization'); + }); + + describe('#generateAuthorization', () => { + const originalAxios = window.axios; + const originalPathname = window.location.pathname; + const axiosMock = { + post: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + window.history.pushState({}, '', '/app/accounts/1/settings'); + }); + + afterEach(() => { + window.axios = originalAxios; + window.history.pushState({}, '', originalPathname); + }); + + it('posts to the authorization endpoint', () => { + tiktokClient.generateAuthorization({ state: 'test-state' }); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/tiktok/authorization', + { state: 'test-state' } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue index e77a0c10d..3dc29738e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue @@ -44,6 +44,7 @@ const SOCIAL_CONFIG = { LINKEDIN: 'i-ri-linkedin-box-fill', FACEBOOK: 'i-ri-facebook-circle-fill', INSTAGRAM: 'i-ri-instagram-line', + TIKTOK: 'i-ri-tiktok-fill', TWITTER: 'i-ri-twitter-x-fill', GITHUB: 'i-ri-github-fill', }; @@ -65,6 +66,7 @@ const defaultState = { facebook: '', github: '', instagram: '', + tiktok: '', linkedin: '', twitter: '', }, diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index a356ae2fd..25b54d125 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -13,6 +13,7 @@ export function useChannelIcon(inbox) { 'Channel::WebWidget': 'i-woot-website', 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', + 'Channel::Tiktok': 'i-woot-tiktok', 'Channel::Voice': 'i-ri-phone-fill', }; diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index e32df567d..7f2319b86 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -61,6 +61,12 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-woot-instagram'); }); + it('returns correct icon for TikTok channel', () => { + const inbox = { channel_type: 'Channel::Tiktok' }; + const { value: icon } = useChannelIcon(inbox); + expect(icon).toBe('i-woot-tiktok'); + }); + describe('TwilioSms channel', () => { it('returns chat icon for regular Twilio SMS channel', () => { const inbox = { channel_type: 'Channel::TwilioSms' }; diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue index fabef6bc5..6fa7ff2b2 100644 --- a/app/javascript/dashboard/components-next/message/Message.vue +++ b/app/javascript/dashboard/components-next/message/Message.vue @@ -28,6 +28,7 @@ import ImageBubble from './bubbles/Image.vue'; import FileBubble from './bubbles/File.vue'; import AudioBubble from './bubbles/Audio.vue'; import VideoBubble from './bubbles/Video.vue'; +import EmbedBubble from './bubbles/Embed.vue'; import InstagramStoryBubble from './bubbles/InstagramStory.vue'; import EmailBubble from './bubbles/Email/Index.vue'; import UnsupportedBubble from './bubbles/Unsupported.vue'; @@ -317,6 +318,7 @@ const componentToRender = computed(() => { if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble; if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble; if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble; + if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble; if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble; } // Attachment content is the name of the contact diff --git a/app/javascript/dashboard/components-next/message/MessageMeta.vue b/app/javascript/dashboard/components-next/message/MessageMeta.vue index e0602cb89..e633d7c3c 100644 --- a/app/javascript/dashboard/components-next/message/MessageMeta.vue +++ b/app/javascript/dashboard/components-next/message/MessageMeta.vue @@ -20,6 +20,7 @@ const { isAWhatsAppChannel, isAnEmailChannel, isAnInstagramChannel, + isATiktokChannel, } = useInbox(); const { @@ -60,7 +61,8 @@ const isSent = computed(() => { isAFacebookInbox.value || isASmsInbox.value || isATelegramChannel.value || - isAnInstagramChannel.value + isAnInstagramChannel.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.SENT; } @@ -78,7 +80,8 @@ const isDelivered = computed(() => { isAWhatsAppChannel.value || isATwilioChannel.value || isASmsInbox.value || - isAFacebookInbox.value + isAFacebookInbox.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED; } @@ -100,7 +103,8 @@ const isRead = computed(() => { isAWhatsAppChannel.value || isATwilioChannel.value || isAFacebookInbox.value || - isAnInstagramChannel.value + isAnInstagramChannel.value || + isATiktokChannel.value ) { return sourceId.value && status.value === MESSAGE_STATUS.READ; } diff --git a/app/javascript/dashboard/components-next/message/bubbles/Embed.vue b/app/javascript/dashboard/components-next/message/bubbles/Embed.vue new file mode 100644 index 000000000..4229e1a89 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/bubbles/Embed.vue @@ -0,0 +1,30 @@ + + +