index.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. require('dotenv').config();
  2. const axios = require('axios');
  3. const BasePlatformService = require('./utils/BasePlatformService');
  4. const { getDb } = require('./utils/MongoDBConnector');
  5. const { encryptToken, decryptToken, warnIfNoKey } = require('./utils/crypto');
  6. const { getWorkspaceCredential } = require('./utils/credentials');
  7. const TIKTOK_API = 'https://open.tiktokapis.com/v2';
  8. const TOKEN_URL = `${TIKTOK_API}/oauth/token/`;
  9. class TikTokService extends BasePlatformService {
  10. constructor() {
  11. super('tiktok');
  12. }
  13. async _getAccount(workspaceId = 'default') {
  14. try {
  15. const db = await getDb();
  16. const [cred, appCred] = await Promise.all([
  17. getWorkspaceCredential(db, 'tiktok', workspaceId),
  18. db.collection('platform_credentials').findOne({ _id: 'tiktok_app' }),
  19. ]);
  20. if (!cred?.accessToken || !appCred?.clientKey) return null;
  21. // Auto-refresh if access token is expired or expires within 5 minutes
  22. if (cred.tokenExpiry && new Date(cred.tokenExpiry) < new Date(Date.now() + 5 * 60 * 1000)) {
  23. if (cred.refreshToken && cred.refreshExpiry && new Date(cred.refreshExpiry) > new Date()) {
  24. try {
  25. const clientSecret = decryptToken(appCred.clientSecret);
  26. const refreshRes = await axios.post(
  27. TOKEN_URL,
  28. new URLSearchParams({
  29. client_key: appCred.clientKey,
  30. client_secret: clientSecret,
  31. grant_type: 'refresh_token',
  32. refresh_token: decryptToken(cred.refreshToken),
  33. }).toString(),
  34. { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000 }
  35. );
  36. const { access_token, refresh_token: newRefresh, expires_in, refresh_expires_in } = refreshRes.data;
  37. const tokenExpiry = new Date(Date.now() + (expires_in || 86400) * 1000).toISOString();
  38. const refreshExpiry = newRefresh
  39. ? new Date(Date.now() + (refresh_expires_in || 31536000) * 1000).toISOString()
  40. : cred.refreshExpiry;
  41. await db.collection('platform_credentials').updateOne(
  42. { _id: cred._id },
  43. {
  44. $set: {
  45. accessToken: encryptToken(access_token),
  46. refreshToken: newRefresh ? encryptToken(newRefresh) : cred.refreshToken,
  47. tokenExpiry,
  48. refreshExpiry,
  49. updatedAt: new Date(),
  50. },
  51. }
  52. );
  53. this.app.log.info({ action: 'token_refresh', platform: 'tiktok', outcome: 'success' });
  54. return { ...cred, accessToken: access_token };
  55. } catch (err) {
  56. this.app.log.warn({ action: 'token_refresh', platform: 'tiktok', outcome: 'failure', err: err.message });
  57. }
  58. }
  59. }
  60. return { ...cred, accessToken: decryptToken(cred.accessToken) };
  61. } catch (_) {}
  62. return null;
  63. }
  64. async getStatus(workspaceId = 'default') {
  65. const account = await this._getAccount(workspaceId);
  66. if (!account?.accessToken) {
  67. return { connected: false, platform: 'tiktok', error: 'Not connected — use Settings to connect via TikTok OAuth' };
  68. }
  69. try {
  70. const res = await axios.get(`${TIKTOK_API}/user/info/`, {
  71. headers: { Authorization: `Bearer ${account.accessToken}` },
  72. params: { fields: 'open_id,display_name,avatar_url,username' },
  73. timeout: 10000,
  74. });
  75. const user = res.data?.data?.user || {};
  76. return {
  77. connected: true,
  78. platform: 'tiktok',
  79. username: user.username || user.display_name,
  80. displayName: user.display_name,
  81. avatar: user.avatar_url,
  82. };
  83. } catch (err) {
  84. return { connected: false, platform: 'tiktok', error: err.response?.data?.error?.message || err.message };
  85. }
  86. }
  87. async fetchFeed({ limit = 20, workspaceId = 'default' } = {}) {
  88. const account = await this._getAccount(workspaceId);
  89. if (!account?.accessToken) throw new Error('TikTok not connected');
  90. const res = await axios.post(
  91. `${TIKTOK_API}/video/list/`,
  92. { max_count: Math.min(Number(limit), 20) },
  93. {
  94. headers: {
  95. Authorization: `Bearer ${account.accessToken}`,
  96. 'Content-Type': 'application/json; charset=UTF-8',
  97. },
  98. params: { fields: 'id,title,video_description,share_url,view_count,like_count,comment_count,share_count,create_time,cover_image_url' },
  99. timeout: 15000,
  100. }
  101. );
  102. const videos = res.data?.data?.videos || [];
  103. const items = videos.map((v) =>
  104. this.normalizeFeedItem({
  105. originalId: v.id,
  106. author: {
  107. name: account.displayName || account.username || 'TikTok',
  108. username: account.username || '',
  109. avatar: account.avatar || null,
  110. },
  111. content: v.video_description || v.title || '',
  112. media: v.cover_image_url ? [{ url: v.cover_image_url, type: 'image' }] : [],
  113. metrics: {
  114. likes: v.like_count || 0,
  115. comments: v.comment_count || 0,
  116. shares: v.share_count || 0,
  117. views: v.view_count || 0,
  118. },
  119. url: v.share_url || '',
  120. createdAt: v.create_time ? new Date(v.create_time * 1000) : new Date(),
  121. })
  122. );
  123. try {
  124. const db = await getDb();
  125. const col = db.collection('feeds');
  126. for (const item of items) {
  127. await col.updateOne(
  128. { platform: 'tiktok', originalId: item.originalId, workspaceId },
  129. { $set: { ...item, workspaceId } },
  130. { upsert: true }
  131. );
  132. }
  133. } catch (err) {
  134. this.app.log.error({ action: 'feed_write', platform: 'tiktok', outcome: 'failure', err: err.message });
  135. }
  136. return items;
  137. }
  138. async publishPost({ content, videoUrl, workspaceId = 'default' } = {}) {
  139. const account = await this._getAccount(workspaceId);
  140. if (!account?.accessToken) throw new Error('TikTok not connected');
  141. if (!videoUrl) throw new Error('TikTok requires a video URL — text-only posts are not supported');
  142. const body = {
  143. post_info: {
  144. title: (content || '').slice(0, 2200),
  145. privacy_level: 'PUBLIC_TO_EVERYONE',
  146. disable_duet: false,
  147. disable_comment: false,
  148. disable_stitch: false,
  149. },
  150. source_info: {
  151. source: 'PULL_FROM_URL',
  152. video_url: videoUrl,
  153. },
  154. };
  155. try {
  156. const res = await axios.post(`${TIKTOK_API}/post/publish/video/init/`, body, {
  157. headers: {
  158. Authorization: `Bearer ${account.accessToken}`,
  159. 'Content-Type': 'application/json; charset=UTF-8',
  160. },
  161. timeout: 30000,
  162. });
  163. const publishId = res.data?.data?.publish_id;
  164. this.app.log.info({ action: 'publish_post', platform: 'tiktok', publishId, outcome: 'success' });
  165. return { publishId };
  166. } catch (err) {
  167. const msg = err.response?.data?.error?.message || err.message;
  168. this.app.log.error({ action: 'publish_post', platform: 'tiktok', outcome: 'failure', err: msg });
  169. throw new Error(msg);
  170. }
  171. }
  172. }
  173. const service = new TikTokService();
  174. warnIfNoKey('tiktok');
  175. service.start(process.env.PORT || 3007);