Let a post carry a video, and publish it where a video can go - #11
Open
pallaoro wants to merge 3 commits into
Open
Let a post carry a video, and publish it where a video can go#11pallaoro wants to merge 3 commits into
pallaoro wants to merge 3 commits into
Conversation
A post could only ever carry images. `media.type` existed in the schema and was
never anything but 'image': /api/upload refused a non-image outright, so TikTok
— a video service — could only be sent a photo post.
Attachments are now images or one video. The type is decided once, at the point
that actually knows it: an upload reads the file's own MIME server-side, a
pasted link falls back to its extension. It travels as `{ url, type }` from the
composer to the API to the publisher, and `media_urls: string[]` becomes
`media`, which still accepts a bare string for a plain image URL.
Where a video goes, and where it doesn't:
TikTok TIKTOK_UPLOAD_VIDEO with publish, staging the bytes through the
broker. Not the URL-pull sibling: TikTok only pulls from a domain
verified in the developer portal that owns the app, and an app's
own hostname is not one, so it answers 403. An upload that comes
back unpublished is a draft in the creator's inbox, not a post, and
is reported as a failure rather than as a delivery.
Facebook FACEBOOK_CREATE_VIDEO_POST — its own Graph endpoint, taking the
file by URL from this app's public uploads route.
X, LinkedIn, Instagram, Bluesky
No call that delivers a video, so those channels fail with a reason
instead of publishing the text on its own and calling it a success.
The broker drops arguments it doesn't know, so a video handed to a
call that has no video parameter posts nothing and reports success
— the failure mode this refusal exists to avoid.
A post carries either images or one video: no platform here takes a mix, so the
composer says so while the attachments are still removable, and publishing
refuses the combination before it sends anything.
Previews render whichever kind the post attached — a video tile that silently
rendered into an <img> would tell the author their post is broken when it isn't.
test/video-publishing.mjs adds 22 checks over the real server: the video
reaching the platform rather than just the caption, the two channels that can't
take one being refused without a send, the TikTok inbox draft not counting as
published, and image posts publishing exactly as they did before.
TikTok's Content Posting API is asynchronous. Both calls this app makes —
TIKTOK_UPLOAD_VIDEO with publish=true, and TIKTOK_POST_PHOTO with
post_mode=DIRECT_POST — return as soon as TikTok has *accepted* the job.
TikTok then downloads or transcodes the media, runs it through moderation,
and only then does anything appear on the profile. Any of those stages can
still reject it: a spam flag, a duration or frame-rate check, a banned
account.
We were reading the upload's `published` field as the verdict. It isn't one:
it reports whether we asked TikTok to publish, not whether TikTok did. So a
post that failed moderation was written to the row as delivered — and
delivered is terminal, the channel is never retried. The author gets a green
chip and an empty profile, with no way back.
Now the publish_id is polled through TIKTOK_FETCH_PUBLISH_STATUS until TikTok
says something terminal, and the failure reasons come back in words the author
can act on ("TikTok flagged the caption as spam") instead of a status code. A
completed post also gets its real link for the first time.
When TikTok still hasn't ruled by the end of the poll budget, the honest answer
is "we don't know yet" — so there's a third outcome. The row stays pending and
holds the publish_id: not published (a lie) and not failed (which would invite
a retry, and re-initiating the same publish_id is the one thing TikTok's docs
say never to do — it double-posts). A later delivery re-checks that row instead
of re-sending it, the post rolls up as partial so the retry stays in the
author's hands, and the channel chip says it is still waiting rather than
looking like a channel that was never sent.
Verified against Composio's TikTok toolkit at version 20260817_00 — the
unversioned schema endpoint serves a stale one that has neither privacy_level
nor `published`.
A channel TikTok has taken but not finished parks as pending, and until now it sat there: the row cleared only when something else happened to deliver the post again — a queue redelivery, or the author noticing and pressing retry. A video that needed ninety seconds looked, to its author, like one that needed a human. So the post now books its own return visit. The follow-up goes to /api/internal/publish, the same endpoint the scheduler already calls, because publishPost re-checks an unconfirmed channel instead of re-sending it — no new endpoint, no new payload, and a redelivery of it is as harmless as any other. The delays widen (1m, 2m, 5m, 15m, 30m) and then stop: a post nobody rules on keeps its row and the author's retry rather than rescheduling itself forever. In practice it ends sooner, because once TikTok forgets a publish_id the status call answers invalid_publish_id, which is terminal. Not held open past the response instead: a Worker's waitUntil budget is 30 seconds, shared across the whole invocation, and its promises are dropped when that runs out — neither long enough for a video nor durable enough to hang delivery on. Cloudflare's own guidance is to queue anything larger, and this is larger. The in-request poll stays, and it isn't redundant. Scheduling is a no-op without a CLAWNIFY_TOKEN, so for a self-hosted deploy that poll is the only confirmation there is — which is what the last test here pins down.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #10 (
idempotent-publish). Review that first, or read this diff alone: idempotent-publish...video-postsThe gap
A post could only carry images.
media.typewas in the schema from the start and was never anything but'image'—/api/uploadrejected a non-image outright. So TikTok, a video service, could only be handed a photo post, and "attach a video" was not a thing the app did at all.What this adds
An attachment is now an image or a video, and its type is decided once, at the point that actually knows it:
/api/uploadanswers{ url, type });.mp4URL is not filed as an image and sent to an image endpoint.The type travels as
{ url, type }from the composer through the API to the publisher.media_urls: string[]becomesmedia, which still accepts a bare string for a plain image URL.Where a video goes
TIKTOK_UPLOAD_VIDEO(publish: true)TIKTOK_PUBLISH_VIDEO: TikTok only pulls from a domain verified in the developer portal that owns the app, and an app's own hostname is not one — it answers 403.FACEBOOK_CREATE_VIDEO_POST/videos), taking the file by URL from this app's public uploads route.Two failure modes drove the shape of this:
Rules
A post carries either images or one video — no platform here takes a mix. The composer says so while the attachments are still on screen and removable; publishing refuses the combination before it sends anything. Uploads are capped at 50 MB, checked in the browser before the bytes leave and again on the server.
Previews render whichever kind the post attached. A video URL rendered into an
<img>would show a broken tile and tell the author their post is broken when it isn't.Checks
test/video-publishing.mjs— 22 checks against the real server overnode:sqlite, alongside the existing 18:.mp4stores and reads back as a video, and replacing it with an image publishes down the photo path.pnpm test→ 18/18 and 22/22.pnpm buildclean;tscclean apart from the four pre-existingD1Database/R2Bucketerrors (Workers types not installed in this repo).Not in this PR
INSTAGRAM_POST_IG_USER_MEDIAdoes takevideo_url, but a video container has to be polled toFINISHEDbefore publishing (~30 attempts, 3–5s apart) or the publish returns OAuthException 9007. That poll loop is its own change.TWITTER_UPLOAD_MEDIAtakesmedia_category, but whether it waits out X's asynchronous video processing is not something that can be confirmed without posting to a real account.