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,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

View File

@@ -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', ''),

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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.'

View File

@@ -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

View File

@@ -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();

View File

@@ -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' }
);
});
});
});

View File

@@ -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: '',
},

View File

@@ -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',
};

View File

@@ -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' };

View File

@@ -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

View File

@@ -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;
}

View File

@@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
<div
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
>
<iframe
class="w-full h-full border-0 rounded-lg"
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
:src="attachment.dataUrl"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
</BaseBubble>
</template>

View File

@@ -49,6 +49,7 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};

View File

@@ -23,6 +23,10 @@ const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
@@ -44,6 +48,10 @@ const isActive = computed(() => {
);
}
if (key === 'tiktok') {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -57,6 +65,7 @@ const isActive = computed(() => {
'telegram',
'line',
'instagram',
'tiktok',
'voice',
].includes(key);
});

View File

@@ -205,6 +205,9 @@ export default {
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (this.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
@@ -218,6 +221,9 @@ export default {
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (this.isATiktokChannel) {
return this.$t('CONVERSATION.48_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}

View File

@@ -234,6 +234,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}

View File

@@ -48,6 +48,7 @@ const mockStore = createStore({
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
},
@@ -215,6 +216,12 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
});
});
@@ -266,6 +273,7 @@ describe('useInbox', () => {
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
];

View File

@@ -17,6 +17,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -24,6 +25,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -128,6 +130,10 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isATiktokChannel = computed(() => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
@@ -149,6 +155,7 @@ export const useInbox = (inboxId = null) => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
};
};

View File

@@ -109,6 +109,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],

View File

@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',

View File

@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
@@ -28,6 +29,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
@@ -43,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
@@ -136,6 +139,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';

View File

@@ -38,6 +38,9 @@ describe('#Inbox Helpers', () => {
it('should return correct class for Email', () => {
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
});
it('should return correct class for TikTok', () => {
expect(getInboxClassByType(INBOX_TYPES.TIKTOK)).toEqual('brand-tiktok');
});
});
describe('getInboxIconByType', () => {
@@ -80,6 +83,10 @@ describe('#Inbox Helpers', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns correct icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK)).toBe('i-ri-tiktok-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
@@ -102,6 +109,12 @@ describe('#Inbox Helpers', () => {
);
});
it('returns correct line icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
'i-ri-tiktok-line'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'

View File

@@ -102,6 +102,9 @@
},
"contact": {
"CONTENT": "Shared contact"
},
"embed": {
"CONTENT": "Embedded content"
}
},
"CHAT_SORT_BY_FILTER": {

View File

@@ -458,6 +458,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
"LINKEDIN": {
"PLACEHOLDER": "Add LinkedIn"
},

View File

@@ -32,6 +32,7 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"48_HOURS_WINDOW": "48 hour message window restriction",
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
@@ -57,7 +58,7 @@
},
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
"UNSUPPORTED_MESSAGE": "This message is unsupported. To view it, please open it on the original platform.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",

View File

@@ -57,6 +57,13 @@
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again",
@@ -471,6 +478,10 @@
"TITLE": "Instagram",
"DESCRIPTION": "Connect your instagram account"
},
"TIKTOK": {
"TITLE": "TikTok",
"DESCRIPTION": "Connect your TikTok account"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
@@ -1009,6 +1020,7 @@
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"VOICE": "Voice"
}
}

View File

@@ -62,6 +62,7 @@ export default {
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},

View File

@@ -15,6 +15,7 @@ export default {
{ key: 'github', icon: 'github', link: 'https://github.com/' },
{ key: 'instagram', icon: 'instagram', link: 'https://instagram.com/' },
{ key: 'telegram', icon: 'telegram', link: 'https://t.me/' },
{ key: 'tiktok', icon: 'tiktok', link: 'https://tiktok.com/@' },
],
};
},

View File

@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Tiktok from './channels/Tiktok.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
@@ -23,6 +24,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
tiktok: Tiktok,
voice: Voice,
};

View File

