TikTok: option to send posts to the TikTok app's inbox to finish there
Direct Post from an unaudited app only works for private accounts. Inbox upload lets a person finish and post from the TikTok app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd87ba08aa
commit
894940800b
@ -17,13 +17,47 @@ async function waitForPost(token, publishId) {
|
|||||||
for (let i = 0; i < 60; i++) {
|
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 { data } = await request("TikTok", `${API}/post/publish/status/fetch/`, { token, method: "POST", json: { publish_id: publishId } });
|
||||||
const s = data.data?.status;
|
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"}`);
|
if (s === "FAILED") throw new Error(`TikTok rejected the post: ${data.data?.fail_reason || "unknown reason"}`);
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
}
|
}
|
||||||
return { status: "PROCESSING" }; // TikTok keeps processing; it appears on the profile when done
|
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 {
|
export default {
|
||||||
id: "tiktok",
|
id: "tiktok",
|
||||||
name: "TikTok",
|
name: "TikTok",
|
||||||
@ -49,7 +83,8 @@ export default {
|
|||||||
formats: { REELS: true, IMAGE: true, CAROUSEL: true },
|
formats: { REELS: true, IMAGE: true, CAROUSEL: true },
|
||||||
limits: { caption: 2200, images: 35 },
|
limits: { caption: 2200, images: 35 },
|
||||||
fields: [
|
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: "allowComments", label: "Allow comments", type: "checkbox", default: false },
|
||||||
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
|
{ key: "allowDuet", label: "Allow Duet", type: "checkbox", default: false, onlyFor: ["REELS"] },
|
||||||
{ key: "allowStitch", label: "Allow Stitch", 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. */
|
/** TikTok's commercial-content rules, checked when the draft is saved and again before posting. */
|
||||||
checkOptions(opts) {
|
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.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.");
|
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 }) {
|
async publish({ tokens, conn, post, opts, caption }) {
|
||||||
const token = tokens.accessToken;
|
const token = tokens.accessToken;
|
||||||
if (!opts.privacy) throw new Error("TikTok: choose who can view the post.");
|
|
||||||
this.checkOptions(opts);
|
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 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 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);
|
const noComment = opts.allowComments !== true || Boolean(creator.comment_disabled);
|
||||||
|
|||||||
@ -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-from", "tiktok"), null);
|
||||||
assert.equal(vault.connectionFor("move-to", "tiktok", { withTokens: true }).tokens.accessToken, "a");
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@ -80,6 +80,17 @@ function TikTokFields({ platform, type, options, setOption, caption }) {
|
|||||||
{error && <div className="text-[12.5px] text-bad">Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.</div>}
|
{error && <div className="text-[12.5px] text-bad">Couldn't load this TikTok account's settings: {error.message}. Reconnect TikTok on the Connections page.</div>}
|
||||||
{!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>}
|
{!creator && !error && <div className="text-[12.5px] text-muted">Loading the account's TikTok settings…</div>}
|
||||||
{creator && (
|
{creator && (
|
||||||
|
<Field label="How to post">
|
||||||
|
<select id="opt-tiktok-mode" className="input" value={options.mode || "direct"} onChange={(e) => setOption("mode", e.target.value)}>
|
||||||
|
<option value="direct">Post directly</option>
|
||||||
|
<option value="inbox">Send to the TikTok app to finish</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{creator && options.mode === "inbox" && (
|
||||||
|
<p className="text-[12.5px] text-ink-2">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.</p>
|
||||||
|
)}
|
||||||
|
{creator && options.mode !== "inbox" && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}>
|
<Field label="Who can view this post" hint={branded ? "Branded content can't be private." : undefined}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user