@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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/dist/base-Bw7e52V8.mjs +246 -0
- package/dist/base-Bw7e52V8.mjs.map +1 -0
- package/dist/base-DBtKFiSX.d.mts +226 -0
- package/dist/base-DBtKFiSX.d.mts.map +1 -0
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/client/index.d.mts +44 -0
- package/dist/client/index.d.mts.map +1 -0
- package/dist/client/index.mjs +154 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/common/index.d.mts +3 -0
- package/dist/common/index.mjs +7 -0
- package/dist/contracts-Cdwa4zlg.d.mts +121 -0
- package/dist/contracts-Cdwa4zlg.d.mts.map +1 -0
- package/dist/contracts-lCa069IK.mjs +221 -0
- package/dist/contracts-lCa069IK.mjs.map +1 -0
- package/dist/env-Bl0cwwjC.mjs +955 -0
- package/dist/env-Bl0cwwjC.mjs.map +1 -0
- package/dist/env-DxOZHf0p.d.mts +394 -0
- package/dist/env-DxOZHf0p.d.mts.map +1 -0
- package/dist/errors-Cm6LeKf7.mjs +32 -0
- package/dist/errors-Cm6LeKf7.mjs.map +1 -0
- package/dist/facebook-l_4CghaA.mjs +95 -0
- package/dist/facebook-l_4CghaA.mjs.map +1 -0
- package/dist/http-DpcLSR1M.mjs +197 -0
- package/dist/http-DpcLSR1M.mjs.map +1 -0
- package/dist/index.d.mts +42 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +71 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instagram-BGaeUFU2.mjs +90 -0
- package/dist/instagram-BGaeUFU2.mjs.map +1 -0
- package/dist/linkedin-70whtVKa.mjs +101 -0
- package/dist/linkedin-70whtVKa.mjs.map +1 -0
- package/dist/meta-D3vcJU1c.mjs +126 -0
- package/dist/meta-D3vcJU1c.mjs.map +1 -0
- package/dist/pkce-jq5II68b.mjs +72 -0
- package/dist/pkce-jq5II68b.mjs.map +1 -0
- package/dist/polling-DZ1apXtA.mjs +25 -0
- package/dist/polling-DZ1apXtA.mjs.map +1 -0
- package/dist/providers/facebook.d.mts +135 -0
- package/dist/providers/facebook.d.mts.map +1 -0
- package/dist/providers/facebook.mjs +450 -0
- package/dist/providers/facebook.mjs.map +1 -0
- package/dist/providers/instagram.d.mts +122 -0
- package/dist/providers/instagram.d.mts.map +1 -0
- package/dist/providers/instagram.mjs +496 -0
- package/dist/providers/instagram.mjs.map +1 -0
- package/dist/providers/linkedin.d.mts +145 -0
- package/dist/providers/linkedin.d.mts.map +1 -0
- package/dist/providers/linkedin.mjs +574 -0
- package/dist/providers/linkedin.mjs.map +1 -0
- package/dist/providers/reddit.d.mts +102 -0
- package/dist/providers/reddit.d.mts.map +1 -0
- package/dist/providers/reddit.mjs +657 -0
- package/dist/providers/reddit.mjs.map +1 -0
- package/dist/providers/telegram.d.mts +139 -0
- package/dist/providers/telegram.d.mts.map +1 -0
- package/dist/providers/telegram.mjs +517 -0
- package/dist/providers/telegram.mjs.map +1 -0
- package/dist/providers/tiktok.d.mts +116 -0
- package/dist/providers/tiktok.d.mts.map +1 -0
- package/dist/providers/tiktok.mjs +676 -0
- package/dist/providers/tiktok.mjs.map +1 -0
- package/dist/providers/twitter.d.mts +150 -0
- package/dist/providers/twitter.d.mts.map +1 -0
- package/dist/providers/twitter.mjs +628 -0
- package/dist/providers/twitter.mjs.map +1 -0
- package/dist/providers/whatsapp.d.mts +79 -0
- package/dist/providers/whatsapp.d.mts.map +1 -0
- package/dist/providers/whatsapp.mjs +376 -0
- package/dist/providers/whatsapp.mjs.map +1 -0
- package/dist/providers/youtube.d.mts +153 -0
- package/dist/providers/youtube.d.mts.map +1 -0
- package/dist/providers/youtube.mjs +902 -0
- package/dist/providers/youtube.mjs.map +1 -0
- package/dist/reddit-B10kS4Se.mjs +126 -0
- package/dist/reddit-B10kS4Se.mjs.map +1 -0
- package/dist/schemas/index.d.mts +819 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +31 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/security-BXhfebWm.d.mts +338 -0
- package/dist/security-BXhfebWm.d.mts.map +1 -0
- package/dist/shared-Fvc6xQku.mjs +100 -0
- package/dist/shared-Fvc6xQku.mjs.map +1 -0
- package/dist/telegram-FaUHpZgB.mjs +107 -0
- package/dist/telegram-FaUHpZgB.mjs.map +1 -0
- package/dist/tiktok-B_bMk4G-.mjs +94 -0
- package/dist/tiktok-B_bMk4G-.mjs.map +1 -0
- package/dist/twitter-BC22zfuc.mjs +98 -0
- package/dist/twitter-BC22zfuc.mjs.map +1 -0
- package/dist/types-BFE4psYI.d.mts +102 -0
- package/dist/types-BFE4psYI.d.mts.map +1 -0
- package/dist/types-Bv27tcT0.d.mts +230 -0
- package/dist/types-Bv27tcT0.d.mts.map +1 -0
- package/dist/types-BwkKyqpi.d.mts +253 -0
- package/dist/types-BwkKyqpi.d.mts.map +1 -0
- package/dist/types-CJrHMDV9.mjs +27 -0
- package/dist/types-CJrHMDV9.mjs.map +1 -0
- package/dist/types-ClbVc2rc.d.mts +117 -0
- package/dist/types-ClbVc2rc.d.mts.map +1 -0
- package/dist/types-D91N16Ym.d.mts +242 -0
- package/dist/types-D91N16Ym.d.mts.map +1 -0
- package/dist/types-DfLp_ibQ.d.mts +178 -0
- package/dist/types-DfLp_ibQ.d.mts.map +1 -0
- package/dist/types-DfjDgEoJ.d.mts +88 -0
- package/dist/types-DfjDgEoJ.d.mts.map +1 -0
- package/dist/types-Dp5Z9VBr.mjs +23 -0
- package/dist/types-Dp5Z9VBr.mjs.map +1 -0
- package/dist/types-hriBJTsU.d.mts +129 -0
- package/dist/types-hriBJTsU.d.mts.map +1 -0
- package/dist/types-rn6UuLL8.d.mts +184 -0
- package/dist/types-rn6UuLL8.d.mts.map +1 -0
- package/dist/whatsapp-CFp7ryR4.mjs +101 -0
- package/dist/whatsapp-CFp7ryR4.mjs.map +1 -0
- package/dist/youtube-Bs0fdY7H.mjs +98 -0
- package/dist/youtube-Bs0fdY7H.mjs.map +1 -0
- 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
|