Merge branch 'develop' into feat/v5-ui-redesign
This commit is contained in:
@@ -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
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: '',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
|
||||
const iconByType = {
|
||||
text: 'i-lucide-align-justify',
|
||||
checkbox: 'i-lucide-circle-check-big',
|
||||
list: 'i-lucide-list',
|
||||
date: 'i-lucide-calendar',
|
||||
link: 'i-lucide-link',
|
||||
number: 'i-lucide-hash',
|
||||
};
|
||||
|
||||
const attributeIcon = computed(() => {
|
||||
const typeKey = props.attribute.type?.toLowerCase();
|
||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2 items-center min-w-0">
|
||||
<h4 class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.label }}
|
||||
</h4>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<div class="flex gap-2 items-center text-sm text-n-slate-11">
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon :icon="attributeIcon" class="size-4" />
|
||||
<span class="text-sm">{{ attribute.type }}</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-n-weak" />
|
||||
<div class="flex items-center gap-1.5 text-n-slate-11">
|
||||
<Icon icon="i-lucide-key-round" class="size-4" />
|
||||
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<AttributeBadge
|
||||
v-for="badge in badges"
|
||||
:key="badge.type"
|
||||
:type="badge.type"
|
||||
/>
|
||||
<div
|
||||
v-if="badges.length > 0"
|
||||
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-pencil-line"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('edit', attribute)"
|
||||
/>
|
||||
<div class="w-px h-3 bg-n-strong" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
size="sm"
|
||||
color="slate"
|
||||
ghost
|
||||
@click="emit('delete', attribute)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ attribute.attribute_description || attribute.description || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'resolution',
|
||||
validator: value => ['pre-chat', 'resolution'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const attributeConfig = {
|
||||
'pre-chat': {
|
||||
colorClass: 'text-n-blue-11',
|
||||
icon: 'i-lucide-message-circle',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
|
||||
},
|
||||
resolution: {
|
||||
colorClass: 'text-n-teal-11',
|
||||
icon: 'i-lucide-circle-check-big',
|
||||
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
|
||||
},
|
||||
};
|
||||
const config = computed(
|
||||
() => attributeConfig[props.type] || attributeConfig.resolution
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
|
||||
>
|
||||
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
|
||||
<span class="text-xs" :class="config.colorClass">{{
|
||||
t(config.labelKey)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +14,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -121,6 +121,7 @@ const {
|
||||
isAnInstagramChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
const inboxHasFeature = feature => {
|
||||
@@ -213,6 +214,9 @@ const replyWindowLink = computed(() => {
|
||||
if (isAWhatsAppCloudChannel.value) {
|
||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (isATiktokChannel.value) {
|
||||
return REPLY_POLICY.TIKTOK;
|
||||
}
|
||||
if (!isAPIInbox.value) {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
}
|
||||
@@ -227,6 +231,9 @@ const replyWindowLinkText = computed(() => {
|
||||
) {
|
||||
return t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
if (isATiktokChannel.value) {
|
||||
return t('CONVERSATION.48_HOURS_WINDOW');
|
||||
}
|
||||
if (!isAPIInbox.value) {
|
||||
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -150,6 +156,7 @@ export const useInbox = (inboxId = null) => {
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
isAVoiceChannel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -44,6 +46,7 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-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';
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -129,6 +129,10 @@
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
}
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
},
|
||||
"contact": {
|
||||
"CONTENT": "Shared contact"
|
||||
},
|
||||
"embed": {
|
||||
"CONTENT": "Embedded content"
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_BY_FILTER": {
|
||||
|
||||
@@ -481,6 +481,9 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
},
|
||||
"LINKEDIN": {
|
||||
"PLACEHOLDER": "Add LinkedIn"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 won’t 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/@' },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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/@' },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AddAttribute from './AddAttribute.vue';
|
||||
import CustomAttribute from './CustomAttribute.vue';
|
||||
import EditAttribute from './EditAttribute.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import {
|
||||
useStoreGetters,
|
||||
useStore,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
const { currentAccount } = useAccount();
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const showAddPopup = ref(false);
|
||||
const [showAddPopup, toggleAddPopup] = useToggle(false);
|
||||
const selectedTabIndex = ref(0);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
const [showEditPopup, toggleEditPopup] = useToggle(false);
|
||||
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
|
||||
const selectedAttribute = ref({});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
toggleAddPopup(true);
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
toggleAddPopup(false);
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
toggleEditPopup(false);
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
const closeDelete = () => {
|
||||
toggleDeletePopup(false);
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -37,6 +59,10 @@ const tabs = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const tabsForTabBar = computed(() =>
|
||||
tabs.value.map(tab => ({ label: tab.name, key: tab.key }))
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
@@ -49,9 +75,75 @@ const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||
);
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
const onClickTabChange = tab => {
|
||||
selectedTabIndex.value = tab.key;
|
||||
};
|
||||
|
||||
const handleEditAttribute = attribute => {
|
||||
selectedAttribute.value = attribute;
|
||||
toggleEditPopup(true);
|
||||
};
|
||||
|
||||
const handleDeleteAttribute = attribute => {
|
||||
selectedAttribute.value = attribute;
|
||||
toggleDeletePopup(true);
|
||||
};
|
||||
|
||||
const confirmDeleteAttribute = async () => {
|
||||
try {
|
||||
await store.dispatch('attributes/delete', selectedAttribute.value.id);
|
||||
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
closeDelete();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const requiredAttributeKeys = computed(
|
||||
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
|
||||
const hasPreChatBadge = attribute => {
|
||||
return (inboxes.value || []).some(inbox => {
|
||||
const fields =
|
||||
inbox?.pre_chat_form_options?.pre_chat_fields ||
|
||||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
|
||||
[];
|
||||
return fields.some(field => field.name === attribute.attribute_key);
|
||||
});
|
||||
};
|
||||
|
||||
const buildBadges = attribute => {
|
||||
const badges = [];
|
||||
if (hasPreChatBadge(attribute)) {
|
||||
badges.push({
|
||||
type: 'pre-chat',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
attribute.attribute_model === 'conversation_attribute' &&
|
||||
requiredAttributeKeys.value.includes(attribute.attribute_key)
|
||||
) {
|
||||
badges.push({
|
||||
type: 'resolution',
|
||||
});
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const derivedAttributes = computed(() =>
|
||||
attributes.value.map(attribute => ({
|
||||
...attribute,
|
||||
label: attribute.attribute_display_name,
|
||||
type: attribute.attribute_display_type,
|
||||
value: attribute.attribute_key,
|
||||
badges: buildBadges(attribute),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,27 +169,25 @@ const onClickTabChange = index => {
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #preBody>
|
||||
<woot-tabs
|
||||
class="font-medium [&_ul]:p-0 mb-4"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.key"
|
||||
:index="index"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
<template #body>
|
||||
<CustomAttribute
|
||||
:key="attributeModel"
|
||||
:attribute-model="attributeModel"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<TabBar
|
||||
:tabs="tabsForTabBar"
|
||||
:initial-active-tab="selectedTabIndex"
|
||||
class="max-w-xl"
|
||||
@tab-changed="onClickTabChange"
|
||||
/>
|
||||
<div class="grid gap-3">
|
||||
<AttributeListItem
|
||||
v-for="attribute in derivedAttributes"
|
||||
:key="attribute.id"
|
||||
:attribute="attribute"
|
||||
:badges="attribute.badges"
|
||||
@edit="handleEditAttribute"
|
||||
@delete="handleDeleteAttribute"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<AddAttribute
|
||||
v-if="showAddPopup"
|
||||
@@ -105,5 +195,34 @@ const onClickTabChange = index => {
|
||||
:on-close="hideAddPopup"
|
||||
:selected-attribute-model-tab="selectedTabIndex"
|
||||
/>
|
||||
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||
<EditAttribute
|
||||
:selected-attribute="selectedAttribute"
|
||||
:is-updating="uiFlags.isUpdating"
|
||||
@on-close="hideEditPopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-confirm-delete-modal
|
||||
v-if="showDeletePopup"
|
||||
v-model:show="showDeletePopup"
|
||||
:title="
|
||||
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||
attributeName: selectedAttribute.attribute_display_name,
|
||||
})
|
||||
"
|
||||
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
|
||||
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
|
||||
selectedAttribute.attribute_display_name || ''
|
||||
}`"
|
||||
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
|
||||
:confirm-value="selectedAttribute.attribute_display_name"
|
||||
:confirm-place-holder-text="
|
||||
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||
attributeName: selectedAttribute.attribute_display_name,
|
||||
})
|
||||
"
|
||||
@on-confirm="confirmDeleteAttribute"
|
||||
@on-close="closeDelete"
|
||||
/>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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-woot-voice',
|
||||
},
|
||||
];
|
||||
|
||||
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-woot-voice',
|
||||
});
|
||||
|
||||
return channels;
|
||||
});
|
||||
|
||||
const initializeEnabledFeatures = async () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+37
@@ -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>
|
||||
@@ -29,6 +29,7 @@ const i18nMap = {
|
||||
'Channel::Line': 'LINE',
|
||||
'Channel::Api': 'API',
|
||||
'Channel::Instagram': 'INSTAGRAM',
|
||||
'Channel::Tiktok': 'TIKTOK',
|
||||
'Channel::Voice': 'VOICE',
|
||||
};
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
||||
welcomeDescription: chatwootSettings.welcomeDescription || '',
|
||||
availableMessage: chatwootSettings.availableMessage || '',
|
||||
unavailableMessage: chatwootSettings.unavailableMessage || '',
|
||||
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
|
||||
enableFileUpload: chatwootSettings.enableFileUpload,
|
||||
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
|
||||
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useAttachments } from '../composables/useAttachments';
|
||||
|
||||
export default {
|
||||
components: { FluentIcon, FileUpload, Spinner },
|
||||
@@ -20,13 +21,16 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
return { canHandleAttachments };
|
||||
},
|
||||
data() {
|
||||
return { isUploading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
}),
|
||||
fileUploadSizeLimit() {
|
||||
return resolveMaximumFileUploadSize(
|
||||
@@ -46,7 +50,7 @@ export default {
|
||||
methods: {
|
||||
handleClipboardPaste(e) {
|
||||
// If file picker is not enabled, do not allow paste
|
||||
if (!this.shouldShowFilePicker) return;
|
||||
if (!this.canHandleAttachments) return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
// items is a DataTransferItemList object which does not have forEach method
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
|
||||
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { useAttachments } from '../composables/useAttachments';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
|
||||
@@ -18,7 +18,6 @@ export default {
|
||||
FluentIcon,
|
||||
ResizableTextArea,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
onSendMessage: {
|
||||
type: Function,
|
||||
@@ -29,6 +28,18 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
} = useAttachments();
|
||||
return {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userInput: '',
|
||||
@@ -41,15 +52,10 @@ export default {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
|
||||
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
|
||||
}),
|
||||
showAttachment() {
|
||||
return (
|
||||
this.shouldShowFilePicker &&
|
||||
this.hasAttachmentsEnabled &&
|
||||
this.userInput.length === 0
|
||||
);
|
||||
return this.canHandleAttachments && this.userInput.length === 0;
|
||||
},
|
||||
showSendButton() {
|
||||
return this.userInput.length > 0;
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAttachments } from '../useAttachments';
|
||||
import { useStore } from 'vuex';
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Mock Vue's useStore
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock Vue's computed
|
||||
vi.mock('vue', () => ({
|
||||
computed: vi.fn(fn => ({ value: fn() })),
|
||||
}));
|
||||
|
||||
describe('useAttachments', () => {
|
||||
let mockStore;
|
||||
let mockGetters;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset window.chatwootWebChannel
|
||||
delete window.chatwootWebChannel;
|
||||
|
||||
// Create mock store
|
||||
mockGetters = {};
|
||||
mockStore = {
|
||||
getters: mockGetters,
|
||||
};
|
||||
vi.mocked(useStore).mockReturnValue(mockStore);
|
||||
|
||||
// Mock computed to return a reactive-like object
|
||||
vi.mocked(computed).mockImplementation(fn => ({ value: fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('shouldShowFilePicker', () => {
|
||||
it('returns value from store getter', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
|
||||
const { shouldShowFilePicker } = useAttachments();
|
||||
|
||||
expect(shouldShowFilePicker.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns undefined when not set in store', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
|
||||
const { shouldShowFilePicker } = useAttachments();
|
||||
|
||||
expect(shouldShowFilePicker.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAttachmentsEnabled', () => {
|
||||
it('returns true when attachments are enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments', 'emoji'],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when attachments are not enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when channel config has no enabled features', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: [],
|
||||
};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when channel config is missing', () => {
|
||||
window.chatwootWebChannel = undefined;
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when enabledFeatures is missing', () => {
|
||||
window.chatwootWebChannel = {};
|
||||
|
||||
const { hasAttachmentsEnabled } = useAttachments();
|
||||
|
||||
expect(hasAttachmentsEnabled.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canHandleAttachments', () => {
|
||||
beforeEach(() => {
|
||||
// Set up a default channel config
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
});
|
||||
|
||||
it('prioritizes SDK flag when explicitly set to true', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes SDK flag when explicitly set to false', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = false;
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to inbox settings when SDK flag is undefined', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('prioritizes SDK false over inbox settings true', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = false;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(false);
|
||||
});
|
||||
|
||||
it('prioritizes SDK true over inbox settings false', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = true;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji'], // no attachments
|
||||
};
|
||||
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
|
||||
expect(canHandleAttachments.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEmojiPickerEnabled', () => {
|
||||
it('returns true when emoji picker is enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['emoji_picker', 'attachments'],
|
||||
};
|
||||
|
||||
const { hasEmojiPickerEnabled } = useAttachments();
|
||||
|
||||
expect(hasEmojiPickerEnabled.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when emoji picker is not enabled in channel config', () => {
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments'],
|
||||
};
|
||||
|
||||
const { hasEmojiPickerEnabled } = useAttachments();
|
||||
|
||||
expect(hasEmojiPickerEnabled.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowEmojiPicker', () => {
|
||||
it('returns value from store getter', () => {
|
||||
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
|
||||
|
||||
const { shouldShowEmojiPicker } = useAttachments();
|
||||
|
||||
expect(shouldShowEmojiPicker.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration test', () => {
|
||||
it('returns all expected properties', () => {
|
||||
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
|
||||
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
|
||||
window.chatwootWebChannel = {
|
||||
enabledFeatures: ['attachments', 'emoji_picker'],
|
||||
};
|
||||
|
||||
const result = useAttachments();
|
||||
|
||||
expect(result).toHaveProperty('shouldShowFilePicker');
|
||||
expect(result).toHaveProperty('shouldShowEmojiPicker');
|
||||
expect(result).toHaveProperty('hasAttachmentsEnabled');
|
||||
expect(result).toHaveProperty('hasEmojiPickerEnabled');
|
||||
expect(result).toHaveProperty('canHandleAttachments');
|
||||
expect(Object.keys(result)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
export function useAttachments() {
|
||||
const store = useStore();
|
||||
|
||||
const shouldShowFilePicker = computed(
|
||||
() => store.getters['appConfig/getShouldShowFilePicker']
|
||||
);
|
||||
|
||||
const shouldShowEmojiPicker = computed(
|
||||
() => store.getters['appConfig/getShouldShowEmojiPicker']
|
||||
);
|
||||
|
||||
const hasAttachmentsEnabled = computed(() => {
|
||||
const channelConfig = window.chatwootWebChannel;
|
||||
return channelConfig?.enabledFeatures?.includes('attachments') || false;
|
||||
});
|
||||
|
||||
const hasEmojiPickerEnabled = computed(() => {
|
||||
const channelConfig = window.chatwootWebChannel;
|
||||
return channelConfig?.enabledFeatures?.includes('emoji_picker') || false;
|
||||
});
|
||||
|
||||
const canHandleAttachments = computed(() => {
|
||||
// If enableFileUpload was explicitly set via SDK, prioritize that
|
||||
if (shouldShowFilePicker.value !== undefined) {
|
||||
return shouldShowFilePicker.value;
|
||||
}
|
||||
|
||||
// Otherwise, fall back to inbox settings only
|
||||
return hasAttachmentsEnabled.value;
|
||||
});
|
||||
|
||||
return {
|
||||
shouldShowFilePicker,
|
||||
shouldShowEmojiPicker,
|
||||
hasAttachmentsEnabled,
|
||||
hasEmojiPickerEnabled,
|
||||
canHandleAttachments,
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const state = {
|
||||
welcomeDescription: '',
|
||||
availableMessage: '',
|
||||
unavailableMessage: '',
|
||||
enableFileUpload: true,
|
||||
enableFileUpload: undefined,
|
||||
enableEmojiPicker: true,
|
||||
enableEndConversation: true,
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export const actions = {
|
||||
welcomeDescription = '',
|
||||
availableMessage = '',
|
||||
unavailableMessage = '',
|
||||
enableFileUpload = true,
|
||||
enableFileUpload = undefined,
|
||||
enableEmojiPicker = true,
|
||||
enableEndConversation = true,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %>,
|
||||
|
||||
@@ -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 |
@@ -227,3 +227,6 @@
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: channel_tiktok
|
||||
display_name: TikTok Channel
|
||||
enabled: true
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -222,6 +222,11 @@ export const icons = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
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: `<g fill="none" stroke="currentColor"><path d="M17.6568 6.34316C14.5326 3.21895 9.46735 3.21895 6.34314 6.34316C3.63893 9.04737 3.27537 13.2059 5.25266 16.3012L4.7839 18.709C4.72531 19.0099 4.99007 19.2747 5.29104 19.2161L7.69882 18.7473C10.7941 20.7246 14.9526 20.3611 17.6568 17.6569C20.7811 14.5326 20.7811 9.46736 17.6568 6.34316Z" stroke-width="1.6" stroke-linejoin="round"/><path d="M15.8218 10.624L13.2228 13.2231L10.853 10.8534L8.25391 13.4524" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 24,
|
||||
|
||||
Reference in New Issue
Block a user