| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- require('dotenv').config();
- const axios = require('axios');
- const BasePlatformService = require('./utils/BasePlatformService');
- const { getDb } = require('./utils/MongoDBConnector');
- const { encryptToken, decryptToken, warnIfNoKey } = require('./utils/crypto');
- const TIKTOK_API = 'https://open.tiktokapis.com/v2';
- const TOKEN_URL = `${TIKTOK_API}/oauth/token/`;
- class TikTokService extends BasePlatformService {
- constructor() {
- super('tiktok');
- }
- async _getAccount() {
- try {
- const db = await getDb();
- const [cred, appCred] = await Promise.all([
- db.collection('platform_credentials').findOne({ _id: 'tiktok' }),
- db.collection('platform_credentials').findOne({ _id: 'tiktok_app' }),
- ]);
- if (!cred?.accessToken || !appCred?.clientKey) return null;
- // Auto-refresh if access token is expired or expires within 5 minutes
- if (cred.tokenExpiry && new Date(cred.tokenExpiry) < new Date(Date.now() + 5 * 60 * 1000)) {
- if (cred.refreshToken && cred.refreshExpiry && new Date(cred.refreshExpiry) > new Date()) {
- try {
- const clientSecret = decryptToken(appCred.clientSecret);
- const refreshRes = await axios.post(
- TOKEN_URL,
- new URLSearchParams({
- client_key: appCred.clientKey,
- client_secret: clientSecret,
- grant_type: 'refresh_token',
- refresh_token: decryptToken(cred.refreshToken),
- }).toString(),
- { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 }
- );
- const { access_token, refresh_token: newRefresh, expires_in, refresh_expires_in } = refreshRes.data;
- const tokenExpiry = new Date(Date.now() + (expires_in || 86400) * 1000).toISOString();
- const refreshExpiry = newRefresh
- ? new Date(Date.now() + (refresh_expires_in || 31536000) * 1000).toISOString()
- : cred.refreshExpiry;
- await db.collection('platform_credentials').updateOne(
- { _id: 'tiktok' },
- {
- $set: {
- accessToken: encryptToken(access_token),
- refreshToken: newRefresh ? encryptToken(newRefresh) : cred.refreshToken,
- tokenExpiry,
- refreshExpiry,
- updatedAt: new Date(),
- },
- }
- );
- this.app.log.info({ action: 'token_refresh', platform: 'tiktok', outcome: 'success' });
- return { ...cred, accessToken: access_token };
- } catch (err) {
- this.app.log.warn({ action: 'token_refresh', platform: 'tiktok', outcome: 'failure', err: err.message });
- }
- }
- }
- return { ...cred, accessToken: decryptToken(cred.accessToken) };
- } catch (_) {}
- return null;
- }
- async getStatus() {
- const account = await this._getAccount();
- if (!account?.accessToken) {
- return { connected: false, platform: 'tiktok', error: 'Not connected — use Settings to connect via TikTok OAuth' };
- }
- try {
- const res = await axios.get(`${TIKTOK_API}/user/info/`, {
- headers: { Authorization: `Bearer ${account.accessToken}` },
- params: { fields: 'open_id,display_name,avatar_url,username' },
- timeout: 10000,
- });
- const user = res.data?.data?.user || {};
- return {
- connected: true,
- platform: 'tiktok',
- username: user.username || user.display_name,
- displayName: user.display_name,
- avatar: user.avatar_url,
- };
- } catch (err) {
- return { connected: false, platform: 'tiktok', error: err.response?.data?.error?.message || err.message };
- }
- }
- async fetchFeed({ limit = 20 } = {}) {
- const account = await this._getAccount();
- if (!account?.accessToken) throw new Error('TikTok not connected');
- const res = await axios.post(
- `${TIKTOK_API}/video/list/`,
- { max_count: Math.min(Number(limit), 20) },
- {
- headers: {
- Authorization: `Bearer ${account.accessToken}`,
- 'Content-Type': 'application/json; charset=UTF-8',
- },
- params: { fields: 'id,title,video_description,share_url,view_count,like_count,comment_count,share_count,create_time,cover_image_url' },
- timeout: 15000,
- }
- );
- const videos = res.data?.data?.videos || [];
- const items = videos.map((v) =>
- this.normalizeFeedItem({
- originalId: v.id,
- author: {
- name: account.displayName || account.username || 'TikTok',
- username: account.username || '',
- avatar: account.avatar || null,
- },
- content: v.video_description || v.title || '',
- media: v.cover_image_url ? [{ url: v.cover_image_url, type: 'image' }] : [],
- metrics: {
- likes: v.like_count || 0,
- comments: v.comment_count || 0,
- shares: v.share_count || 0,
- views: v.view_count || 0,
- },
- url: v.share_url || '',
- createdAt: v.create_time ? new Date(v.create_time * 1000) : new Date(),
- })
- );
- try {
- const db = await getDb();
- const col = db.collection('feeds');
- for (const item of items) {
- await col.updateOne(
- { platform: 'tiktok', originalId: item.originalId },
- { $set: item },
- { upsert: true }
- );
- }
- } catch (err) {
- this.app.log.error({ action: 'feed_write', platform: 'tiktok', outcome: 'failure', err: err.message });
- }
- return items;
- }
- async publishPost({ content, videoUrl } = {}) {
- const account = await this._getAccount();
- if (!account?.accessToken) throw new Error('TikTok not connected');
- if (!videoUrl) throw new Error('TikTok requires a video URL — text-only posts are not supported');
- const body = {
- post_info: {
- title: (content || '').slice(0, 2200),
- privacy_level: 'PUBLIC_TO_EVERYONE',
- disable_duet: false,
- disable_comment: false,
- disable_stitch: false,
- },
- source_info: {
- source: 'PULL_FROM_URL',
- video_url: videoUrl,
- },
- };
- try {
- const res = await axios.post(`${TIKTOK_API}/post/publish/video/init/`, body, {
- headers: {
- Authorization: `Bearer ${account.accessToken}`,
- 'Content-Type': 'application/json; charset=UTF-8',
- },
- timeout: 30000,
- });
- const publishId = res.data?.data?.publish_id;
- this.app.log.info({ action: 'publish_post', platform: 'tiktok', publishId, outcome: 'success' });
- return { publishId };
- } catch (err) {
- const msg = err.response?.data?.error?.message || err.message;
- this.app.log.error({ action: 'publish_post', platform: 'tiktok', outcome: 'failure', err: msg });
- throw new Error(msg);
- }
- }
- }
- const service = new TikTokService();
- warnIfNoKey('tiktok');
- service.start(process.env.PORT || 3007);
|