@@ -16,9 +16,13 @@ const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const channelList = computed(() => {
const { apiChannelName } = globalConfig.value;
return [
const channels = [
{
key: 'website',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE'),
@@ -73,13 +77,25 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-ri-phone-fill',
},
];
if (hasTiktokConfigured.value) {
channels.push({
key: 'tiktok',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.DESCRIPTION'),
icon: 'i-woot-tiktok',
});
}
channels.push({
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-ri-phone-fill',
});
return channels;
});
const initializeEnabledFeatures = async () => {

View File

@@ -9,6 +9,7 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import TiktokReauthorize from './channels/tiktok/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
@@ -48,6 +49,7 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
TiktokReauthorize,
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
@@ -248,6 +250,9 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
// Check if a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
@@ -524,6 +529,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<TiktokReauthorize v-if="tiktokUnauthorized" :inbox="inbox" />
<WhatsappReauthorize
v-if="whatsappUnauthorized"
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"

View File

@@ -1,63 +1,46 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
setup() {
const { accountId } = useAccount();
return {
accountId,
v$: useVuelidate(),
};
},
data() {
return {
isCreating: false,
hasError: false,
errorStateMessage: '',
errorStateDescription: '',
isRequestingAuthorization: false,
};
},
const { t } = useI18n();
mounted() {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
if (errorMessage) {
this.hasError = true;
if (errorCode === '400') {
this.errorStateMessage = errorMessage;
this.errorStateDescription = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'
);
} else {
this.errorStateMessage = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE'
);
this.errorStateDescription = errorMessage;
}
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
if (errorMessage) {
hasError.value = true;
if (errorCode === '400') {
errorStateMessage.value = errorMessage;
errorStateDescription.value = t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH');
} else {
errorStateMessage.value = t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE');
errorStateDescription.value = errorMessage;
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
},
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
});
methods: {
async requestAuthorization() {
this.isRequestingAuthorization = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
const requestAuthorization = async () => {
isRequestingAuthorization.value = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
},
},
window.location.href = url;
};
</script>
@@ -81,38 +64,15 @@ export default {
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
<button
class="flex items-center justify-center px-8 py-3.5 gap-2 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
>
<span class="i-ri-instagram-line size-5" />
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
<span v-if="isRequestingAuthorization" class="ml-2">
<svg
class="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
</button>
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
if (errorMessage) {
hasError.value = true;
if (errorCode === '400') {
errorStateMessage.value = errorMessage;
errorStateDescription.value = t('INBOX_MGMT.ADD.TIKTOK.ERROR_AUTH');
} else {
errorStateMessage.value = t('INBOX_MGMT.ADD.TIKTOK.ERROR_MESSAGE');
errorStateDescription.value = errorMessage;
}
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
});
const requestAuthorization = async () => {
isRequestingAuthorization.value = true;
const response = await tiktokClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
};
</script>
<template>
<div class="h-full p-6 w-full max-w-full flex-shrink-0 flex-grow-0">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
<p
v-if="errorStateDescription"
v-dompurify-html="errorStateDescription"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
</p>
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
lg
icon="i-ri-tiktok-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
@click="requestAuthorization()"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await tiktokClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.TIKTOK.ERROR_AUTH'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
</template>

View File

@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};

View File

@@ -165,6 +165,13 @@ export const getters = {
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
getTiktokInboxByBusinessId: $state => businessId => {
return $state.records.find(
item =>
item.business_id === businessId &&
item.channel_type === INBOX_TYPES.TIKTOK
);
},
};
const sendAnalyticsEvent = channelType => {

View File

@@ -71,4 +71,12 @@ export default [
instagram_id: 123456789,
provider: 'default',
},
{
id: 8,
channel_id: 8,
name: 'Test TikTok 1',
channel_type: 'Channel::Tiktok',
business_id: 123456789,
provider: 'default',
},
];

View File

@@ -27,7 +27,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(8);
});
it('getInbox', () => {
@@ -95,6 +95,18 @@ describe('#getters', () => {
});
});
it('getTiktokInboxByBusinessId', () => {
const state = { records: inboxList };
expect(getters.getTiktokInboxByBusinessId(state)(123456789)).toEqual({
id: 8,
channel_id: 8,
name: 'Test TikTok 1',
channel_type: 'Channel::Tiktok',
business_id: 123456789,
provider: 'default',
});
});
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };

View File

@@ -45,6 +45,7 @@
"bot-outline": "M17.753 14a2.25 2.25 0 0 1 2.25 2.25v.905a3.75 3.75 0 0 1-1.307 2.846C17.13 21.345 14.89 22 12 22c-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.306-2.843v-.908A2.25 2.25 0 0 1 6.253 14h11.5Zm0 1.5h-11.5a.75.75 0 0 0-.75.75v.908c0 .655.286 1.278.784 1.706C7.545 19.945 9.44 20.502 12 20.502c2.56 0 4.458-.557 5.719-1.64a2.25 2.25 0 0 0 .784-1.706v-.906a.75.75 0 0 0-.75-.75ZM11.898 2.008 12 2a.75.75 0 0 1 .743.648l.007.102V3.5h3.5a2.25 2.25 0 0 1 2.25 2.25v4.505a2.25 2.25 0 0 1-2.25 2.25h-8.5a2.25 2.25 0 0 1-2.25-2.25V5.75A2.25 2.25 0 0 1 7.75 3.5h3.5v-.749a.75.75 0 0 1 .648-.743L12 2l-.102.007ZM16.25 5h-8.5a.75.75 0 0 0-.75.75v4.505c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75V5.75a.75.75 0 0 0-.75-.75Zm-6.5 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm4.492 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
"brand-facebook-outline": "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z",
"brand-instagram-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
"brand-tiktok-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
"brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z",
"brand-line-outline": "M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314",
"brand-linkedin-outline": "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",

View File

@@ -5,6 +5,7 @@ export const REPLY_POLICY = {
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
TIKTOK: 'https://business-api.tiktok.com/portal/docs?id=1832184236919810',
};
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';

View File

@@ -8,6 +8,8 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 2000,
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
INSTAGRAM: 1000,
// https://business-api.tiktok.com/portal/docs?id=1832184403754242
TIKTOK: 6000,
// https://www.twilio.com/docs/glossary/what-sms-character-limit
TWILIO_SMS: 320,
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging

