diff --git a/README.md b/README.md index a1cc9c6..ff4f5c0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful - **Drafts** -- save unfinished posts and come back to them later - **Channel management** -- add your social media accounts with platform detection and color coding - **Labels** -- categorize posts with colored labels for organization -- **Media attachments** -- upload images to posts +- **Media attachments** -- attach several images to a post; every platform publishes the whole set (carousel, gallery or multi-photo, whichever that platform calls it) - **Analytics** -- bar charts showing posts per channel, per label, and daily activity - **Dashboard** -- at-a-glance stats, upcoming posts, and recent drafts - **Native previews** -- see the post as it will look on each selected platform before it goes out @@ -23,16 +23,20 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful ### Supported Platforms -| Platform | Character Limit | Publishing | -|----------|----------------|------------| -| X / Twitter | 280 | text or photo | -| LinkedIn | 3,000 | text or photo | -| Instagram | 2,200 | photo + caption (Business or Creator account, picked up from the connection) | -| Facebook | 63,206 | text or photo, as a Page you manage | -| TikTok | 2,200 | photo post | -| Bluesky | 300 | text, with an image and link cards | -| Mastodon | 500 | coming soon | -| Threads | 500 | coming soon | +| Platform | Character Limit | Images | Publishing | +|----------|----------------|--------|------------| +| X / Twitter | 280 | up to 4 | text, or a post with up to four images | +| LinkedIn | 3,000 | up to 20 | text, or a post with up to twenty images | +| Instagram | 2,200 | 1-10 | photo + caption; two or more images publish as a carousel (Business or Creator account, picked up from the connection) | +| Facebook | 63,206 | no stated cap | text, one photo, or one feed post with several photos, as a Page you manage | +| TikTok | 2,200 | 1-35 | photo post; the first image is the cover | +| Bluesky | 300 | up to 4 | text, with images and link cards | +| Mastodon | 500 | -- | coming soon | +| Threads | 500 | -- | coming soon | + +Attaching more images than a platform accepts fails **that channel only**, with the +reason on the post, rather than quietly publishing a subset -- the other channels in +the fan-out still go out. The composer warns you before you get there. Connect each account once in Clawnify (Settings → Integrations), then add it as a channel here. Facebook channels are Pages: the channel form lists the Pages the connected account manages so you can pick one. @@ -75,6 +79,8 @@ Publishing runs through the accounts connected in Clawnify -- no API keys in the ``` src/ + shared/ + platforms.ts -- Platform limits, colours, labels; the media caps the server enforces and the composer warns on server/ index.ts -- Hono API with D1 + credentials middleware db.ts -- D1-native database adapter diff --git a/src/client/components/post-composer.tsx b/src/client/components/post-composer.tsx index 0ed8484..4a6c02b 100644 --- a/src/client/components/post-composer.tsx +++ b/src/client/components/post-composer.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from "preact/hooks"; import { Send, Save, ArrowLeft, Image, X, Upload, Loader2 } from "lucide-preact"; import { useApp } from "../context"; -import { PLATFORM_LIMITS, PLATFORM_LABELS } from "../types"; +import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaLimitError } from "../types"; import type { Platform } from "../types"; import { PostPreview, PreviewChannelTabs } from "./previews"; @@ -57,6 +57,21 @@ export function PostComposer({ editId, navigate }: Props) { const overLimit = charLimit !== null && content.length > charLimit; + // Selected channels whose platform can't carry this many images. Publishing + // fails those channels rather than posting a truncated set, so say it here — + // while the images are still on screen and removable — instead of letting it + // surface as a delivery error after the fact. + const mediaWarnings = useMemo(() => { + const messages = new Set(); + for (const c of channels) { + if (!selectedChannels.includes(c.id)) continue; + const msg = mediaLimitError(c.platform, mediaUrls.length); + // Two channels on the same platform share one message. + if (msg) messages.add(msg); + } + return [...messages]; + }, [selectedChannels, channels, mediaUrls.length]); + // Selected channels, in selection order, for the preview switcher. const previewChannels = useMemo( () => selectedChannels.map((id) => channels.find((c) => c.id === id)).filter(Boolean) as typeof channels, @@ -207,6 +222,13 @@ export function PostComposer({ editId, navigate }: Props) { Add + {mediaWarnings.length > 0 && ( +
+ {mediaWarnings.map((msg) => ( +

{msg}

+ ))} +
+ )} {mediaUrls.length > 0 && (
{mediaUrls.map((url, i) => ( diff --git a/src/client/types.ts b/src/client/types.ts index 7ddb6e7..ccf725c 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -3,22 +3,19 @@ export type PostStatus = "draft" | "scheduled" | "published" | "partial" | "fail // Per-channel delivery state on a post's channel (Postiz-style). Present on the // Channel objects returned inside a Post; absent in the standalone channel list. export type DeliveryStatus = "pending" | "published" | "failed"; -export type Platform = "twitter" | "linkedin" | "instagram" | "facebook" | "bluesky" | "mastodon" | "threads" | "tiktok"; - -// The platforms the server can actually publish to — the single source the -// channel picker offers. It MUST track the cases in publishToChannel() -// (src/server/index.ts): a platform here with no server case would let a user -// schedule posts that only fail at send time (the mastodon/threads trap this -// list closes); a server case missing here is simply not offered. -// mastodon/threads stay out until they have a publish path. -export const PUBLISHABLE_PLATFORMS: Platform[] = [ - "twitter", - "linkedin", - "instagram", - "facebook", - "tiktok", - "bluesky", -]; +// Platform metadata (limits, colours, labels, the publishable list) lives in +// src/shared/platforms.ts because the server enforces the same tables. Re-exported +// here so client code keeps importing platform facts from one place. +export { + PUBLISHABLE_PLATFORMS, + PLATFORM_LIMITS, + PLATFORM_MEDIA_LIMITS, + PLATFORM_COLORS, + PLATFORM_LABELS, + mediaLimitError, +} from "../shared/platforms"; +export type { Platform } from "../shared/platforms"; +import type { Platform } from "../shared/platforms"; // A Facebook Page the connected account manages (GET /api/platforms/facebook/pages). export interface FacebookPage { @@ -27,39 +24,6 @@ export interface FacebookPage { username: string | null; } -export const PLATFORM_LIMITS: Record = { - twitter: 280, - linkedin: 3000, - instagram: 2200, - facebook: 63206, - bluesky: 300, - mastodon: 500, - threads: 500, - tiktok: 2200, -}; - -export const PLATFORM_COLORS: Record = { - twitter: "#1da1f2", - linkedin: "#0a66c2", - instagram: "#e4405f", - facebook: "#1877f2", - bluesky: "#0085ff", - mastodon: "#6364ff", - threads: "#000000", - tiktok: "#00f2ea", -}; - -export const PLATFORM_LABELS: Record = { - twitter: "X / Twitter", - linkedin: "LinkedIn", - instagram: "Instagram", - facebook: "Facebook", - bluesky: "Bluesky", - mastodon: "Mastodon", - threads: "Threads", - tiktok: "TikTok", -}; - export interface Channel { id: number; name: string; diff --git a/src/server/bluesky.ts b/src/server/bluesky.ts index f68fa16..5352deb 100644 --- a/src/server/bluesky.ts +++ b/src/server/bluesky.ts @@ -128,11 +128,11 @@ async function uploadBlob( return { blob: data.blob }; } -// Publish one post: createSession → (optional) uploadBlob → createRecord. +// Publish one post: createSession → (optional) uploadBlob per image → createRecord. export async function publishToBluesky( creds: BlueskyCreds, content: string, - imageUrl?: string, + imageUrls: string[] = [], ): Promise { const base = trimBase(creds.service); const session = await createSession(creds); @@ -146,13 +146,17 @@ export async function publishToBluesky( const facets = detectFacets(content); if (facets.length) record.facets = facets; - if (imageUrl) { - const up = await uploadBlob(base, session.accessJwt, imageUrl); - if ("error" in up) return { success: false, error: up.error }; - record.embed = { - $type: "app.bsky.embed.images", - images: [{ image: up.blob, alt: "" }], - }; + if (imageUrls.length) { + // One blob upload per image, in order — the lexicon caps the embed at 4, + // which the caller has already checked. A single failed blob fails the + // whole post rather than shipping the rest without it. + const images: Array<{ image: unknown; alt: string }> = []; + for (const url of imageUrls) { + const up = await uploadBlob(base, session.accessJwt, url); + if ("error" in up) return { success: false, error: up.error }; + images.push({ image: up.blob, alt: "" }); + } + record.embed = { $type: "app.bsky.embed.images", images }; } const res = await fetch(`${base}/xrpc/com.atproto.repo.createRecord`, { diff --git a/src/server/index.ts b/src/server/index.ts index 582fcb1..e56f948 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,6 +5,7 @@ import type { CredentialServiceBinding, StagedFile } from "./credentials"; import { publishToBluesky, blueskyProfile, type BlueskyCreds } from "./bluesky"; import { scheduleDelivery, cancelDelivery, verifyDelivery } from "./queue"; import { initUploads, uploadsEnabled, putUpload, getUpload, makeKey } from "./uploads"; +import { mediaLimitError } from "../shared/platforms"; type Env = { Bindings: { @@ -47,11 +48,14 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P [id], ); const media = await query("SELECT * FROM media WHERE post_id = ? ORDER BY id ASC", [id]); - const firstImage = media[0]?.url as string | undefined; + // Every attached image, in the order the composer shows them. Each platform + // decides how many it can carry (see mediaLimitError) — none of them silently + // gets a subset. + const imageUrls = media.map((m: any) => m.url as string).filter(Boolean); const results: PublishResult[] = []; for (const channel of channels) { - const r = await publishToChannel(channel, post.content, firstImage); + const r = await publishToChannel(channel, post.content, imageUrls); // Persist this channel's delivery outcome on its post_channels row. await run( `UPDATE post_channels @@ -87,18 +91,30 @@ async function publishPost(id: number): Promise<{ published: boolean; results: P return { published: delivered > 0, results }; } -async function publishToChannel(channel: any, content: string, imageUrl?: string): Promise { +async function publishToChannel(channel: any, content: string, imageUrls: string[]): Promise { const base = { channelId: channel.id as number, channel: channel.name as string, platform: channel.platform as string }; + + // More images than this platform accepts fails the channel rather than + // posting a truncated set. Sending fewer images than the user attached, + // silently, is the bug the whole media path exists to avoid — and the + // per-channel delivery model means the other channels still go out. + const overLimit = mediaLimitError(channel.platform, imageUrls.length); + if (overLimit) return { ...base, success: false, error: overLimit }; + switch (channel.platform) { case "twitter": { // Composio execute (raw tokens are permanently redacted post-incident). - // An image is a second call first: stage it with the broker, hand the - // descriptor to TWITTER_UPLOAD_MEDIA, then attach the media id it mints. + // Images are separate calls first: stage each with the broker, hand the + // descriptor to TWITTER_UPLOAD_MEDIA, then attach every media id it mints. let mediaIds: string[] | undefined; - if (imageUrl) { - const up = await uploadTwitterMedia(imageUrl); - if ("error" in up) return { ...base, success: false, error: up.error }; - mediaIds = [up.mediaId]; + if (imageUrls.length) { + const ids: string[] = []; + for (const url of imageUrls) { + const up = await uploadTwitterMedia(url); + if ("error" in up) return { ...base, success: false, error: up.error }; + ids.push(up.mediaId); + } + mediaIds = ids; } const r = await executeTool("twitter", "TWITTER_CREATION_OF_A_POST", { text: content, @@ -116,17 +132,22 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string if (!me?.successful) return { ...base, success: false, error: me?.error || "LinkedIn not connected" }; const id = (me.data as { id?: string } | null)?.id; if (!id) return { ...base, success: false, error: "could not resolve LinkedIn member id" }; - // LinkedIn's action uploads the image itself, but only from a file + // LinkedIn's action uploads the images itself, but only from files // staged through the broker — a URL in `images` is not a shape it takes. + // The action carries 1-20 of them; slide order follows this array. let images: StagedFile[] | undefined; - if (imageUrl) { - const staged = await stageFile("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", imageUrl); - // No staging available at all: off-platform, or a runtime older than - // the broker's stageFile. Either way the image cannot go out, and the - // channel fails rather than quietly posting the text on its own. - if (!staged) return { ...base, success: false, error: "Couldn't upload the image to LinkedIn. Reconnect LinkedIn in Clawnify." }; - if (!staged.descriptor) return { ...base, success: false, error: `LinkedIn image upload failed: ${staged.error}` }; - images = [staged.descriptor]; + if (imageUrls.length) { + const staged: StagedFile[] = []; + for (const url of imageUrls) { + const s = await stageFile("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", url); + // No staging available at all: off-platform, or a runtime older than + // the broker's stageFile. Either way the image cannot go out, and the + // channel fails rather than quietly posting the text on its own. + if (!s) return { ...base, success: false, error: "Couldn't upload the images to LinkedIn. Reconnect LinkedIn in Clawnify." }; + if (!s.descriptor) return { ...base, success: false, error: `LinkedIn image upload failed: ${s.error}` }; + staged.push(s.descriptor); + } + images = staged; } const r = await executeTool("linkedin", "LINKEDIN_CREATE_LINKED_IN_POST", { author: `urn:li:person:${id}`, @@ -143,20 +164,34 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // Composio execute, two-step: create media container → publish it. // IG requires a Business account, an image, and the IG Business Account // ID (resolved from the connection, see resolveInstagramAccountId). - if (!imageUrl) return { ...base, success: false, error: "Instagram requires an image." }; + // + // One image is a plain container; two or more is a carousel, which takes + // its children as URLs directly (no per-child container round-trip). Both + // publish through the same media_publish call. + // + // INSTAGRAM_CREATE_MEDIA_CONTAINER / INSTAGRAM_CREATE_POST — what this + // used to call — are both marked deprecated in Composio's catalogue, and + // the carousel container has no deprecated publish partner anyway. + if (!imageUrls.length) return { ...base, success: false, error: "Instagram requires an image." }; const igUserId = await resolveInstagramAccountId(channel); if (!igUserId) return { ...base, success: false, error: "No Instagram credentials. Connect Instagram in Clawnify." }; - const container = await executeTool("instagram", "INSTAGRAM_CREATE_MEDIA_CONTAINER", { - ig_user_id: igUserId, - image_url: imageUrl, - caption: content, - content_type: "photo", - }); + const container = + imageUrls.length === 1 + ? await executeTool("instagram", "INSTAGRAM_POST_IG_USER_MEDIA", { + ig_user_id: igUserId, + image_url: imageUrls[0], + caption: content, + }) + : await executeTool("instagram", "INSTAGRAM_CREATE_CAROUSEL_CONTAINER", { + ig_user_id: igUserId, + child_image_urls: imageUrls, + caption: content, + }); if (!container) return { ...base, success: false, error: "No Instagram credentials. Connect Instagram in Clawnify." }; if (!container.successful) return { ...base, success: false, error: container.error || "Instagram container failed" }; const creationId = (container.data as { id?: string } | null)?.id; if (!creationId) return { ...base, success: false, error: "Instagram: no creation_id returned" }; - const pub = await executeTool("instagram", "INSTAGRAM_CREATE_POST", { + const pub = await executeTool("instagram", "INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH", { ig_user_id: igUserId, creation_id: creationId, }); @@ -166,10 +201,11 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string case "tiktok": { // Composio execute, single-step photo post (TikTok Content Posting API). // Image-only for now, matching this app's photo-first media model. - if (!imageUrl) return { ...base, success: false, error: "TikTok requires an image." }; + // photo_images carries the whole set (1-35); the first is the cover. + if (!imageUrls.length) return { ...base, success: false, error: "TikTok requires an image." }; const r = await executeTool("tiktok", "TIKTOK_POST_PHOTO", { post_mode: "DIRECT_POST", - photo_images: [imageUrl], + photo_images: imageUrls, photo_cover_index: 0, title: content.slice(0, 90), description: content, @@ -198,22 +234,36 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // through /feed (Postiz's facebook.provider.ts splits the same way). const pageId = accountId(channel); if (!pageId) return { ...base, success: false, error: "Facebook channel has no Page selected." }; - const r = imageUrl - ? await executeTool("facebook", "FACEBOOK_CREATE_PHOTO_POST", { - page_id: pageId, - url: imageUrl, - message: content, - published: true, - }) - : await executeTool("facebook", "FACEBOOK_CREATE_POST", { - page_id: pageId, - message: content, - published: true, - }); + // Several photos need the unpublished-upload + attached_media dance; + // FACEBOOK_CREATE_MULTI_PHOTO_POST does the whole thing in one call and + // fails the post outright if any upload fails, so a partial set never + // ships. One photo keeps the plain photo post. + const r = + imageUrls.length > 1 + ? await executeTool("facebook", "FACEBOOK_CREATE_MULTI_PHOTO_POST", { + page_id: pageId, + photo_urls: imageUrls, + message: content, + }) + : imageUrls.length === 1 + ? await executeTool("facebook", "FACEBOOK_CREATE_PHOTO_POST", { + page_id: pageId, + url: imageUrls[0], + message: content, + published: true, + }) + : await executeTool("facebook", "FACEBOOK_CREATE_POST", { + page_id: pageId, + message: content, + published: true, + }); if (!r) return { ...base, success: false, error: "No Facebook credentials. Connect Facebook in Clawnify." }; - // Composio wraps the Graph response as data.response_data. /feed returns - // { id: "_" }; /photos returns { id: , post_id: - // "_" } — the post id is the one that has a permalink. + // Composio wraps the raw Graph response as data.response_data. /feed + // returns { id: "_" }; /photos returns { id: , + // post_id: "_" } — the post id is the one that has a + // permalink. The multi-photo action is Composio-authored and returns a + // typed { post_id } with no response_data wrapper, which the same + // `response_data ?? data` then `post_id || id` read already covers. const d = (((r.data as any)?.response_data ?? r.data) || {}) as { id?: string; post_id?: string }; const ref = d.post_id || d.id; const url = ref ? facebookPostUrl(ref) : undefined; @@ -227,7 +277,7 @@ async function publishToChannel(channel: any, content: string, imageUrl?: string // OAuth channels above. const creds = await resolveBlueskyCreds(); if (!creds) return { ...base, success: false, error: "No Bluesky credentials. Connect Bluesky in Clawnify." }; - const r = await publishToBluesky(creds, content, imageUrl); + const r = await publishToBluesky(creds, content, imageUrls); return { ...base, success: r.success, error: r.error, ref: r.ref, url: r.url }; } default: diff --git a/src/shared/platforms.ts b/src/shared/platforms.ts new file mode 100644 index 0000000..3dab9f8 --- /dev/null +++ b/src/shared/platforms.ts @@ -0,0 +1,94 @@ +// Platform metadata shared by the server (publishing) and the client (composer, +// previews, channel picker). +// +// It lives here rather than in client/types.ts because the server enforces the +// same media limits it warns about: two copies of this table would drift, and +// the copy that drifts is the one that only fails at send time. + +export type Platform = + | "twitter" + | "linkedin" + | "instagram" + | "facebook" + | "bluesky" + | "mastodon" + | "threads" + | "tiktok"; + +// The platforms the server can actually publish to — the single source the +// channel picker offers. It MUST track the cases in publishToChannel() +// (src/server/index.ts): a platform here with no server case would let a user +// schedule posts that only fail at send time (the mastodon/threads trap this +// list closes); a server case missing here is simply not offered. +// mastodon/threads stay out until they have a publish path. +export const PUBLISHABLE_PLATFORMS: Platform[] = [ + "twitter", + "linkedin", + "instagram", + "facebook", + "tiktok", + "bluesky", +]; + +export const PLATFORM_LIMITS: Record = { + twitter: 280, + linkedin: 3000, + instagram: 2200, + facebook: 63206, + bluesky: 300, + mastodon: 500, + threads: 500, + tiktok: 2200, +}; + +// How many images one post may carry, per platform. Only platforms whose own +// contract states a maximum appear here — an absent entry means "the platform +// publishes as many as it accepts, and its own rejection is the error we +// surface" (the stance bluesky.ts already takes on text length). +// +// Each number is the platform's stated cap, not folklore: +// twitter 4 — TWITTER_CREATION_OF_A_POST.media_media_ids ("Up to 4 Media IDs") +// linkedin 20 — LINKEDIN_CREATE_LINKED_IN_POST.images (maxItems: 20) +// instagram 10 — a carousel is 2-10 items (INSTAGRAM_CREATE_CAROUSEL_CONTAINER) +// tiktok 35 — TIKTOK_POST_PHOTO.photo_images (maxItems: 35) +// bluesky 4 — app.bsky.embed.images.images (lexicon maxLength: 4) +// facebook is deliberately absent: FACEBOOK_CREATE_MULTI_PHOTO_POST.photo_urls +// declares minItems but no maximum, so there is no stated number to enforce. +export const PLATFORM_MEDIA_LIMITS: Partial> = { + twitter: 4, + linkedin: 20, + instagram: 10, + tiktok: 35, + bluesky: 4, +}; + +export const PLATFORM_COLORS: Record = { + twitter: "#1da1f2", + linkedin: "#0a66c2", + instagram: "#e4405f", + facebook: "#1877f2", + bluesky: "#0085ff", + mastodon: "#6364ff", + threads: "#000000", + tiktok: "#00f2ea", +}; + +export const PLATFORM_LABELS: Record = { + twitter: "X / Twitter", + linkedin: "LinkedIn", + instagram: "Instagram", + facebook: "Facebook", + bluesky: "Bluesky", + mastodon: "Mastodon", + threads: "Threads", + tiktok: "TikTok", +}; + +// Why a channel can't take this many images, in the user's words. Null when the +// count is fine (or the platform states no cap). +export function mediaLimitError(platform: string, count: number): string | null { + const max = PLATFORM_MEDIA_LIMITS[platform as Platform]; + if (max === undefined || count <= max) return null; + const label = PLATFORM_LABELS[platform as Platform] ?? platform; + return `${label} takes at most ${max} image${max === 1 ? "" : "s"}; this post has ${count}.`; +}