@classytic/social 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/README.md +368 -0
  4. package/dist/base-Bw7e52V8.mjs +246 -0
  5. package/dist/base-Bw7e52V8.mjs.map +1 -0
  6. package/dist/base-DBtKFiSX.d.mts +226 -0
  7. package/dist/base-DBtKFiSX.d.mts.map +1 -0
  8. package/dist/chunk-DQk6qfdC.mjs +18 -0
  9. package/dist/client/index.d.mts +44 -0
  10. package/dist/client/index.d.mts.map +1 -0
  11. package/dist/client/index.mjs +154 -0
  12. package/dist/client/index.mjs.map +1 -0
  13. package/dist/common/index.d.mts +3 -0
  14. package/dist/common/index.mjs +7 -0
  15. package/dist/contracts-Cdwa4zlg.d.mts +121 -0
  16. package/dist/contracts-Cdwa4zlg.d.mts.map +1 -0
  17. package/dist/contracts-lCa069IK.mjs +221 -0
  18. package/dist/contracts-lCa069IK.mjs.map +1 -0
  19. package/dist/env-Bl0cwwjC.mjs +955 -0
  20. package/dist/env-Bl0cwwjC.mjs.map +1 -0
  21. package/dist/env-DxOZHf0p.d.mts +394 -0
  22. package/dist/env-DxOZHf0p.d.mts.map +1 -0
  23. package/dist/errors-Cm6LeKf7.mjs +32 -0
  24. package/dist/errors-Cm6LeKf7.mjs.map +1 -0
  25. package/dist/facebook-l_4CghaA.mjs +95 -0
  26. package/dist/facebook-l_4CghaA.mjs.map +1 -0
  27. package/dist/http-DpcLSR1M.mjs +197 -0
  28. package/dist/http-DpcLSR1M.mjs.map +1 -0
  29. package/dist/index.d.mts +42 -0
  30. package/dist/index.d.mts.map +1 -0
  31. package/dist/index.mjs +71 -0
  32. package/dist/index.mjs.map +1 -0
  33. package/dist/instagram-BGaeUFU2.mjs +90 -0
  34. package/dist/instagram-BGaeUFU2.mjs.map +1 -0
  35. package/dist/linkedin-70whtVKa.mjs +101 -0
  36. package/dist/linkedin-70whtVKa.mjs.map +1 -0
  37. package/dist/meta-D3vcJU1c.mjs +126 -0
  38. package/dist/meta-D3vcJU1c.mjs.map +1 -0
  39. package/dist/pkce-jq5II68b.mjs +72 -0
  40. package/dist/pkce-jq5II68b.mjs.map +1 -0
  41. package/dist/polling-DZ1apXtA.mjs +25 -0
  42. package/dist/polling-DZ1apXtA.mjs.map +1 -0
  43. package/dist/providers/facebook.d.mts +135 -0
  44. package/dist/providers/facebook.d.mts.map +1 -0
  45. package/dist/providers/facebook.mjs +450 -0
  46. package/dist/providers/facebook.mjs.map +1 -0
  47. package/dist/providers/instagram.d.mts +122 -0
  48. package/dist/providers/instagram.d.mts.map +1 -0
  49. package/dist/providers/instagram.mjs +496 -0
  50. package/dist/providers/instagram.mjs.map +1 -0
  51. package/dist/providers/linkedin.d.mts +145 -0
  52. package/dist/providers/linkedin.d.mts.map +1 -0
  53. package/dist/providers/linkedin.mjs +574 -0
  54. package/dist/providers/linkedin.mjs.map +1 -0
  55. package/dist/providers/reddit.d.mts +102 -0
  56. package/dist/providers/reddit.d.mts.map +1 -0
  57. package/dist/providers/reddit.mjs +657 -0
  58. package/dist/providers/reddit.mjs.map +1 -0
  59. package/dist/providers/telegram.d.mts +139 -0
  60. package/dist/providers/telegram.d.mts.map +1 -0
  61. package/dist/providers/telegram.mjs +517 -0
  62. package/dist/providers/telegram.mjs.map +1 -0
  63. package/dist/providers/tiktok.d.mts +116 -0
  64. package/dist/providers/tiktok.d.mts.map +1 -0
  65. package/dist/providers/tiktok.mjs +676 -0
  66. package/dist/providers/tiktok.mjs.map +1 -0
  67. package/dist/providers/twitter.d.mts +150 -0
  68. package/dist/providers/twitter.d.mts.map +1 -0
  69. package/dist/providers/twitter.mjs +628 -0
  70. package/dist/providers/twitter.mjs.map +1 -0
  71. package/dist/providers/whatsapp.d.mts +79 -0
  72. package/dist/providers/whatsapp.d.mts.map +1 -0
  73. package/dist/providers/whatsapp.mjs +376 -0
  74. package/dist/providers/whatsapp.mjs.map +1 -0
  75. package/dist/providers/youtube.d.mts +153 -0
  76. package/dist/providers/youtube.d.mts.map +1 -0
  77. package/dist/providers/youtube.mjs +902 -0
  78. package/dist/providers/youtube.mjs.map +1 -0
  79. package/dist/reddit-B10kS4Se.mjs +126 -0
  80. package/dist/reddit-B10kS4Se.mjs.map +1 -0
  81. package/dist/schemas/index.d.mts +819 -0
  82. package/dist/schemas/index.d.mts.map +1 -0
  83. package/dist/schemas/index.mjs +31 -0
  84. package/dist/schemas/index.mjs.map +1 -0
  85. package/dist/security-BXhfebWm.d.mts +338 -0
  86. package/dist/security-BXhfebWm.d.mts.map +1 -0
  87. package/dist/shared-Fvc6xQku.mjs +100 -0
  88. package/dist/shared-Fvc6xQku.mjs.map +1 -0
  89. package/dist/telegram-FaUHpZgB.mjs +107 -0
  90. package/dist/telegram-FaUHpZgB.mjs.map +1 -0
  91. package/dist/tiktok-B_bMk4G-.mjs +94 -0
  92. package/dist/tiktok-B_bMk4G-.mjs.map +1 -0
  93. package/dist/twitter-BC22zfuc.mjs +98 -0
  94. package/dist/twitter-BC22zfuc.mjs.map +1 -0
  95. package/dist/types-BFE4psYI.d.mts +102 -0
  96. package/dist/types-BFE4psYI.d.mts.map +1 -0
  97. package/dist/types-Bv27tcT0.d.mts +230 -0
  98. package/dist/types-Bv27tcT0.d.mts.map +1 -0
  99. package/dist/types-BwkKyqpi.d.mts +253 -0
  100. package/dist/types-BwkKyqpi.d.mts.map +1 -0
  101. package/dist/types-CJrHMDV9.mjs +27 -0
  102. package/dist/types-CJrHMDV9.mjs.map +1 -0
  103. package/dist/types-ClbVc2rc.d.mts +117 -0
  104. package/dist/types-ClbVc2rc.d.mts.map +1 -0
  105. package/dist/types-D91N16Ym.d.mts +242 -0
  106. package/dist/types-D91N16Ym.d.mts.map +1 -0
  107. package/dist/types-DfLp_ibQ.d.mts +178 -0
  108. package/dist/types-DfLp_ibQ.d.mts.map +1 -0
  109. package/dist/types-DfjDgEoJ.d.mts +88 -0
  110. package/dist/types-DfjDgEoJ.d.mts.map +1 -0
  111. package/dist/types-Dp5Z9VBr.mjs +23 -0
  112. package/dist/types-Dp5Z9VBr.mjs.map +1 -0
  113. package/dist/types-hriBJTsU.d.mts +129 -0
  114. package/dist/types-hriBJTsU.d.mts.map +1 -0
  115. package/dist/types-rn6UuLL8.d.mts +184 -0
  116. package/dist/types-rn6UuLL8.d.mts.map +1 -0
  117. package/dist/whatsapp-CFp7ryR4.mjs +101 -0
  118. package/dist/whatsapp-CFp7ryR4.mjs.map +1 -0
  119. package/dist/youtube-Bs0fdY7H.mjs +98 -0
  120. package/dist/youtube-Bs0fdY7H.mjs.map +1 -0
  121. package/package.json +148 -0
