diff --git a/connector/src/platforms/tiktok.js b/connector/src/platforms/tiktok.js index 9551df3..64ec431 100644 --- a/connector/src/platforms/tiktok.js +++ b/connector/src/platforms/tiktok.js @@ -17,13 +17,47 @@ async function waitForPost(token, publishId) { for (let i = 0; i < 60; i++) { const { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } }); const s = data.data?.status; - if (s === "PUBLISH_COMPLETE") return data.data; + if (s === "PUBLISH_COMPLETE" || s === "SEND_TO_USER_INBOX") return data.data; if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`); await sleep(5000); } return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done } +/** Upload to the creator's TikTok inbox (video.upload). They finish and post it in the TikTok app. */ +async function sendToInbox(token, post, opts, caption) { + const media = post.media || []; + let publishId; + if (post.type === "REELS" || isVideo(media[0])) { + const bytes = await bytesOf(media[0]); + const plan = chunkPlan(bytes.length); + const { data } = await request("TikTok", `${API}/post/publish/inbox/video/init/`, { + token, method: "POST", + json: { source_info: { source: "FILE_UPLOAD", video_size: bytes.length, chunk_size: plan.chunkSize, total_chunk_count: plan.count } }, + }); + publishId = data.data.publish_id; + for (let i = 0; i < plan.count; i++) { + const start = i * plan.chunkSize; + const end = i === plan.count - 1 ? bytes.length : start + plan.chunkSize; + const r = await fetch(data.data.upload_url, { method: "PUT", headers: { "Content-Type": "video/mp4", "Content-Range": `bytes ${start}-${end - 1}/${bytes.length}` }, body: bytes.subarray(start, end) }); + if (!r.ok) throw new Error(`TikTok: video upload failed on part ${i + 1} (${r.status}).`); + } + } else { + const urls = media.slice(0, 35).map((m) => (/^https?:/.test(m) ? m : publicMediaUrl(m))); + const { data } = await request("TikTok", `${API}/post/publish/content/init/`, { + token, method: "POST", + json: { + post_info: { title: String(opts.title || "").slice(0, 90), description: String(caption || "").slice(0, 4000) }, + source_info: { source: "PULL_FROM_URL", photo_cover_index: 0, photo_images: urls }, + post_mode: "MEDIA_UPLOAD", media_type: "PHOTO", + }, + }); + publishId = data.data.publish_id; + } + const done = await waitForPost(token, publishId); + return { id: publishId, publishId, status: done.status, inbox: true, note: "Sent to the TikTok app. Open TikTok → inbox notification to finish and post it." }; +} + export default { id: "tiktok", name: "TikTok", @@ -49,7 +83,8 @@ export default { formats: { REELS: true, IMAGE: true, CAROUSEL: true }, limits: { caption: 2200, images: 35 }, fields: [ - { key: "privacy", label: "Who can view", type: "select", options: PRIVACY, required: true, hint: "TikTok requires you to choose. Unaudited apps can only use “Only me”." }, + { key: "mode", label: "How to post", type: "select", options: [["direct", "Post directly"], ["inbox", "Send to the TikTok app to finish"]], default: "direct", hint: "“Send to the TikTok app” puts the video in the account's TikTok inbox; someone opens TikTok, edits if needed and taps Post." }, + { key: "privacy", label: "Who can view", type: "select", options: PRIVACY, hint: "Required for direct posts. TikTok requires you to choose. Unaudited apps can only use “Only me”." }, { key: "allowComments", label: "Allow comments", type: "checkbox", default: false }, { key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] }, { key: "allowStitch", label: "Allow Stitch", type: "checkbox", default: false, onlyFor: ["REELS"] }, @@ -72,6 +107,7 @@ export default { /** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */ checkOptions(opts) { + if (opts.mode !== "inbox" && !opts.privacy) throw new Error("TikTok: who can view is required."); if (opts.discloseCommercial && !opts.brandOrganic && !opts.brandedContent) throw new Error("TikTok: you turned on commercial content disclosure. Choose “Your brand”, “Branded content” or both."); if (opts.brandedContent && opts.privacy === "SELF_ONLY") throw new Error("TikTok: branded content can't be private (“Only me”). Choose another audience."); }, @@ -84,8 +120,8 @@ export default { async publish({ tokens, conn, post, opts, caption }) { const token = tokens.accessToken; - if (!opts.privacy) throw new Error("TikTok: choose who can view the post."); this.checkOptions(opts); + if (opts.mode === "inbox") return sendToInbox(token, post, opts, caption); const creator = (await request("TikTok", `${API}/post/publish/creator_info/query/`, { token, method: "POST", json: {} })).data.data || {}; const brand = { brand_content_toggle: Boolean(opts.discloseCommercial && opts.brandedContent), brand_organic_toggle: Boolean(opts.discloseCommercial && opts.brandOrganic) }; const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled); diff --git a/connector/test/platforms.test.mjs b/connector/test/platforms.test.mjs index d404b0c..3ed6656 100644 --- a/connector/test/platforms.test.mjs +++ b/connector/test/platforms.test.mjs @@ -326,3 +326,23 @@ test("vault: a connection can move to another client, but not onto an existing o assert.equal(vault.connectionFor("move-from", "tiktok"), null); assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a"); }); + +test("TikTok: inbox mode uploads to the creator's inbox with no audience settings", async () => { + reset(); + route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } })); + route("POST", "https://open.tiktokapis.com/v2/post/publish/inbox/video/init/", json({ data: { publish_id: "inb1", upload_url: "https://upload.tiktok.test/i1" }, error: { code: "ok" } })); + route("PUT", "https://upload.tiktok.test/i1", new Response("", { status: 201 })); + route("POST", "https://open.tiktokapis.com/v2/post/publish/status/fetch/", json({ data: { status: "SEND_TO_USER_INBOX" }, error: { code: "ok" } })); + P.validate("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } }); + const r = await P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { mode: "inbox" } } }); + assert.equal(r.inbox, true); + assert.equal(sent(/\/video\/init\//).filter((c) => !c.url.includes("inbox")).length, 0, "no direct post"); + assert.equal(bodyJson(sent(/inbox\/video\/init/)[0]).post_info, undefined); +}); + +test("platform errors include the code and a plain fix", async () => { + reset(); + route("POST", "https://open.tiktokapis.com/v2/post/publish/creator_info/query/", json({ data: { privacy_level_options: ["SELF_ONLY"] }, error: { code: "ok" } })); + route("POST", "https://open.tiktokapis.com/v2/post/publish/video/init/", json({ error: { code: "unaudited_client_can_only_post_to_private_accounts", message: "Please review our integration guidelines" } }, 403)); + await assert.rejects(P.publish("tiktok", "acme", { type: "REELS", media: [VID], caption: "x", options: { tiktok: { privacy: "SELF_ONLY" } } }), /Private account.*unaudited_client_can_only_post_to_private_accounts/); +}); diff --git a/dashboard/web/src/PlatformPicker.jsx b/dashboard/web/src/PlatformPicker.jsx index 30fc634..2218753 100644 --- a/dashboard/web/src/PlatformPicker.jsx +++ b/dashboard/web/src/PlatformPicker.jsx @@ -80,6 +80,17 @@ function TikTokFields({ platform, type, options, setOption, caption }) { {error &&
Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.
} {!creator && !error &&
Loading the account's TikTok settings…
} {creator && ( + + + + )} + {creator && options.mode === "inbox" && ( +

Quorla uploads the {type === "REELS" ? "video" : "photos"} to the account's TikTok inbox. Someone opens TikTok, chooses who can see it, edits if needed and taps Post there.

+ )} + {creator && options.mode !== "inbox" && ( <>