View File

@@ -14,6 +14,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -21,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -115,6 +117,8 @@ export default {
badgeKey = this.twilioBadge;
} else if (this.isAWhatsAppChannel) {
badgeKey = 'whatsapp';
} else if (this.isATiktokChannel) {
badgeKey = 'tiktok';
}
return badgeKey || this.channelType;
},
@@ -127,6 +131,9 @@ export default {
isAnInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
},
methods: {
inboxHasFeature(feature) {

View File

@@ -9,6 +9,7 @@ class SendReplyJob < ApplicationJob
'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService,
'Channel::Sms' => ::Sms::SendOnSmsService,
'Channel::Instagram' => ::Instagram::SendOnInstagramService,
'Channel::Tiktok' => ::Tiktok::SendOnTiktokService,
'Channel::Email' => ::Email::SendOnEmailService,
'Channel::WebWidget' => ::Messages::SendEmailNotificationService,
'Channel::Api' => ::Messages::SendEmailNotificationService

View File

@@ -0,0 +1,69 @@
# https://business-api.tiktok.com/portal/docs?id=1832190670631937
class Webhooks::TiktokEventsJob < MutexApplicationJob
queue_as :default
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 8
SUPPORTED_EVENTS = [:im_send_msg, :im_receive_msg, :im_mark_read_msg].freeze
def perform(event)
@event = event.with_indifferent_access
return if channel_is_inactive?
key = format(::Redis::Alfred::TIKTOK_MESSAGE_MUTEX, business_id: business_id, conversation_id: conversation_id)
with_lock(key, 10.seconds) do
process_event
end
end
private
def channel_is_inactive?
return true if channel.blank?
return true unless channel.account.active?
false
end
def process_event
return if event_name.blank? || channel.blank?
send(event_name)
end
def event_name
@event_name ||= SUPPORTED_EVENTS.include?(@event[:event].to_sym) ? @event[:event] : nil
end
def business_id
@business_id ||= @event[:user_openid]
end
def content
@content ||= JSON.parse(@event[:content]).deep_symbolize_keys
end
def conversation_id
@conversation_id ||= content[:conversation_id]
end
def channel
@channel ||= Channel::Tiktok.find_by(business_id: business_id)
end
# Receive real-time notifications if you send a message to a user.
def im_send_msg
# This can be either an echo message or a message sent directly via tiktok application
::Tiktok::MessageService.new(channel: channel, content: content).perform
end
# Receive real-time notifications if a user outside the European Economic Area (EEA), Switzerland, or the UK sends a message to you.
def im_receive_msg
::Tiktok::MessageService.new(channel: channel, content: content).perform
end
# Receive real-time notifications when a Personal Account user marks all messages in a session as read.
def im_mark_read_msg
::Tiktok::ReadStatusService.new(channel: channel, content: content).perform
end
end

View File

@@ -9,6 +9,11 @@ class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNoti
send_notification(subject, action_url: inbox_url(inbox))
end
def tiktok_disconnect(inbox)
subject = 'Your TikTok connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def whatsapp_disconnect(inbox)
subject = 'Your Whatsapp connection has expired'
send_notification(subject, action_url: inbox_url(inbox))

View File

@@ -82,6 +82,7 @@ class Account < ApplicationRecord
has_many :email_channels, dependent: :destroy_async, class_name: '::Channel::Email'
has_many :facebook_pages, dependent: :destroy_async, class_name: '::Channel::FacebookPage'
has_many :instagram_channels, dependent: :destroy_async, class_name: '::Channel::Instagram'
has_many :tiktok_channels, dependent: :destroy_async, class_name: '::Channel::Tiktok'
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inboxes, dependent: :destroy_async
has_many :labels, dependent: :destroy_async

View File

@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
validate :acceptable_file
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11, :embed => 12 }
def push_event_data
return unless file_type
@@ -86,11 +86,19 @@ class Attachment < ApplicationRecord
contact_metadata
when :audio
audio_metadata
when :embed
embed_data
else
file_metadata
end
end
def embed_data
{
data_url: external_url
}
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(

View File

@@ -0,0 +1,45 @@
# == Schema Information
#
# Table name: channel_tiktok
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime not null
# refresh_token :string not null
# refresh_token_expires_at :datetime not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
# business_id :string not null
#
# Indexes
#
# index_channel_tiktok_on_business_id (business_id) UNIQUE
#
class Channel::Tiktok < ApplicationRecord
include Channelable
include Reauthorizable
self.table_name = 'channel_tiktok'
# TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out).
if Chatwoot.encryption_configured?
encrypts :access_token
encrypts :refresh_token
end
AUTHORIZATION_ERROR_THRESHOLD = 1
validates :business_id, uniqueness: true, presence: true
validates :access_token, presence: true
validates :refresh_token, presence: true
validates :expires_at, presence: true
validates :refresh_token_expires_at, presence: true
def name
'Tiktok'
end
def validated_access_token
Tiktok::TokenService.new(channel: self).access_token
end
end

View File

@@ -76,6 +76,7 @@ module Reauthorizable
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
'Channel::FacebookPage' => ->(obj) { obj.send_channel_reauthorization_email(:facebook_disconnect) },
'Channel::Instagram' => ->(obj) { obj.send_channel_reauthorization_email(:instagram_disconnect) },
'Channel::Tiktok' => ->(obj) { obj.send_channel_reauthorization_email(:tiktok_disconnect) },
'Channel::Whatsapp' => ->(obj) { obj.send_channel_reauthorization_email(:whatsapp_disconnect) },
'Channel::Email' => ->(obj) { obj.send_channel_reauthorization_email(:email_disconnect) },
'AutomationRule' => ->(obj) { obj.handle_automation_rule_reauthorization }

View File

@@ -126,6 +126,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Instagram'
end
def tiktok?
channel_type == 'Channel::Tiktok'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end

View File

@@ -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

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

View File

@@ -60,6 +60,9 @@ end
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.instagram?
json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
## Tiktok Attributes
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
## Twilio Attributes
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
json.phone_number resource.channel.try(:phone_number)

View File

@@ -36,6 +36,7 @@
helpCenterURL: '<%= ENV.fetch('HELPCENTER_URL', '') %>',
fbAppId: '<%= @global_config['FB_APP_ID'] %>',
instagramAppId: '<%= @global_config['INSTAGRAM_APP_ID'] %>',
tiktokAppId: '<%= @global_config['TIKTOK_APP_ID'] %>',
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
allowedLoginMethods: <%= @global_config['ALLOWED_LOGIN_METHODS'].to_json.html_safe %>,

View File

@@ -161,6 +161,10 @@
<symbol id="icon-instagram" viewBox="0 0 24 24">
<path d="M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z"/>
</symbol>
<symbol id="icon-tiktok" viewBox="0 0 21 24">
<path fill="currentColor" d="M17.804,4.814C16.176,3.752 15.19,1.944 15.19,0L11.062,0L11.054,16.541C10.982,18.453 9.371,19.948 7.459,19.872C5.547,19.8 4.052,18.188 4.128,16.277C4.2,14.413 5.731,12.942 7.595,12.942C7.944,12.942 8.289,12.998 8.617,13.102L8.617,8.886C8.277,8.838 7.936,8.814 7.595,8.81C3.407,8.81 0,12.216 0,16.405C0.008,20.597 3.403,23.996 7.599,24C11.788,24 15.194,20.593 15.194,16.405L15.194,8.02C16.866,9.222 18.874,9.872 20.934,9.868L20.934,5.739C19.82,5.743 18.733,5.419 17.804,4.814Z"/>
</symbol>
<symbol id="icon-notion" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.104 5.91c.584.474.802.438 1.898.365l10.332-.62c.22 0 .037-.22-.036-.256l-1.716-1.24c-.329-.255-.767-.548-1.606-.475l-10.005.73c-.364.036-.437.219-.292.365zm.62 2.408v10.87c0 .585.292.803.95.767l11.354-.657c.657-.036.73-.438.73-.913V7.588c0-.474-.182-.73-.584-.693l-11.866.693c-.438.036-.584.255-.584.73m11.21.583c.072.328 0 .657-.33.694l-.547.109v8.025c-.475.256-.913.401-1.278.401c-.584 0-.73-.182-1.168-.729l-3.579-5.618v5.436l1.133.255s0 .656-.914.656l-2.519.146c-.073-.146 0-.51.256-.583l.657-.182v-7.187l-.913-.073c-.073-.329.11-.803.621-.84l2.702-.182l3.724 5.692V9.886l-.95-.109c-.072-.402.22-.693.585-.73zM4.131 3.429l10.406-.766c1.277-.11 1.606-.036 2.41.547l3.321 2.335c.548.401.731.51.731.948v12.805c0 .803-.292 1.277-1.314 1.35l-12.085.73c-.767.036-1.132-.073-1.534-.584L3.62 17.62c-.438-.584-.62-1.021-.62-1.533V4.705c0-.656.292-1.203 1.132-1.276"/>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -227,3 +227,6 @@
enabled: false
premium: true
chatwoot_internal: true
- name: channel_tiktok
display_name: TikTok Channel
enabled: true

View File

@@ -406,6 +406,16 @@
locked: true
# ------- End of Instagram Channel Related Config ------- #
# ------- TikTok Channel Related Config ------- #
- name: TIKTOK_APP_ID
display_title: 'TikTok App ID'
locked: false
- name: TIKTOK_APP_SECRET
display_title: 'TikTok App Secret'
locked: false
type: secret
# ------- End of TikTok Channel Related Config ------- #
# ------- OG Image Related Config ------- #
- name: OG_IMAGE_CDN_URL
display_title: 'OG Image CDN URL'

View File

@@ -19,10 +19,13 @@ Rails.application.routes.draw do
get '/app/accounts/:account_id/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/tiktok', to: 'dashboard#index', as: 'app_new_tiktok_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_email_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_instagram_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_tiktok_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings'
get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings'
resource :widget, only: [:show]
@@ -267,6 +270,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :tiktok do
resource :authorization, only: [:create]
end
namespace :notion do
resource :authorization, only: [:create]
end
@@ -539,6 +546,7 @@ Rails.application.routes.draw do
post 'webhooks/whatsapp/:phone_number', to: 'webhooks/whatsapp#process_payload'
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
namespace :twitter do
resource :callback, only: [:show]
@@ -566,6 +574,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'tiktok/callback', to: 'tiktok/callbacks#show'
get 'notion/callback', to: 'notion/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications

View File

@@ -0,0 +1,15 @@
class AddTiktokChannel < ActiveRecord::Migration[7.1]
def change
create_table :channel_tiktok do |t|
t.integer :account_id, null: false
t.string :business_id, null: false
t.string :access_token, null: false
t.datetime :expires_at, null: false
t.string :refresh_token, null: false
t.datetime :refresh_token_expires_at, null: false
t.timestamps
end
add_index :channel_tiktok, :business_id, unique: true
end
end

View File

@@ -497,6 +497,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_19_161025) do
t.index ["bot_token"], name: "index_channel_telegram_on_bot_token", unique: true
end
create_table "channel_tiktok", force: :cascade do |t|
t.integer "account_id", null: false
t.string "business_id", null: false
t.string "access_token", null: false
t.datetime "expires_at", null: false
t.string "refresh_token", null: false
t.datetime "refresh_token_expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["business_id"], name: "index_channel_tiktok_on_business_id", unique: true
end
create_table "channel_twilio_sms", force: :cascade do |t|
t.string "phone_number"
t.string "auth_token", null: false

View File

@@ -39,6 +39,8 @@ module Redis::RedisKeys
# We don't want to process messages from the same sender concurrently to prevent creating double conversations
FACEBOOK_MESSAGE_MUTEX = 'FB_MESSAGE_CREATE_LOCK::%<sender_id>s::%<recipient_id>s'.freeze
IG_MESSAGE_MUTEX = 'IG_MESSAGE_CREATE_LOCK::%<sender_id>s::%<ig_account_id>s'.freeze
TIKTOK_MESSAGE_MUTEX = 'TIKTOK_MESSAGE_CREATE_LOCK::%<business_id>s::%<conversation_id>s'.freeze
TIKTOK_REFRESH_TOKEN_MUTEX = 'TIKTOK_REFRESH_TOKEN_LOCK::%<channel_id>s'.freeze
SLACK_MESSAGE_MUTEX = 'SLACK_MESSAGE_LOCK::%<conversation_id>s::%<reference_id>s'.freeze
EMAIL_MESSAGE_MUTEX = 'EMAIL_CHANNEL_LOCK::%<inbox_id>s'.freeze
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,55 @@
require 'rails_helper'
RSpec.describe 'TikTok Authorization API', type: :request do
let(:account) { create(:account) }
describe 'POST /api/v1/accounts/{account.id}/tiktok/authorization' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/tiktok/authorization"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
InstallationConfig.where(name: %w[TIKTOK_APP_ID TIKTOK_APP_SECRET]).delete_all
GlobalConfig.clear_cache
end
it 'returns unauthorized for agent' do
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
headers: agent.create_new_auth_token,
as: :json
end
expect(response).to have_http_status(:unauthorized)
end
it 'creates a new authorization and returns the redirect url' do
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
headers: administrator.create_new_auth_token,
as: :json
end
expect(response).to have_http_status(:success)
expect(response.parsed_body['success']).to be true
helper = Class.new do
include Tiktok::IntegrationHelper
end.new
expected_state = helper.generate_tiktok_token(account.id)
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
expect(response.parsed_body['url']).to eq(expected_url)
end
end
end
end

View File

@@ -0,0 +1,139 @@
require 'rails_helper'
RSpec.describe 'TikTok Callbacks', type: :request do
let(:account) { create(:account) }
let(:client_secret) { 'tiktok-app-secret' }
let(:client_id) { 'tiktok-app-id' }
let(:token_endpoint) { 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/' }
let(:business_endpoint) { 'https://business-api.tiktok.com/open_api/v1.3/business/get/' }
let(:tiktok_access_token) { 'access-token-1' }
let(:tiktok_refresh_token) { 'refresh-token-1' }
let(:tiktok_business_id) { 'biz-123' }
let(:token_response) do
{
code: 0,
message: 'ok',
data: {
open_id: tiktok_business_id,
scope: Tiktok::AuthClient::REQUIRED_SCOPES.join(','),
access_token: tiktok_access_token,
refresh_token: tiktok_refresh_token,
expires_in: 86_400,
refresh_token_expires_in: 2_592_000
}
}.to_json
end
let(:business_response) do
{
code: 0,
message: 'ok',
data: {
username: 'tiktok_user',
display_name: 'TikTok Display Name',
profile_image: 'https://www.example.com/avatar.png'
}
}.to_json
end
let(:state) do
JWT.encode({ sub: account.id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
InstallationConfig.where(name: %w[TIKTOK_APP_ID TIKTOK_APP_SECRET]).delete_all
GlobalConfig.clear_cache
stub_request(:post, token_endpoint).to_return(status: 200, body: token_response, headers: { 'Content-Type' => 'application/json' })
stub_request(:get, business_endpoint)
.with(query: hash_including('business_id' => tiktok_business_id))
.to_return(status: 200, body: business_response, headers: { 'Content-Type' => 'application/json' })
end
it 'creates channel and inbox and redirects to agents step for new connections' do
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(instance_of(Inbox), 'https://www.example.com/avatar.png')
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
expect do
get '/tiktok/callback', params: { code: 'valid_code', state: state }
end.to change(Channel::Tiktok, :count).by(1).and change(Inbox, :count).by(1)
end
inbox = Inbox.last
channel = inbox.channel
expect(channel.business_id).to eq(tiktok_business_id)
expect(channel.access_token).to eq(tiktok_access_token)
expect(channel.refresh_token).to eq(tiktok_refresh_token)
expect(response).to redirect_to(app_tiktok_inbox_agents_url(account_id: account.id, inbox_id: inbox.id))
end
it 'updates an existing channel and redirects to settings' do
existing_channel = create(
:channel_tiktok,
account: account,
business_id: tiktok_business_id,
access_token: 'old-access-token',
refresh_token: 'old-refresh-token',
expires_at: 1.hour.ago,
refresh_token_expires_at: 1.day.from_now
)
existing_channel.inbox.update!(name: 'Old Name')
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
expect do
get '/tiktok/callback', params: { code: 'valid_code', state: state }
end.to not_change(Channel::Tiktok, :count).and not_change(Inbox, :count)
end
existing_channel.reload
inbox = existing_channel.inbox.reload
expect(existing_channel.access_token).to eq(tiktok_access_token)
expect(existing_channel.refresh_token).to eq(tiktok_refresh_token)
expect(inbox.name).to eq('TikTok Display Name')
expect(response).to redirect_to(app_tiktok_inbox_settings_url(account_id: account.id, inbox_id: inbox.id))
end
it 'redirects to error page when user denies authorization' do
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
get '/tiktok/callback', params: { error: 'access_denied', error_description: 'User cancelled', error_code: '400', state: state }
end
expect(response).to redirect_to(
app_new_tiktok_inbox_url(
account_id: account.id,
error_type: 'access_denied',
code: '400',
error_message: 'User cancelled'
)
)
end
it 'redirects to error page when required scopes are not granted' do
stub_request(:post, token_endpoint).to_return(
status: 200,
body: JSON.parse(token_response).deep_merge('data' => { 'scope' => 'user.info.basic' }).to_json,
headers: { 'Content-Type' => 'application/json' }
)
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
get '/tiktok/callback', params: { code: 'valid_code', state: state }
end
expect(response).to redirect_to(
app_new_tiktok_inbox_url(
account_id: account.id,
error_type: 'ungranted_scopes',
code: 400,
error_message: 'User did not grant all the required scopes'
)
)
end
end

View File

@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe 'Webhooks::TiktokController', type: :request do
let(:client_secret) { 'test-tiktok-secret' }
let(:timestamp) { Time.current.to_i }
let(:event_payload) do
{
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
end
def signature_for(body)
OpenSSL::HMAC.hexdigest('SHA256', client_secret, "#{timestamp}.#{body}")
end
before do
InstallationConfig.where(name: 'TIKTOK_APP_SECRET').delete_all
GlobalConfig.clear_cache
end
it 'enqueues the events job for valid signature' do
allow(Webhooks::TiktokEventsJob).to receive(:perform_later)
body = event_payload.to_json
with_modified_env TIKTOK_APP_SECRET: client_secret do
post '/webhooks/tiktok',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{signature_for(body)}" }
end
expect(response).to have_http_status(:success)
expect(Webhooks::TiktokEventsJob).to have_received(:perform_later)
end
it 'delays processing by 2 seconds for echo events' do
job_double = class_double(Webhooks::TiktokEventsJob)
allow(Webhooks::TiktokEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
allow(job_double).to receive(:perform_later)
body = event_payload.to_json
with_modified_env TIKTOK_APP_SECRET: client_secret do
post '/webhooks/tiktok?event=im_send_msg',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{signature_for(body)}" }
end
expect(response).to have_http_status(:success)
expect(Webhooks::TiktokEventsJob).to have_received(:set).with(wait: 2.seconds)
expect(job_double).to have_received(:perform_later)
end
it 'returns unauthorized for invalid signature' do
body = event_payload.to_json
with_modified_env TIKTOK_APP_SECRET: client_secret do
post '/webhooks/tiktok',
params: body,
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{'0' * 64}" }
end
expect(response).to have_http_status(:unauthorized)
end
end

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
FactoryBot.define do
factory :channel_tiktok, class: 'Channel::Tiktok' do
account
business_id { SecureRandom.hex(16) }
access_token { SecureRandom.hex(32) }
refresh_token { SecureRandom.hex(32) }
expires_at { 1.day.from_now }
refresh_token_expires_at { 30.days.from_now }
after(:create) do |channel|
create(:inbox, channel: channel, account: channel.account)
end
end
end

View File

@@ -135,5 +135,14 @@ RSpec.describe SendReplyJob do
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do
tiktok_channel = create(:channel_tiktok)
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service)
expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end
end

View File

@@ -0,0 +1,91 @@
require 'rails_helper'
RSpec.describe Webhooks::TiktokEventsJob do
let(:account) { create(:account) }
let!(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
let(:job) { described_class.new }
before do
allow(job).to receive(:with_lock).and_yield
end
describe '#perform' do
it 'processes im_receive_msg events via Tiktok::MessageService' do
message_service = instance_double(Tiktok::MessageService, perform: true)
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
event = {
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
expect(message_service).to have_received(:perform)
end
it 'processes im_mark_read_msg events via Tiktok::ReadStatusService' do
read_status_service = instance_double(Tiktok::ReadStatusService, perform: true)
allow(Tiktok::ReadStatusService).to receive(:new).and_return(read_status_service)
event = {
event: 'im_mark_read_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1', read: { last_read_timestamp: 1_700_000_000_000 }, from_user: { id: 'user-1' } }.to_json
}
job.perform(event)
expect(Tiktok::ReadStatusService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
expect(read_status_service).to have_received(:perform)
end
it 'ignores unsupported event types' do
allow(Tiktok::MessageService).to receive(:new)
event = {
event: 'unknown_event',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
it 'does nothing when channel is missing' do
allow(Tiktok::MessageService).to receive(:new)
event = {
event: 'im_receive_msg',
user_openid: 'biz-does-not-exist',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
it 'does nothing when account is inactive' do
allow(Channel::Tiktok).to receive(:find_by).and_return(channel)
allow(channel.account).to receive(:active?).and_return(false)
message_service = instance_double(Tiktok::MessageService, perform: true)
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
event = {
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
end
end

View File

@@ -155,6 +155,14 @@ RSpec.describe Attachment do
end
end
describe 'push_event_data for embed attachments' do
it 'returns external url as data_url' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :embed, external_url: 'https://example.com/embed')
expect(attachment.push_event_data[:data_url]).to eq('https://example.com/embed')
end
end
describe 'file size validation' do
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }

View File

@@ -0,0 +1,117 @@
require 'rails_helper'
RSpec.describe Tiktok::MessageService do
let(:account) { create(:account) }
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
describe '#perform' do
it 'creates an incoming text message' do
content = {
type: 'text',
message_id: 'tt-msg-1',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
text: { body: 'Hello from TikTok' },
from: 'Alice',
from_user: { id: 'user-1' },
to: 'Biz',
to_user: { id: 'biz-123' }
}.deep_symbolize_keys
expect do
service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
service.perform
end.to change(Message, :count).by(1)
message = Message.last
expect(message.inbox).to eq(inbox)
expect(message.message_type).to eq('incoming')
expect(message.content).to eq('Hello from TikTok')
expect(message.source_id).to eq('tt-msg-1')
expect(message.sender).to eq(contact)
expect(message.content_attributes['is_unsupported']).to be_nil
end
it 'creates an incoming unsupported message for non-supported types' do
content = {
type: 'sticker',
message_id: 'tt-msg-2',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
from: 'Alice',
from_user: { id: 'user-1' },
to: 'Biz',
to_user: { id: 'biz-123' }
}.deep_symbolize_keys
service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
service.perform
message = Message.last
expect(message.content).to be_nil
expect(message.content_attributes['is_unsupported']).to be true
end
it 'creates an incoming embed attachment for share_post messages' do
content = {
type: 'share_post',
message_id: 'tt-msg-3',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
share_post: { embed_url: 'https://www.tiktok.com/embed/123' },
from: 'Alice',
from_user: { id: 'user-1' },
to: 'Biz',
to_user: { id: 'biz-123' }
}.deep_symbolize_keys
service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
service.perform
message = Message.last
expect(message.attachments.count).to eq(1)
attachment = message.attachments.last
expect(attachment.file_type).to eq('embed')
expect(attachment.external_url).to eq('https://www.tiktok.com/embed/123')
end
it 'creates an incoming image attachment when media is present' do
content = {
type: 'image',
message_id: 'tt-msg-4',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
image: { media_id: 'media-1' },
from: 'Alice',
from_user: { id: 'user-1' },
to: 'Biz',
to_user: { id: 'biz-123' }
}.deep_symbolize_keys
tempfile = Tempfile.new(['tiktok', '.png'])
tempfile.write('fake-image')
tempfile.rewind
tempfile.define_singleton_method(:original_filename) { 'tiktok.png' }
tempfile.define_singleton_method(:content_type) { 'image/png' }
service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
allow(service).to receive(:fetch_attachment).and_return(tempfile)
service.perform
message = Message.last
expect(message.attachments.count).to eq(1)
expect(message.attachments.last.file_type).to eq('image')
expect(message.attachments.last.file).to be_attached
ensure
tempfile.close!
end
end
end

View File

@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe Tiktok::ReadStatusService do
let(:account) { create(:account) }
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
let!(:conversation) do
create(
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
additional_attributes: { conversation_id: 'tt-conv-1' }
)
end
describe '#perform' do
it 'enqueues Conversations::UpdateMessageStatusJob for inbound read events' do
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
content = {
conversation_id: 'tt-conv-1',
read: { last_read_timestamp: 1_700_000_000_000 },
from_user: { id: 'user-1' }
}.deep_symbolize_keys
described_class.new(channel: channel, content: content).perform
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time))
end
end
end

View File

@@ -0,0 +1,71 @@
require 'rails_helper'
RSpec.describe Tiktok::SendOnTiktokService do
let(:tiktok_client) { instance_double(Tiktok::Client) }
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
let(:channel) { create(:channel_tiktok, business_id: 'biz-123') }
let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: inbox.account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
let(:conversation) do
create(
:conversation,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
additional_attributes: { conversation_id: 'tt-conv-1' }
)
end
before do
allow(channel).to receive(:validated_access_token).and_return('valid-access-token')
allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
end
describe '#perform' do
it 'sends outgoing text message and updates source_id' do
allow(tiktok_client).to receive(:send_text_message).and_return('tt-msg-123')
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Hello')
message.update!(source_id: nil)
described_class.new(message: message).perform
expect(tiktok_client).to have_received(:send_text_message).with('tt-conv-1', 'Hello', referenced_message_id: nil)
expect(message.reload.source_id).to eq('tt-msg-123')
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
end
it 'sends outgoing image message when a single attachment is present' do
allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!
described_class.new(message: message).perform
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
expect(message.reload.source_id).to eq('tt-msg-124')
end
it 'marks message as failed when sending multiple attachments' do
allow(tiktok_client).to receive(:send_media_message)
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
a1 = message.attachments.new(account_id: message.account_id, file_type: :image)
a1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
a2 = message.attachments.new(account_id: message.account_id, file_type: :image)
a2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!
described_class.new(message: message).perform
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
expect(tiktok_client).not_to have_received(:send_media_message)
end
end
end

View File

@@ -0,0 +1,57 @@
require 'rails_helper'
RSpec.describe Tiktok::TokenService do
let(:channel) do
create(
:channel_tiktok,
access_token: 'old-access-token',
refresh_token: 'old-refresh-token',
expires_at: 1.minute.ago,
refresh_token_expires_at: 1.day.from_now
)
end
describe '#access_token' do
it 'returns current token when it is still valid' do
channel.update!(expires_at: 10.minutes.from_now)
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token)
token = described_class.new(channel: channel).access_token
expect(token).to eq('old-access-token')
expect(Tiktok::AuthClient).not_to have_received(:renew_short_term_access_token)
end
it 'refreshes access token when expired and refresh token is valid' do
lock_manager = instance_double(Redis::LockManager, lock: true, unlock: true)
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token).and_return(
{
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
expires_at: 1.day.from_now,
refresh_token_expires_at: 30.days.from_now
}.with_indifferent_access
)
token = described_class.new(channel: channel).access_token
expect(token).to eq('new-access-token')
expect(channel.reload.access_token).to eq('new-access-token')
expect(channel.refresh_token).to eq('new-refresh-token')
end
it 'prompts reauthorization when both access and refresh tokens are expired' do
channel.update!(expires_at: 1.day.ago, refresh_token_expires_at: 1.minute.ago)
allow(channel).to receive(:reauthorization_required?).and_return(false)
allow(channel).to receive(:prompt_reauthorization!)
token = described_class.new(channel: channel).access_token
expect(token).to eq('old-access-token')
expect(channel).to have_received(:prompt_reauthorization!)
end
end
end

View File

@@ -139,6 +139,11 @@ export const icons = {
width: 12,
height: 12,
},
tiktok: {
body: `<path d="M10.206,2.759C9.273,2.15 8.708,1.114 8.708,0L6.341,0L6.337,9.482C6.295,10.578 5.372,11.435 4.276,11.391C3.18,11.35 2.323,10.426 2.366,9.33C2.408,8.262 3.285,7.419 4.354,7.419C4.554,7.419 4.751,7.451 4.94,7.511L4.94,5.094C4.744,5.066 4.549,5.052 4.354,5.05C1.953,5.05 0,7.003 0,9.404C0.005,11.807 1.951,13.755 4.356,13.758C6.757,13.758 8.71,11.805 8.71,9.404L8.71,4.597C9.668,5.287 10.819,5.659 12,5.657L12,3.29C11.361,3.292 10.739,3.106 10.206,2.759Z" fill="currentColor"/>`,
width: 12,
height: 14,
},
messenger: {
body: `<path fill-rule="evenodd" clip-rule="evenodd" d="M.333 7a6.667 6.667 0 1 1 3.221 5.709l-2.033.597a.667.667 0 0 1-.827-.827l.598-2.033A6.64 6.64 0 0 1 .333 7M5.53 5.53c.26-.26.682-.26.942 0L8 7.057 9.529 5.53a.667.667 0 1 1 .942.943l-2 2a.667.667 0 0 1-.942 0L6 6.943 4.471 8.472a.667.667 0 1 1-.942-.943z" fill="currentColor"/>`,
width: 14,