@@ -0,0 +1,676 @@
1
+ import { t as PlatformProvider } from "../base-Bw7e52V8.mjs";
2
+ import { t as SocialError } from "../errors-Cm6LeKf7.mjs";
3
+ import { t as httpRequest } from "../http-DpcLSR1M.mjs";
4
+ import { t as PkceStore } from "../pkce-jq5II68b.mjs";
5
+ import { t as pollUntilComplete } from "../polling-DZ1apXtA.mjs";
6
+ import { t as TikTokCredentialsSchema } from "../tiktok-B_bMk4G-.mjs";
7
+ import { createReadStream } from "fs";
8
+ import { stat } from "fs/promises";
9
+ import { Readable } from "stream";
10
+ import { createHash, randomBytes } from "crypto";
11
+
12
+ //#region src/providers/tiktok/index.ts
13
+ /**
14
+ * TikTok Platform Provider
15
+ * ========================
16
+ * TikTok integration with OAuth2 and Content Posting API.
17
+ *
18
+ * Features:
19
+ * - OAuth2 authorization flow (TikTok Login Kit)
20
+ * - Video upload with 3-step process (init → binary PUT → poll status)
21
+ * - Photo post support (via URL)
22
+ * - Draft mode via SELF_ONLY privacy level
23
+ * - Credential validation
24
+ *
25
+ * TikTok API Quirks:
26
+ * - Uses `client_key` instead of `client_id`
27
+ * - Token endpoint requires application/x-www-form-urlencoded
28
+ * - Scopes are comma-separated (not space-separated)
29
+ * - Token response includes `open_id` and `refresh_expires_in`
30
+ * - No true draft API — SELF_ONLY privacy is the closest equivalent
31
+ */
32
+ const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
33
+ const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
34
+ const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
35
+ const TIKTOK_VIDEO_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/video/init/";
36
+ const TIKTOK_CONTENT_INIT_URL = "https://open.tiktokapis.com/v2/post/publish/content/init/";
37
+ const TIKTOK_STATUS_URL = "https://open.tiktokapis.com/v2/post/publish/status/fetch/";
38
+ const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/";
39
+ const TIKTOK_VIDEO_QUERY_URL = "https://open.tiktokapis.com/v2/video/query/";
40
+ const PRIVACY_MAP = {
41
+ public: "PUBLIC_TO_EVERYONE",
42
+ private: "SELF_ONLY",
43
+ unlisted: "SELF_ONLY",
44
+ followers: "FOLLOWER_OF_CREATOR",
45
+ friends: "MUTUAL_FOLLOW_FRIENDS"
46
+ };
47
+ const VIDEO_QUERY_FIELDS = "id,title,video_description,create_time,cover_image_url,share_url,duration,like_count,comment_count,share_count,view_count";
48
+ function generateCodeVerifier() {
49
+ return randomBytes(32).toString("base64url");
50
+ }
51
+ function generateCodeChallenge(verifier) {
52
+ return createHash("sha256").update(verifier).digest("base64url");
53
+ }
54
+ var TikTokProvider = class extends PlatformProvider {
55
+ defaultRedirectUri;
56
+ scopes;
57
+ /**
58
+ * PKCE verifier store keyed by `state`. TTL'd to 10 minutes; entries are
59
+ * single-use. For multi-instance deployments, persist verifiers alongside
60
+ * `state` (session/Redis) and pass them back via `exchangeCode(code, creds, state)`.
61
+ */
62
+ codeVerifiers = new PkceStore(600 * 1e3);
63
+ constructor(cfg = {}) {
64
+ super(cfg);
65
+ this.name = "tiktok";
66
+ this.displayName = "TikTok";
67
+ this.authType = "oauth2";
68
+ this.defaultRedirectUri = cfg.redirectUri || `http://localhost:${cfg.port || 8060}/api/oauth/tiktok/callback`;
69
+ this.scopes = [
70
+ "user.info.profile",
71
+ "video.upload",
72
+ "video.publish"
73
+ ];
74
+ }
75
+ /**
76
+ * Get TikTok OAuth authorization URL
77
+ * Quirk: uses `client_key` (not `client_id`), comma-separated scopes
78
+ *
79
+ * @param options.environment - Both sandbox and production request the same scopes.
80
+ * Sandbox apps with "Direct Post" enabled support video.publish for test users.
81
+ */
82
+ getAuthUrl(state, credentials = {}, options) {
83
+ const codeVerifier = generateCodeVerifier();
84
+ const codeChallenge = generateCodeChallenge(codeVerifier);
85
+ this.codeVerifiers.set(state, codeVerifier);
86
+ return `${TIKTOK_AUTH_URL}?${new URLSearchParams({
87
+ client_key: credentials.clientKey?.trim(),
88
+ redirect_uri: credentials.redirectUri || this.defaultRedirectUri,
89
+ scope: [
90
+ "user.info.profile",
91
+ "video.upload",
92
+ "video.publish"
93
+ ].join(","),
94
+ response_type: "code",
95
+ state,
96
+ code_challenge: codeChallenge,
97
+ code_challenge_method: "S256"
98
+ }).toString()}`;
99
+ }
100
+ /**
101
+ * Exchange authorization code for tokens
102
+ * Quirk: form-urlencoded body with client_key/client_secret (not Basic auth)
103
+ */
104
+ async exchangeCode(code, credentials = {}, state) {
105
+ const bodyParams = {
106
+ client_key: credentials.clientKey?.trim(),
107
+ client_secret: credentials.clientSecret?.trim(),
108
+ code,
109
+ grant_type: "authorization_code",
110
+ redirect_uri: credentials.redirectUri || this.defaultRedirectUri
111
+ };
112
+ if (state) {
113
+ const verifier = this.codeVerifiers.take(state);
114
+ if (verifier) bodyParams.code_verifier = verifier;
115
+ }
116
+ const { data } = await httpRequest("tiktok", {
117
+ method: "POST",
118
+ url: TIKTOK_TOKEN_URL,
119
+ urlencoded: bodyParams,
120
+ timeout: 3e4,
121
+ retry: { attempts: 1 },
122
+ parseError: (raw) => {
123
+ if (raw && typeof raw === "object") {
124
+ const r = raw;
125
+ const message = r.error_description || r.error || "TikTok token exchange failed";
126
+ const isScopeError = String(message).toLowerCase().includes("scope");
127
+ return {
128
+ message,
129
+ errorCode: r.error ?? null,
130
+ hint: isScopeError ? "Your TikTok app may not have the required scopes approved. Try connecting with Sandbox environment, or submit your app for TikTok audit to enable video.publish." : null
131
+ };
132
+ }
133
+ return null;
134
+ }
135
+ });
136
+ if (data.error || !data.access_token) {
137
+ const message = data.error_description || data.error || "TikTok token exchange failed";
138
+ throw new SocialError("tiktok", message, {
139
+ statusCode: 401,
140
+ hint: String(message).toLowerCase().includes("scope") ? "Your TikTok app may not have the required scopes approved. Try connecting with Sandbox environment, or submit your app for TikTok audit to enable video.publish." : void 0
141
+ });
142
+ }
143
+ return {
144
+ access_token: data.access_token,
145
+ refresh_token: data.refresh_token,
146
+ expires_in: data.expires_in,
147
+ open_id: data.open_id,
148
+ scope: data.scope,
149
+ token_type: data.token_type,
150
+ refresh_expires_in: data.refresh_expires_in
151
+ };
152
+ }
153
+ /**
154
+ * Refresh access token
155
+ */
156
+ async refreshToken(refreshToken, credentials = {}) {
157
+ const { data } = await httpRequest("tiktok", {
158
+ method: "POST",
159
+ url: TIKTOK_TOKEN_URL,
160
+ urlencoded: {
161
+ client_key: credentials.clientKey,
162
+ client_secret: credentials.clientSecret,
163
+ grant_type: "refresh_token",
164
+ refresh_token: refreshToken
165
+ },
166
+ timeout: 3e4,
167
+ retry: { attempts: 2 }
168
+ });
169
+ if (data.error || !data.access_token) throw new SocialError("tiktok", data.error_description || data.error || "TikTok token refresh failed", { statusCode: 401 });
170
+ return {
171
+ access_token: data.access_token,
172
+ refresh_token: data.refresh_token,
173
+ expires_in: data.expires_in,
174
+ open_id: data.open_id,
175
+ scope: data.scope,
176
+ token_type: data.token_type,
177
+ refresh_expires_in: data.refresh_expires_in
178
+ };
179
+ }
180
+ /**
181
+ * Revoke TikTok access token.
182
+ * Forces fresh consent screen on next authorization — ensures newly
183
+ * enabled scopes (e.g. video.publish after enabling Direct Post) are granted.
184
+ */
185
+ async revokeToken(accessToken, credData) {
186
+ if (!accessToken) throw new SocialError("tiktok", "Access token is required to revoke", { statusCode: 400 });
187
+ if (!credData?.clientKey || !credData?.clientSecret) throw new SocialError("tiktok", "clientKey and clientSecret are required to revoke", { statusCode: 400 });
188
+ await httpRequest("tiktok", {
189
+ method: "POST",
190
+ url: "https://open.tiktokapis.com/v2/oauth/revoke/",
191
+ urlencoded: {
192
+ client_key: credData.clientKey,
193
+ client_secret: credData.clientSecret,
194
+ token: accessToken
195
+ },
196
+ timeout: 15e3
197
+ });
198
+ }
199
+ /**
200
+ * Get TikTok user profile
201
+ * Quirk: requires explicit `fields` query param
202
+ */
203
+ async getAccountInfo(accessToken) {
204
+ const { data: result } = await httpRequest("tiktok", {
205
+ method: "GET",
206
+ url: TIKTOK_USER_INFO_URL,
207
+ query: { fields: "open_id,avatar_large_url,avatar_url,display_name,username,bio_description" },
208
+ bearer: accessToken,
209
+ timeout: 3e4,
210
+ retry: { attempts: 2 }
211
+ });
212
+ if (result.error?.code && result.error.code !== "ok") throw new SocialError("tiktok", result.error?.message || "Failed to get TikTok account info");
213
+ const user = result.data?.user;
214
+ if (!user) throw new SocialError("tiktok", "No user data returned from TikTok");
215
+ return {
216
+ id: user.open_id,
217
+ name: user.display_name || user.username,
218
+ username: user.username,
219
+ profileImage: user.avatar_large_url || user.avatar_url,
220
+ bio: user.bio_description
221
+ };
222
+ }
223
+ /**
224
+ * Test credential validity
225
+ */
226
+ async testCredential(credentialData) {
227
+ try {
228
+ if (credentialData.oauthTokenData) {
229
+ const tokenData = typeof credentialData.oauthTokenData === "string" ? JSON.parse(credentialData.oauthTokenData) : credentialData.oauthTokenData;
230
+ const accountInfo = await this.getAccountInfo(tokenData.access_token);
231
+ return {
232
+ status: "OK",
233
+ message: "TikTok credential is valid",
234
+ data: {
235
+ channelId: accountInfo.id,
236
+ channelTitle: accountInfo.name,
237
+ profileImage: accountInfo.profileImage
238
+ }
239
+ };
240
+ }
241
+ if (!credentialData.clientKey || !credentialData.clientSecret) return {
242
+ status: "Error",
243
+ message: "TikTok credentials not configured — clientKey and clientSecret are required"
244
+ };
245
+ return {
246
+ status: "Pending",
247
+ message: "Credential needs OAuth authorization. Click \"Connect Account\" to link your TikTok."
248
+ };
249
+ } catch (error) {
250
+ return {
251
+ status: "Error",
252
+ message: error.message || "Failed to validate TikTok credential"
253
+ };
254
+ }
255
+ }
256
+ /**
257
+ * Upload video to TikTok
258
+ *
259
+ * Smart mode selection:
260
+ * - videoUrl → tries PULL_FROM_URL first (zero server load, TikTok fetches directly)
261
+ * → falls back to FILE_UPLOAD if domain not verified (url_ownership_unverified)
262
+ * - filePath → always FILE_UPLOAD (local file, no URL to pull from)
263
+ *
264
+ * FILE_UPLOAD uses stream-through: fetch(url) → pipe to TikTok (no server buffering).
265
+ */
266
+ async uploadVideo(params) {
267
+ const { filePath, videoUrl, title = "", description = "", privacy = "private", tokens, scheduledAt, onProgress } = params;
268
+ if (!filePath && !videoUrl) throw new SocialError("tiktok", "uploadVideo requires either filePath or videoUrl", { statusCode: 400 });
269
+ const accessToken = tokens.access_token;
270
+ const privacyLevel = PRIVACY_MAP[privacy] || "SELF_ONLY";
271
+ if (scheduledAt) throw new SocialError("tiktok", "Scheduling is not supported by TikTok Content Posting API. Post immediately or use TikTok Business API (requires separate approval).", {
272
+ statusCode: 400,
273
+ retryable: false
274
+ });
275
+ const postInfo = {
276
+ title: (title || description || "").substring(0, 2200),
277
+ privacy_level: privacyLevel,
278
+ disable_duet: false,
279
+ disable_stitch: false,
280
+ disable_comment: false,
281
+ video_cover_timestamp_ms: 1e3
282
+ };
283
+ if (videoUrl && !filePath) {
284
+ const pullResult = await this._tryPullFromUrl(accessToken, videoUrl, postInfo, onProgress);
285
+ if (pullResult) return {
286
+ ...pullResult,
287
+ status: scheduledAt ? "scheduled" : privacyLevel === "SELF_ONLY" ? "draft" : "published",
288
+ uploadedAt: /* @__PURE__ */ new Date(),
289
+ scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
290
+ metadata: {
291
+ title,
292
+ privacy: privacyLevel,
293
+ uploadMode: "PULL_FROM_URL",
294
+ publishId: pullResult.platformVideoId,
295
+ ...pullResult.metadata || {}
296
+ }
297
+ };
298
+ }
299
+ return this._uploadViaFile(accessToken, postInfo, {
300
+ filePath,
301
+ videoUrl,
302
+ title,
303
+ privacy: privacyLevel,
304
+ scheduledAt,
305
+ onProgress
306
+ });
307
+ }
308
+ /**
309
+ * Attempt PULL_FROM_URL upload. Returns result on success, null if domain not verified.
310
+ * Throws on any other error.
311
+ */
312
+ async _tryPullFromUrl(accessToken, videoUrl, postInfo, onProgress) {
313
+ const initBody = {
314
+ post_info: postInfo,
315
+ source_info: {
316
+ source: "PULL_FROM_URL",
317
+ video_url: videoUrl
318
+ }
319
+ };
320
+ const initData = await (await fetch(TIKTOK_VIDEO_INIT_URL, {
321
+ method: "POST",
322
+ headers: {
323
+ Authorization: `Bearer ${accessToken}`,
324
+ "Content-Type": "application/json; charset=UTF-8"
325
+ },
326
+ body: JSON.stringify(initBody)
327
+ })).json();
328
+ const errorCode = initData.error?.code;
329
+ if (errorCode === "url_ownership_unverified") return null;
330
+ if (errorCode && errorCode !== "ok") {
331
+ const hint = errorCode === "unaudited_client_can_only_post_to_private_accounts" ? "Sandbox apps require the TikTok account to be set to Private. Go to TikTok > Settings > Privacy > Private Account. Or submit your app for TikTok audit to remove this restriction." : void 0;
332
+ throw new SocialError("tiktok", initData.error?.message || `PULL_FROM_URL init failed (${errorCode})`, {
333
+ errorCode,
334
+ hint
335
+ });
336
+ }
337
+ const publish_id = initData.data.publish_id;
338
+ if (onProgress) onProgress(10);
339
+ const publishResult = await this._pollPublishStatus(accessToken, publish_id);
340
+ if (onProgress) onProgress(100);
341
+ return {
342
+ platformVideoId: publish_id,
343
+ platformUrl: null,
344
+ metadata: publishResult || {}
345
+ };
346
+ }
347
+ /**
348
+ * FILE_UPLOAD with stream-through (fetch URL → pipe to TikTok, or local file → stream).
349
+ */
350
+ async _uploadViaFile(accessToken, postInfo, opts) {
351
+ const { filePath, videoUrl, title, privacy, scheduledAt, onProgress } = opts;
352
+ let videoStream;
353
+ let videoSize;
354
+ if (filePath) {
355
+ videoSize = (await stat(filePath)).size;
356
+ videoStream = createReadStream(filePath);
357
+ } else {
358
+ const fetchResponse = await fetch(videoUrl);
359
+ if (!fetchResponse.ok) throw new SocialError("tiktok", `Failed to fetch video from URL: ${fetchResponse.status} ${fetchResponse.statusText}`, {
360
+ statusCode: 502,
361
+ hint: "Ensure the video URL is publicly accessible"
362
+ });
363
+ const contentLength = fetchResponse.headers.get("content-length");
364
+ if (!contentLength) throw new SocialError("tiktok", "Video URL did not return Content-Length header (required for TikTok FILE_UPLOAD)", {
365
+ statusCode: 502,
366
+ hint: "GCS and S3 always return Content-Length — ensure the URL points to cloud storage"
367
+ });
368
+ videoSize = parseInt(contentLength, 10);
369
+ videoStream = Readable.fromWeb(fetchResponse.body);
370
+ }
371
+ const initBody = {
372
+ post_info: postInfo,
373
+ source_info: {
374
+ source: "FILE_UPLOAD",
375
+ video_size: videoSize,
376
+ chunk_size: videoSize,
377
+ total_chunk_count: 1
378
+ }
379
+ };
380
+ const initData = await (await fetch(TIKTOK_VIDEO_INIT_URL, {
381
+ method: "POST",
382
+ headers: {
383
+ Authorization: `Bearer ${accessToken}`,
384
+ "Content-Type": "application/json; charset=UTF-8"
385
+ },
386
+ body: JSON.stringify(initBody)
387
+ })).json();
388
+ if (initData.error?.code && initData.error.code !== "ok") throw new SocialError("tiktok", initData.error?.message || `Upload init failed (${initData.error?.code})`, { errorCode: initData.error?.code });
389
+ const publish_id = initData.data.publish_id;
390
+ const { upload_url } = initData.data;
391
+ if (onProgress) onProgress(10);
392
+ const uploadResponse = await fetch(upload_url, {
393
+ method: "PUT",
394
+ headers: {
395
+ "Content-Type": "video/mp4",
396
+ "Content-Length": String(videoSize),
397
+ "Content-Range": `bytes 0-${videoSize - 1}/${videoSize}`
398
+ },
399
+ body: videoStream,
400
+ duplex: "half"
401
+ });
402
+ if (!uploadResponse.ok) {
403
+ const errText = await uploadResponse.text().catch(() => "");
404
+ throw new SocialError("tiktok", `Video upload failed (HTTP ${uploadResponse.status}): ${errText}`, { statusCode: uploadResponse.status });
405
+ }
406
+ if (onProgress) onProgress(80);
407
+ const publishResult = await this._pollPublishStatus(accessToken, publish_id);
408
+ if (onProgress) onProgress(100);
409
+ return {
410
+ platformVideoId: publish_id,
411
+ platformUrl: null,
412
+ status: scheduledAt ? "scheduled" : privacy === "SELF_ONLY" ? "draft" : "published",
413
+ uploadedAt: /* @__PURE__ */ new Date(),
414
+ scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
415
+ metadata: {
416
+ title,
417
+ privacy,
418
+ uploadMode: "FILE_UPLOAD",
419
+ publishId: publish_id,
420
+ ...publishResult || {}
421
+ }
422
+ };
423
+ }
424
+ /**
425
+ * Create a photo post on TikTok
426
+ * TikTok-specific — not in the base class.
427
+ *
428
+ * Restrictions:
429
+ * - Only JPG/JPEG/WEBP (no PNG)
430
+ * - Max 35 images per post
431
+ * - Images must be publicly accessible URLs
432
+ *
433
+ * @param params
434
+ * @param params.imageUrls - Array of public image URLs
435
+ * @param params.title - Post title (max 150 chars)
436
+ * @param params.privacy - 'public' | 'private' | 'followers' | 'friends'
437
+ * @param params.tokens - { access_token }
438
+ */
439
+ async uploadPhoto(params) {
440
+ const { imageUrls, title = "", privacy = "private", tokens } = params;
441
+ if (!imageUrls || imageUrls.length === 0) throw new SocialError("tiktok", "Photo post requires at least one image URL", { statusCode: 400 });
442
+ const validFormats = [
443
+ "jpg",
444
+ "jpeg",
445
+ "webp"
446
+ ];
447
+ for (const url of imageUrls) {
448
+ const ext = url.split("?")[0].split(".").pop()?.toLowerCase();
449
+ if (ext && !validFormats.includes(ext)) throw new SocialError("tiktok", `Unsupported image format .${ext} — only JPG, JPEG, WEBP are allowed`, { statusCode: 400 });
450
+ }
451
+ const accessToken = tokens.access_token;
452
+ const privacyLevel = PRIVACY_MAP[privacy] || "SELF_ONLY";
453
+ const body = {
454
+ post_info: {
455
+ title: (title || "").substring(0, 2200),
456
+ privacy_level: privacyLevel,
457
+ disable_comment: false
458
+ },
459
+ source_info: {
460
+ source: "PULL_FROM_URL",
461
+ photo_cover_index: 0,
462
+ photo_images: imageUrls.slice(0, 35)
463
+ },
464
+ media_type: "PHOTO"
465
+ };
466
+ const data = await (await fetch(TIKTOK_CONTENT_INIT_URL, {
467
+ method: "POST",
468
+ headers: {
469
+ Authorization: `Bearer ${accessToken}`,
470
+ "Content-Type": "application/json; charset=UTF-8"
471
+ },
472
+ body: JSON.stringify(body)
473
+ })).json();
474
+ if (data.error?.code && data.error.code !== "ok") throw new SocialError("tiktok", data.error?.message || `Photo post failed (${data.error?.code})`, { errorCode: data.error?.code });
475
+ const publish_id = data.data?.publish_id;
476
+ await this._pollPublishStatus(accessToken, publish_id);
477
+ return {
478
+ platformVideoId: publish_id,
479
+ platformUrl: null,
480
+ status: privacyLevel === "SELF_ONLY" ? "draft" : "published"
481
+ };
482
+ }
483
+ /**
484
+ * List videos for the authenticated user (cursor-based pagination).
485
+ * Requires scope: `video.list`
486
+ *
487
+ * @param accessToken - OAuth access token
488
+ * @param options.maxCount - Number of videos per page (1-20, default 20)
489
+ * @param options.cursor - Pagination cursor from a previous response
490
+ */
491
+ async listVideos(accessToken, options) {
492
+ const maxCount = Math.min(Math.max(options?.maxCount ?? 20, 1), 20);
493
+ const url = `${TIKTOK_VIDEO_LIST_URL}?fields=${VIDEO_QUERY_FIELDS}`;
494
+ try {
495
+ const response = await fetch(url, {
496
+ method: "POST",
497
+ headers: {
498
+ Authorization: `Bearer ${accessToken}`,
499
+ "Content-Type": "application/json"
500
+ },
501
+ body: JSON.stringify({
502
+ max_count: maxCount,
503
+ ...options?.cursor != null ? { cursor: options.cursor } : {}
504
+ })
505
+ });
506
+ const result = await response.json();
507
+ if (result.error?.code && result.error.code !== "ok") throw new SocialError("tiktok", result.error?.message || "Failed to list TikTok videos", {
508
+ statusCode: response.status >= 400 ? response.status : 502,
509
+ errorCode: result.error.code
510
+ });
511
+ return {
512
+ videos: (result.data?.videos || []).map((v) => this._parseVideoItem(v)),
513
+ cursor: result.data?.cursor ?? null,
514
+ hasMore: result.data?.has_more ?? false
515
+ };
516
+ } catch (error) {
517
+ if (error instanceof SocialError) throw error;
518
+ throw new SocialError("tiktok", error.message || "Failed to list TikTok videos", { originalError: error });
519
+ }
520
+ }
521
+ /**
522
+ * Query specific videos by their IDs.
523
+ *
524
+ * @param accessToken - OAuth access token
525
+ * @param videoIds - Array of TikTok video IDs to fetch
526
+ */
527
+ async getVideos(accessToken, videoIds) {
528
+ if (!videoIds.length) return [];
529
+ const url = `${TIKTOK_VIDEO_QUERY_URL}?fields=${VIDEO_QUERY_FIELDS}`;
530
+ try {
531
+ const response = await fetch(url, {
532
+ method: "POST",
533
+ headers: {
534
+ Authorization: `Bearer ${accessToken}`,
535
+ "Content-Type": "application/json"
536
+ },
537
+ body: JSON.stringify({ filters: { video_ids: videoIds } })
538
+ });
539
+ const result = await response.json();
540
+ if (result.error?.code && result.error.code !== "ok") throw new SocialError("tiktok", result.error?.message || "Failed to query TikTok videos", {
541
+ statusCode: response.status >= 400 ? response.status : 502,
542
+ errorCode: result.error.code
543
+ });
544
+ const videos = result.data?.videos;
545
+ if (!videos) throw new SocialError("tiktok", "No video data returned from TikTok", { statusCode: 404 });
546
+ return videos.map((v) => this._parseVideoItem(v));
547
+ } catch (error) {
548
+ if (error instanceof SocialError) throw error;
549
+ throw new SocialError("tiktok", error.message || "Failed to query TikTok videos", { originalError: error });
550
+ }
551
+ }
552
+ getCredentialZodSchema() {
553
+ return TikTokCredentialsSchema;
554
+ }
555
+ getCredentialSchema() {
556
+ return [{
557
+ name: "clientKey",
558
+ displayName: "Client Key",
559
+ type: "text",
560
+ required: true,
561
+ description: "TikTok App Client Key from TikTok Developer Portal"
562
+ }, {
563
+ name: "clientSecret",
564
+ displayName: "Client Secret",
565
+ type: "password",
566
+ required: true,
567
+ description: "TikTok App Client Secret"
568
+ }];
569
+ }
570
+ getMetadata() {
571
+ return {
572
+ name: this.name,
573
+ displayName: this.displayName,
574
+ authType: this.authType,
575
+ icon: "tiktok",
576
+ brandColor: "#000000",
577
+ supportsScheduling: true,
578
+ supportsEnvironment: true,
579
+ description: "Share short-form videos on TikTok",
580
+ scopes: this.scopes,
581
+ scopeDescriptions: {
582
+ "user.info.profile": "View your TikTok profile info (display name, username, avatar, bio)",
583
+ "video.upload": "Upload videos to your TikTok account",
584
+ "video.publish": "Publish videos directly (requires TikTok audit approval)"
585
+ },
586
+ setupGuide: [
587
+ {
588
+ step: 1,
589
+ title: "Create TikTok Developer Account",
590
+ description: "Go to developers.tiktok.com and sign up or log in with your TikTok account"
591
+ },
592
+ {
593
+ step: 2,
594
+ title: "Create an App",
595
+ description: "Click \"Manage apps\" → \"Connect an app\". Fill in the app name and description"
596
+ },
597
+ {
598
+ step: 3,
599
+ title: "Add Login Kit",
600
+ description: "In your app, go to \"Add products\" and enable \"Login Kit\". Set the redirect URI shown below"
601
+ },
602
+ {
603
+ step: 4,
604
+ title: "Add Content Posting API",
605
+ description: "Enable \"Content Posting API\" product to allow video and photo uploads"
606
+ },
607
+ {
608
+ step: 5,
609
+ title: "Set Redirect URI",
610
+ description: `Add this as a redirect URI in your TikTok app settings: ${this.defaultRedirectUri}`
611
+ },
612
+ {
613
+ step: 6,
614
+ title: "Copy Credentials",
615
+ description: "Copy your Client Key and Client Secret from the app dashboard and paste them above"
616
+ },
617
+ {
618
+ step: 7,
619
+ title: "Unaudited App Limitations",
620
+ description: "Before TikTok audit approval: posts are limited to SELF_ONLY (private/draft) privacy, max 5 users per day, and all test users must have private TikTok profiles"
621
+ }
622
+ ],
623
+ redirectUriPattern: this.defaultRedirectUri,
624
+ credentialSchema: this.getCredentialSchema()
625
+ };
626
+ }
627
+ /**
628
+ * Parse a raw TikTok video API item into a normalized TikTokVideo object.
629
+ * @private
630
+ */
631
+ _parseVideoItem(item) {
632
+ return {
633
+ id: item.id,
634
+ title: item.title || "",
635
+ description: item.video_description || "",
636
+ createTime: item.create_time ?? 0,
637
+ coverImageUrl: item.cover_image_url ?? null,
638
+ shareUrl: item.share_url ?? null,
639
+ duration: item.duration ?? null,
640
+ viewCount: item.view_count ?? null,
641
+ likeCount: item.like_count ?? null,
642
+ commentCount: item.comment_count ?? null,
643
+ shareCount: item.share_count ?? null
644
+ };
645
+ }
646
+ /**
647
+ * Poll TikTok publish status until complete or failed
648
+ * @private
649
+ */
650
+ async _pollPublishStatus(accessToken, publishId, maxAttempts = 15, intervalMs = 5e3) {
651
+ return (await pollUntilComplete({
652
+ fn: async () => {
653
+ return (await fetch(TIKTOK_STATUS_URL, {
654
+ method: "POST",
655
+ headers: {
656
+ Authorization: `Bearer ${accessToken}`,
657
+ "Content-Type": "application/json"
658
+ },
659
+ body: JSON.stringify({ publish_id: publishId })
660
+ })).json();
661
+ },
662
+ isComplete: (data) => data.data?.status === "PUBLISH_COMPLETE",
663
+ getError: (data) => {
664
+ if (data.data?.status === "FAILED") return new SocialError("tiktok", `Publish failed: ${data.data?.fail_reason || "Unknown reason"}`);
665
+ return null;
666
+ },
667
+ maxAttempts,
668
+ intervalMs,
669
+ label: "TikTokProvider"
670
+ }))?.data || null;
671
+ }
672
+ };
673
+
674
+ //#endregion
675
+ export { TikTokProvider };
676
+ //# sourceMappingURL=tiktok.mjs.map