Skip to content

Publish every attached image, not just the first - #8

Open
pallaoro wants to merge 1 commit into
mainfrom
multi-image-publishing
Open

Publish every attached image, not just the first#8
pallaoro wants to merge 1 commit into
mainfrom
multi-image-publishing

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

The bug

The composer takes multiple files, stores a media row per image and renders a thumbnail for each. Publishing then did this:

const firstImage = media[0]?.url as string | undefined;

Every platform got one image. The rest were dropped silently — no warning in the composer, no error on the post. Same class as the single-image drop fixed in #7, one level up.

What each platform gets now

Every cap below was read off the platform's own contract — Composio's live v3.1 tool schemas and the atproto lexicon — not recalled.

Platform Cap Shape Source
X 4 media_media_ids, one staged upload per image TWITTER_CREATION_OF_A_POST — "Up to 4 Media IDs"
LinkedIn 20 images[] of broker-staged descriptors LINKEDIN_CREATE_LINKED_IN_POST.imagesmaxItems: 20
Instagram 1–10 one image = plain container; 2+ = carousel INSTAGRAM_CREATE_CAROUSEL_CONTAINER — 2–10 children
Facebook none stated FACEBOOK_CREATE_MULTI_PHOTO_POST for 2+ photo_urls declares minItems, no maximum
TikTok 35 photo_images[], first image is the cover TIKTOK_POST_PHOTO.photo_imagesmaxItems: 35
Bluesky 4 one blob upload per image into the embed app.bsky.embed.images.imagesmaxLength: 4

Facebook is deliberately left uncapped: with no stated maximum there is no number to enforce, and Facebook's own rejection is the error we surface. That's the stance bluesky.ts already takes on text length.

Over the cap fails the channel

Attach 6 images with X selected and X fails with "X / Twitter takes at most 4 images; this post has 6." — every other channel still goes out, because the per-channel delivery model exists for exactly this. Truncating to 4 would silently publish something the user did not write, which is the bug this whole path exists to close.

The composer surfaces the same message while the images are still on screen and removable, so it normally never reaches publish time.

Instagram moved off deprecated actions

Both actions this case called are marked deprecated in Composio's catalogue:

  • INSTAGRAM_CREATE_MEDIA_CONTAINERINSTAGRAM_POST_IG_USER_MEDIA
  • INSTAGRAM_CREATE_POSTINSTAGRAM_POST_IG_USER_MEDIA_PUBLISH

The carousel container has no deprecated publish partner, so the case had to move regardless — building the new path on a deprecated action would have been a wound.

Why src/shared/platforms.ts

The server enforces the same table the composer warns on. Two copies drift, and the copy that drifts is the one that only fails at send time. The platform tables move there; client/types.ts re-exports them, so no client component changed its imports.

Verification

  • Composio v3.1 tool schemas fetched live for every action and argument name used here — the invocation contract, not just the behaviour.
  • mediaLimitError exercised over 13 boundary cases (at cap, one over, zero images, uncapped platform, unknown platform); all correct.
  • tsc --noEmit — no new errors (4 pre-existing D1Database/R2Bucket global-type errors remain, in lines this PR does not touch).
  • vite build and wrangler deploy --dry-run both clean.

Not verified: no end-to-end publish against a live social account — that needs connected accounts and posts to a real audience. The per-platform request shapes are grounded in the schemas, but the first real multi-image send on each platform is still the true test.

Left out, deliberately

  • Previews still render one image. PostPreview takes imageUrl={mediaUrls[0]}, so a 3-image post previews as 1. Worth its own PR — each platform renders a gallery differently.
  • Video. Still images-only; /api/upload rejects non-images.
  • FACEBOOK_GET_USER_PAGES, used by the channel form, is also deprecated (→ FACEBOOK_LIST_MANAGED_PAGES). Not on this PR's path.

The composer takes `multiple` files, stores a media row per image and shows a
thumbnail for each — then publishing sent `media[0]` and dropped the rest, on
every platform, with no warning. Same class as the single-image drop, one level
up.

Each platform now carries the whole set, using the shape its own contract
defines:

  X          up to 4   media_media_ids — one staged upload per image
  LinkedIn   up to 20  images[] of broker-staged descriptors
  Instagram  1-10      one image = a plain container, 2+ = a carousel
  Facebook   —         FACEBOOK_CREATE_MULTI_PHOTO_POST for 2+ photos
  TikTok     1-35      photo_images[], first image is the cover
  Bluesky    up to 4   one blob upload per image into the embed

The Instagram path also moves off two actions Composio marks deprecated
(INSTAGRAM_CREATE_MEDIA_CONTAINER, INSTAGRAM_CREATE_POST) onto
INSTAGRAM_POST_IG_USER_MEDIA / _PUBLISH — the carousel container has no
deprecated publish partner, so the whole case had to move anyway.

Attaching more images than a platform accepts fails that channel with the
reason, rather than posting a truncated set: shipping fewer images than the
user attached, silently, is the bug this path exists to close, and the
per-channel delivery model means the other channels still go out. The composer
warns while the images are still on screen and removable.

The limits live in src/shared/platforms.ts because the server enforces the same
table the composer warns on — two copies would drift, and the copy that drifts
is the one that only fails at send time.

Caps verified against Composio's live v3.1 tool schemas and the atproto
app.bsky.embed.images lexicon, not recalled. Facebook is deliberately uncapped:
photo_urls declares minItems and no maximum, so there is no stated number to
enforce and Facebook's own rejection is the error we surface.
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