index.js 6.9 KB

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