Skip to content

Let a post carry a video, and publish it where a video can go - #11

Open
pallaoro wants to merge 3 commits into
idempotent-publishfrom
video-posts
Open

Let a post carry a video, and publish it where a video can go#11
pallaoro wants to merge 3 commits into
idempotent-publishfrom
video-posts

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stacked on #10 (idempotent-publish). Review that first, or read this diff alone: idempotent-publish...video-posts

The gap

A post could only carry images. media.type was in the schema from the start and was never anything but 'image'/api/upload rejected 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:

  • an upload reads the file's own MIME server-side (/api/upload answers { url, type });
  • a pasted link falls back to its extension, so a .mp4 URL 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[] becomes media, which still accepts a bare string for a plain image URL.

Where a video goes

Platform Call Note
TikTok TIKTOK_UPLOAD_VIDEO (publish: true) Bytes staged through the broker. Not the URL-pull sibling 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 FACEBOOK_CREATE_VIDEO_POST Its own Graph endpoint (/videos), taking the file by URL from this app's public uploads route.
X, LinkedIn, Instagram, Bluesky No call that delivers a video. Those channels fail with a reason.

Two failure modes drove the shape of this:

  • A video that quietly never got attached. The broker drops arguments a tool doesn't declare, without an error — so a video handed to a call that has no video parameter publishes the text on its own and reports success. That is why the four platforms above refuse rather than "try anyway", and why every check in the new test reads the video off the call the app actually made.
  • A channel marked delivered with nothing live. TikTok's upload can succeed while leaving the video as a draft in the creator's inbox. That comes back as a failure, not a delivery.

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 over node:sqlite, alongside the existing 18:

  • the video reaches TikTok and Facebook, and it is the video that arrives, not just the caption;
  • X and LinkedIn are refused with nothing sent, and stay retryable rather than stuck as delivered;
  • a TikTok upload that lands in the inbox is not reported as published;
  • images + a video, and two videos, fail every channel before a single send;
  • image posts publish exactly as they did before, X media ids included;
  • a pasted .mp4 stores 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 build clean; tsc clean apart from the four pre-existing D1Database/R2Bucket errors (Workers types not installed in this repo).

Not in this PR

  • Instagram Reels. INSTAGRAM_POST_IG_USER_MEDIA does take video_url, but a video container has to be polled to FINISHED before publishing (~30 attempts, 3–5s apart) or the publish returns OAuthException 9007. That poll loop is its own change.
  • X video. TWITTER_UPLOAD_MEDIA takes media_category, but whether it waits out X's asynchronous video processing is not something that can be confirmed without posting to a real account.
  • LinkedIn video. The action has no video parameter at any version.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant