fixes: #11834 This pull request introduces TikTok channel integration, enabling users to connect and manage TikTok business accounts similarly to other supported social channels. The changes span backend API endpoints, authentication helpers, webhook handling, configuration, and frontend components to support TikTok as a first-class channel. **Key Notes** * This integration is only compatible with TikTok Business Accounts * Special permissions are required to access the TikTok [Business Messaging API](https://business-api.tiktok.com/portal/docs?id=1832183871604753). * The Business Messaging API is region-restricted and is currently unavailable to users in the EU. * Only TEXT, IMAGE, and POST_SHARE messages are currently supported due to limitations in the TikTok Business Messaging API * A message will be successfully sent only if it contains text alone or one image attachment. Messages with multiple attachments or those combining text and attachments will fail and receive a descriptive error status. * Messages sent directly from the TikTok App will be synced into the system * Initiating a new conversation from the system is not permitted due to limitations from the TikTok Business Messaging API. **Backend: TikTok Channel Integration** * Added `Api::V1::Accounts::Tiktok::AuthorizationsController` to handle TikTok OAuth authorization initiation, returning the TikTok authorization URL. * Implemented `Tiktok::CallbacksController` to handle TikTok OAuth callback, process authorization results, create or update channel/inbox, and handle errors or denied scopes. * Added `Webhooks::TiktokController` to receive and verify TikTok webhook events, including signature verification and event dispatching. * Created `Tiktok::IntegrationHelper` module for JWT-based token generation and verification for secure TikTok OAuth state management. **Configuration and Feature Flags** * Added TikTok app credentials (`TIKTOK_APP_ID`, `TIKTOK_APP_SECRET`) to allowed configs and app config, and registered TikTok as a feature in the super admin features YAML. [[1]](diffhunk://#diff-5e46e1d248631a1147521477d84a54f8ba6846ea21c61eca5f70042d960467f4R43) [[2]](diffhunk://#diff-8bf37a019cab1dedea458c437bd93e34af1d6e22b1672b1d43ef6eaa4dcb7732R69) [[3]](diffhunk://#diff-123164bea29f3c096b0d018702b090d5ae670760c729141bd4169a36f5f5c1caR74-R79) **Frontend: TikTok Channel UI and Messaging Support** * Added `TiktokChannel` API client for frontend TikTok authorization requests. * Updated channel icon mappings and tests to include TikTok (`Channel::Tiktok`). [[1]](diffhunk://#diff-b852739ed45def61218d581d0de1ba73f213f55570aa5eec52aaa08f380d0e16R16) [[2]](diffhunk://#diff-3cd3ae32e94ef85f1f2c4435abf0775cc0614fb37ee25d97945cd51573ef199eR64-R69) * Enabled TikTok as a supported channel in contact forms, channel widgets, and feature toggles. [[1]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R47) [[2]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R69) [[3]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R26-R29) [[4]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R51-R54) [[5]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R68) * Updated message meta logic to support TikTok-specific message statuses (sent, delivered, read). [[1]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696R23) [[2]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L63-R65) [[3]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L81-R84) [[4]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L103-R107) * Added support for embedded message attachments (e.g., TikTok embeds) with a new `EmbedBubble` component and updated message rendering logic. [[1]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR31) [[2]](diffhunk://#diff-047859f9368a46d6d20177df7d6d623768488ecc38a5b1e284f958fad49add68R1-R19) [[3]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR316) [[4]](diffhunk://#diff-cbc85e7c4c8d56f2a847d0b01cd48ef36e5f87b43023bff0520fdfc707283085R52) * Adjusted reply policy and UI messaging for TikTok's 48-hour reply window. [[1]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R208-R210) [[2]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R224-R226) These changes collectively enable end-to-end TikTok channel support, from configuration and OAuth flow to webhook processing and frontend message handling. ------------ # TikTok App Setup & Configuration 1. Grant access to the Business Messaging API ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832184145137922)) 2. Set the app authorization redirect URL to `https://FRONTEND_URL/tiktok/callback` 3. Update the installation config with TikTok App ID and Secret 4. Create a Business Messaging Webhook configuration and set the callback url to `https://FRONTEND_URL/webhooks/tiktok` ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832190670631937)) . You can do this by calling `Tiktok::AuthClient.update_webhook_callback` from rails console once you finish Tiktok channel configuration in super admin ( will be automated in future ) 5. Enable TikTok channel feature in an account --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
|
import * as types from '../mutation-types';
|
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
import InboxesAPI from '../../api/inboxes';
|
|
import WebChannel from '../../api/channel/webChannel';
|
|
import FBChannel from '../../api/channel/fbChannel';
|
|
import TwilioChannel from '../../api/channel/twilioChannel';
|
|
import WhatsappChannel from '../../api/channel/whatsappChannel';
|
|
import { throwErrorMessage } from '../utils/api';
|
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
|
import camelcaseKeys from 'camelcase-keys';
|
|
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
|
import { channelActions, buildInboxData } from './inboxes/channelActions';
|
|
|
|
export const state = {
|
|
records: [],
|
|
uiFlags: {
|
|
isFetching: false,
|
|
isFetchingItem: false,
|
|
isCreating: false,
|
|
isUpdating: false,
|
|
isDeleting: false,
|
|
isUpdatingIMAP: false,
|
|
isUpdatingSMTP: false,
|
|
},
|
|
};
|
|
|
|
export const getters = {
|
|
getInboxes($state) {
|
|
return $state.records;
|
|
},
|
|
getAllInboxes($state) {
|
|
return camelcaseKeys($state.records, { deep: true });
|
|
},
|
|
getWhatsAppTemplates: $state => inboxId => {
|
|
const [inbox] = $state.records.filter(
|
|
record => record.id === Number(inboxId)
|
|
);
|
|
|
|
const {
|
|
message_templates: whatsAppMessageTemplates,
|
|
additional_attributes: additionalAttributes,
|
|
} = inbox || {};
|
|
|
|
const { message_templates: apiInboxMessageTemplates } =
|
|
additionalAttributes || {};
|
|
const messagesTemplates =
|
|
whatsAppMessageTemplates || apiInboxMessageTemplates;
|
|
|
|
return messagesTemplates;
|
|
},
|
|
getFilteredWhatsAppTemplates: $state => inboxId => {
|
|
const [inbox] = $state.records.filter(
|
|
record => record.id === Number(inboxId)
|
|
);
|
|
|
|
const {
|
|
message_templates: whatsAppMessageTemplates,
|
|
additional_attributes: additionalAttributes,
|
|
} = inbox || {};
|
|
|
|
const { message_templates: apiInboxMessageTemplates } =
|
|
additionalAttributes || {};
|
|
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
|
|
|
|
if (!templates || !Array.isArray(templates)) {
|
|
return [];
|
|
}
|
|
|
|
return templates.filter(template => {
|
|
// Ensure template has required properties
|
|
if (!template || !template.status || !template.components) {
|
|
return false;
|
|
}
|
|
|
|
// Only show approved templates
|
|
if (template.status.toLowerCase() !== 'approved') {
|
|
return false;
|
|
}
|
|
|
|
// Filter out authentication templates
|
|
if (template.category === 'AUTHENTICATION') {
|
|
return false;
|
|
}
|
|
|
|
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
|
const hasUnsupportedComponents = template.components.some(
|
|
component =>
|
|
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
|
|
component.type
|
|
) ||
|
|
(component.type === 'HEADER' && component.format === 'LOCATION')
|
|
);
|
|
|
|
if (hasUnsupportedComponents) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
},
|
|
getNewConversationInboxes($state) {
|
|
return $state.records.filter(inbox => {
|
|
const { channel_type: channelType, phone_number: phoneNumber = '' } =
|
|
inbox;
|
|
|
|
const isEmailChannel = channelType === INBOX_TYPES.EMAIL;
|
|
const isSmsChannel =
|
|
channelType === INBOX_TYPES.TWILIO &&
|
|
phoneNumber.startsWith('whatsapp');
|
|
return isEmailChannel || isSmsChannel;
|
|
});
|
|
},
|
|
getInbox: $state => inboxId => {
|
|
const [inbox] = $state.records.filter(
|
|
record => record.id === Number(inboxId)
|
|
);
|
|
return inbox || {};
|
|
},
|
|
getInboxById: $state => inboxId => {
|
|
const [inbox] = $state.records.filter(
|
|
record => record.id === Number(inboxId)
|
|
);
|
|
return camelcaseKeys(inbox || {}, { deep: true });
|
|
},
|
|
getUIFlags($state) {
|
|
return $state.uiFlags;
|
|
},
|
|
getWebsiteInboxes($state) {
|
|
return $state.records.filter(item => item.channel_type === INBOX_TYPES.WEB);
|
|
},
|
|
getTwilioInboxes($state) {
|
|
return $state.records.filter(
|
|
item => item.channel_type === INBOX_TYPES.TWILIO
|
|
);
|
|
},
|
|
getSMSInboxes($state) {
|
|
return $state.records.filter(
|
|
item =>
|
|
item.channel_type === INBOX_TYPES.SMS ||
|
|
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
|
);
|
|
},
|
|
getWhatsAppInboxes($state) {
|
|
return $state.records.filter(
|
|
item => item.channel_type === INBOX_TYPES.WHATSAPP
|
|
);
|
|
},
|
|
dialogFlowEnabledInboxes($state) {
|
|
return $state.records.filter(
|
|
item => item.channel_type !== INBOX_TYPES.EMAIL
|
|
);
|
|
},
|
|
getFacebookInboxByInstagramId: $state => instagramId => {
|
|
return $state.records.find(
|
|
item =>
|
|
item.instagram_id === instagramId &&
|
|
item.channel_type === INBOX_TYPES.FB
|
|
);
|
|
},
|
|
getInstagramInboxByInstagramId: $state => instagramId => {
|
|
return $state.records.find(
|
|
item =>
|
|
item.instagram_id === instagramId &&
|
|
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 => {
|
|
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
|
|
channelType,
|
|
});
|
|
};
|
|
|
|
export const actions = {
|
|
revalidate: async ({ commit }, { newKey }) => {
|
|
try {
|
|
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
|
|
if (!isExistingKeyValid) {
|
|
const response = await InboxesAPI.refetchAndCommit(newKey);
|
|
commit(types.default.SET_INBOXES, response.data.payload);
|
|
}
|
|
} catch (error) {
|
|
// Ignore error
|
|
}
|
|
},
|
|
get: async ({ commit }) => {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
|
|
try {
|
|
const response = await InboxesAPI.get(true);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
|
|
commit(types.default.SET_INBOXES, response.data.payload);
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
|
|
}
|
|
},
|
|
createChannel: async ({ commit }, params) => {
|
|
try {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
|
const response = await WebChannel.create(params);
|
|
commit(types.default.ADD_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
const { channel = {} } = params;
|
|
sendAnalyticsEvent(channel.type);
|
|
return response.data;
|
|
} catch (error) {
|
|
const errorMessage = error?.response?.data?.message;
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
throw new Error(errorMessage);
|
|
}
|
|
},
|
|
createWebsiteChannel: async ({ commit }, params) => {
|
|
try {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
|
const response = await WebChannel.create(buildInboxData(params));
|
|
commit(types.default.ADD_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
sendAnalyticsEvent('website');
|
|
return response.data;
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
return throwErrorMessage(error);
|
|
}
|
|
},
|
|
createTwilioChannel: async ({ commit }, params) => {
|
|
try {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
|
const response = await TwilioChannel.create(params);
|
|
commit(types.default.ADD_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
sendAnalyticsEvent('twilio');
|
|
return response.data;
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
throw error;
|
|
}
|
|
},
|
|
createFBChannel: async ({ commit }, params) => {
|
|
try {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
|
const response = await FBChannel.create(params);
|
|
commit(types.default.ADD_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
sendAnalyticsEvent('facebook');
|
|
return response.data;
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
throw new Error(error);
|
|
}
|
|
},
|
|
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
|
|
try {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
|
const response = await WhatsappChannel.createEmbeddedSignup(params);
|
|
commit(types.default.ADD_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
sendAnalyticsEvent('whatsapp');
|
|
return response.data;
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
|
throw error;
|
|
}
|
|
},
|
|
...channelActions,
|
|
// TODO: Extract other create channel methods to separate files to reduce file size
|
|
// - createChannel
|
|
// - createWebsiteChannel
|
|
// - createTwilioChannel
|
|
// - createFBChannel
|
|
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
|
|
try {
|
|
const response = await InboxesAPI.update(
|
|
id,
|
|
formData ? buildInboxData(inboxParams) : inboxParams
|
|
);
|
|
commit(types.default.EDIT_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
|
|
throwErrorMessage(error);
|
|
}
|
|
},
|
|
updateInboxIMAP: async ({ commit }, { id, ...inboxParams }) => {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true });
|
|
try {
|
|
const response = await InboxesAPI.update(id, inboxParams);
|
|
commit(types.default.EDIT_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false });
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false });
|
|
throwErrorMessage(error);
|
|
}
|
|
},
|
|
updateInboxSMTP: async ({ commit }, { id, ...inboxParams }) => {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true });
|
|
try {
|
|
const response = await InboxesAPI.update(id, inboxParams);
|
|
commit(types.default.EDIT_INBOXES, response.data);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false });
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false });
|
|
throwErrorMessage(error);
|
|
}
|
|
},
|
|
delete: async ({ commit }, inboxId) => {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: true });
|
|
try {
|
|
await InboxesAPI.delete(inboxId);
|
|
commit(types.default.DELETE_INBOXES, inboxId);
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
|
} catch (error) {
|
|
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
|
|
throw new Error(error);
|
|
}
|
|
},
|
|
reauthorizeFacebookPage: async ({ commit }, params) => {
|
|
try {
|
|
const response = await FBChannel.reauthorizeFacebookPage(params);
|
|
commit(types.default.EDIT_INBOXES, response.data);
|
|
} catch (error) {
|
|
throw new Error(error.message);
|
|
}
|
|
},
|
|
deleteInboxAvatar: async (_, inboxId) => {
|
|
try {
|
|
await InboxesAPI.deleteInboxAvatar(inboxId);
|
|
} catch (error) {
|
|
throw new Error(error);
|
|
}
|
|
},
|
|
syncTemplates: async (_, inboxId) => {
|
|
try {
|
|
await InboxesAPI.syncTemplates(inboxId);
|
|
} catch (error) {
|
|
throw new Error(error);
|
|
}
|
|
},
|
|
};
|
|
|
|
export const mutations = {
|
|
[types.default.SET_INBOXES_UI_FLAG]($state, uiFlag) {
|
|
$state.uiFlags = { ...$state.uiFlags, ...uiFlag };
|
|
},
|
|
[types.default.SET_INBOXES]: MutationHelpers.set,
|
|
[types.default.SET_INBOXES_ITEM]: MutationHelpers.setSingleRecord,
|
|
[types.default.ADD_INBOXES]: MutationHelpers.create,
|
|
[types.default.EDIT_INBOXES]: MutationHelpers.update,
|
|
[types.default.DELETE_INBOXES]: MutationHelpers.destroy,
|
|
};
|
|
|
|
export default {
|
|
namespaced: true,
|
|
state,
|
|
getters,
|
|
actions,
|
|
mutations,
|
|
};
|