diff --git a/.env.example b/.env.example index 7bb82f98..eb65f017 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,11 @@ MODAL_LTX2_ENDPOINT_URL= # Modal self-hosted LTX-2 endpoint (optional) PEXELS_API_KEY= # Pexels stock footage/images (free) PIXABAY_API_KEY= # Pixabay stock footage/images (free) +# --- Social Publishing --- +UPLOADPOST_API_KEY= # Publish to 11 platforms (IG, TikTok, YT, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, FB, Google Business) + # Free tier: 10 uploads/month, no credit card required + # Get one at https://upload-post.com + # --- Analysis --- HF_TOKEN= # HuggingFace token — enables speaker diarization in transcriber diff --git a/pipeline_defs/animated-explainer.yaml b/pipeline_defs/animated-explainer.yaml index 1ea2ac6c..d2eedb36 100644 --- a/pipeline_defs/animated-explainer.yaml +++ b/pipeline_defs/animated-explainer.yaml @@ -250,7 +250,8 @@ stages: - proposal_packet produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/animation.yaml b/pipeline_defs/animation.yaml index 2aab5f94..acfb96af 100644 --- a/pipeline_defs/animation.yaml +++ b/pipeline_defs/animation.yaml @@ -267,7 +267,8 @@ stages: - script produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/avatar-spokesperson.yaml b/pipeline_defs/avatar-spokesperson.yaml index 4b6afae7..3aca2d68 100644 --- a/pipeline_defs/avatar-spokesperson.yaml +++ b/pipeline_defs/avatar-spokesperson.yaml @@ -194,7 +194,8 @@ stages: - script produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/cinematic.yaml b/pipeline_defs/cinematic.yaml index a327f360..114f8c19 100644 --- a/pipeline_defs/cinematic.yaml +++ b/pipeline_defs/cinematic.yaml @@ -254,7 +254,8 @@ stages: - script produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/clip-factory.yaml b/pipeline_defs/clip-factory.yaml index 690957f2..937072bd 100644 --- a/pipeline_defs/clip-factory.yaml +++ b/pipeline_defs/clip-factory.yaml @@ -194,7 +194,8 @@ stages: - brief produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/hybrid.yaml b/pipeline_defs/hybrid.yaml index 1cd1b740..1f425ae9 100644 --- a/pipeline_defs/hybrid.yaml +++ b/pipeline_defs/hybrid.yaml @@ -212,7 +212,8 @@ stages: - script produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/localization-dub.yaml b/pipeline_defs/localization-dub.yaml index 726a348d..69752bdc 100644 --- a/pipeline_defs/localization-dub.yaml +++ b/pipeline_defs/localization-dub.yaml @@ -195,7 +195,8 @@ stages: - script produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/podcast-repurpose.yaml b/pipeline_defs/podcast-repurpose.yaml index f41e97f9..bc1abe6c 100644 --- a/pipeline_defs/podcast-repurpose.yaml +++ b/pipeline_defs/podcast-repurpose.yaml @@ -200,7 +200,8 @@ stages: - brief produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/screen-demo.yaml b/pipeline_defs/screen-demo.yaml index 75487e71..6895ad7a 100644 --- a/pipeline_defs/screen-demo.yaml +++ b/pipeline_defs/screen-demo.yaml @@ -199,7 +199,8 @@ stages: - brief produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/pipeline_defs/talking-head.yaml b/pipeline_defs/talking-head.yaml index 33a21787..b9cd00c9 100644 --- a/pipeline_defs/talking-head.yaml +++ b/pipeline_defs/talking-head.yaml @@ -217,7 +217,8 @@ stages: - final_review produces: - publish_log - tools_available: [] + tools_available: + - uploadpost_publisher checkpoint_required: true human_approval_default: true review_focus: diff --git a/skills/INDEX.md b/skills/INDEX.md index 1907a084..f6b27acf 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -63,6 +63,7 @@ Key capability families to look for in the output: | `subtitle` | — | Pure Python | | `avatar` | — | Local GPU models | | `video_post` | — | FFmpeg-based local tools | +| `social_publishing` | — | Upload-Post API (11 platforms) | ### Adding New Tools @@ -82,6 +83,7 @@ Key capability families to look for in the output: | WhisperX | `core/whisperx.md` | Transcription with word-level timestamps | `speech-to-text` | | Subtitle Sync | `core/subtitle-sync.md` | Subtitle timing and alignment | `remotion-best-practices` | | Color Grading | `core/color-grading.md` | FFmpeg color profiles, LUT workflow, accessibility | `ffmpeg` | +| Social Publishing | `core/social-publishing.md` | Publish to 11 social platforms via Upload-Post API | — | ## Creative Skills diff --git a/skills/core/social-publishing.md b/skills/core/social-publishing.md new file mode 100644 index 00000000..b2d8bed3 --- /dev/null +++ b/skills/core/social-publishing.md @@ -0,0 +1,68 @@ +# Social Publishing via Upload-Post + +## When To Use + +When the pipeline's publish stage needs to distribute the final video (or photos/text) to one or more social media platforms. Upload-Post replaces manual export-and-upload workflows with a single API call. + +## Tool + +`uploadpost_publisher` — registered in `tools/publishers/uploadpost_publisher.py` + +| Field | Value | +|-------|-------| +| Tier | publish | +| Capability | `social_publishing` | +| Runtime | API | +| Env var | `UPLOADPOST_API_KEY` | + +## Supported Platforms + +Instagram, TikTok, YouTube, LinkedIn, Facebook, X (Twitter), Threads, Pinterest, Bluesky, Reddit, Google Business Profile. + +## How It Works + +1. The user connects social accounts through the Upload-Post dashboard (two clicks per account — no app creation, no developer tokens, no OAuth flows to build). +2. A single API key handles all connected platforms. +3. The tool sends the rendered video (or photos) to `POST /api/upload_videos` (or `/upload_photos`, `/upload_text`) with the target platforms in one request. +4. Upload-Post handles platform-specific formatting, aspect ratios, and API requirements. + +## Integration With The Publish Stage + +During the `publish` pipeline step: + +```python +from tools.publishers.uploadpost_publisher import UploadPostPublisher + +publisher = UploadPostPublisher() +result = publisher.execute({ + "video_path": "pipeline/compose/final.mp4", + "platforms": ["youtube", "tiktok", "instagram"], + "profile_username": "my_profile", + "title": "My OpenMontage Video", + "description": "Created with OpenMontage", +}) +``` + +The returned `ToolResult.data` maps directly to `publish_log` schema entries: + +| ToolResult field | publish_log field | +|------------------|-------------------| +| `results[].platform` | `entries[].platform` | +| `results[].post_url` | `entries[].url` | +| `results[].platform_post_id` | `entries[].video_id` | + +## Scheduling & Queue + +- Set `scheduled_date` (ISO-8601) to schedule for a specific time. +- Set `add_to_queue: true` to auto-schedule to the next available queue slot. +- Queue slots are configurable per-profile in the Upload-Post dashboard. + +## Cost + +Free tier: 10 uploads/month across all platforms. No credit card required. + +## Common Pitfalls + +- Forgetting to set `profile_username` — this identifies which connected accounts to use. +- Not including `title` for YouTube or Reddit uploads (required by those platforms). +- Sending a vertical video to platforms that expect landscape without setting aspect ratio metadata in the render stage. diff --git a/skills/pipelines/animation/publish-director.md b/skills/pipelines/animation/publish-director.md index 1048201c..99072d4e 100644 --- a/skills/pipelines/animation/publish-director.md +++ b/skills/pipelines/animation/publish-director.md @@ -38,6 +38,10 @@ Store in `publish_log.metadata`: - exports are labeled by purpose and platform, - the package is usable without extra manual work. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Writing generic metadata that ignores the animation style. diff --git a/skills/pipelines/avatar-spokesperson/publish-director.md b/skills/pipelines/avatar-spokesperson/publish-director.md index a3005f79..f6a3d42f 100644 --- a/skills/pipelines/avatar-spokesperson/publish-director.md +++ b/skills/pipelines/avatar-spokesperson/publish-director.md @@ -37,6 +37,10 @@ If the avatar path has limitations such as visible lip-sync risk, retain that no - poster frame or thumbnail concept features the presenter cleanly, - review notes stay attached to the package. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Mixing hero and derivative exports without clear naming. diff --git a/skills/pipelines/cinematic/publish-director.md b/skills/pipelines/cinematic/publish-director.md index bf70f0af..07a22aca 100644 --- a/skills/pipelines/cinematic/publish-director.md +++ b/skills/pipelines/cinematic/publish-director.md @@ -49,6 +49,10 @@ Store in `publish_log.metadata`: - metadata fits the tone, - the package is usable without manual cleanup. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Mixing teaser and hero outputs without clear naming. diff --git a/skills/pipelines/clip-factory/publish-director.md b/skills/pipelines/clip-factory/publish-director.md index 0273b83c..ed9df901 100644 --- a/skills/pipelines/clip-factory/publish-director.md +++ b/skills/pipelines/clip-factory/publish-director.md @@ -53,6 +53,10 @@ Store in `publish_log.metadata`: - export folders are usable without extra cleanup, - the batch catalog clearly links ranking, file paths, and publishing intent. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Publishing the whole batch on the same day. diff --git a/skills/pipelines/explainer/publish-director.md b/skills/pipelines/explainer/publish-director.md index aaa2dcd6..9efdb396 100644 --- a/skills/pipelines/explainer/publish-director.md +++ b/skills/pipelines/explainer/publish-director.md @@ -143,6 +143,10 @@ If any dimension scores below 3, revise. Validate the publish_log against the schema and persist via checkpoint. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - **Generic titles**: "Video About X" loses to "X Explained in 60 Seconds" every time. Be specific and compelling. diff --git a/skills/pipelines/hybrid/publish-director.md b/skills/pipelines/hybrid/publish-director.md index e8e10a6d..93f405f8 100644 --- a/skills/pipelines/hybrid/publish-director.md +++ b/skills/pipelines/hybrid/publish-director.md @@ -43,6 +43,10 @@ Recommended metadata keys: - export folders are organized by purpose, - the package is ready to use without manual cleanup. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Hiding which output is the hero cut. diff --git a/skills/pipelines/localization-dub/publish-director.md b/skills/pipelines/localization-dub/publish-director.md index bd39714a..439971ee 100644 --- a/skills/pipelines/localization-dub/publish-director.md +++ b/skills/pipelines/localization-dub/publish-director.md @@ -37,6 +37,10 @@ If a language output has pronunciation caveats, timing warnings, or missing lip - supporting text assets are present, - warnings and review notes are not lost. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Shipping localized videos without the matching subtitle or transcript files. diff --git a/skills/pipelines/podcast-repurpose/publish-director.md b/skills/pipelines/podcast-repurpose/publish-director.md index 0323ebc9..b8f22517 100644 --- a/skills/pipelines/podcast-repurpose/publish-director.md +++ b/skills/pipelines/podcast-repurpose/publish-director.md @@ -54,6 +54,10 @@ Recommended metadata keys: - copy matches the platform, - the release order reflects actual clip strength. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Publishing clips without clear episode references. diff --git a/skills/pipelines/screen-demo/publish-director.md b/skills/pipelines/screen-demo/publish-director.md index 040f540a..b66ee6db 100644 --- a/skills/pipelines/screen-demo/publish-director.md +++ b/skills/pipelines/screen-demo/publish-director.md @@ -73,6 +73,10 @@ For developer or product-demo content, also package: - export folders are clean and reusable, - copy is tailored to the platform instead of duplicated. +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. + ## Common Pitfalls - Publishing with generic titles that omit the actual software or task. diff --git a/skills/pipelines/talking-head/publish-director.md b/skills/pipelines/talking-head/publish-director.md index 456dba84..245bf30c 100644 --- a/skills/pipelines/talking-head/publish-director.md +++ b/skills/pipelines/talking-head/publish-director.md @@ -50,3 +50,7 @@ Document the publish event with platform, status (draft), and export path. ### Step 6: Submit Validate the publish_log against the schema and persist via checkpoint. + +## Direct Publishing (Optional) + +If `UPLOADPOST_API_KEY` is set, use the `uploadpost_publisher` tool to publish the final video directly to social platforms (Instagram, TikTok, YouTube, LinkedIn, X, Threads, Pinterest, Bluesky, Reddit, Facebook, Google Business) instead of only exporting locally. See `skills/core/social-publishing.md` for integration details. diff --git a/tools/publishers/uploadpost_publisher.py b/tools/publishers/uploadpost_publisher.py new file mode 100644 index 00000000..095e3965 --- /dev/null +++ b/tools/publishers/uploadpost_publisher.py @@ -0,0 +1,428 @@ +"""Upload-Post publisher — publish videos and photos to 11 social platforms via a single API. + +Upload-Post (https://upload-post.com) provides a unified API that publishes to +Instagram, TikTok, YouTube, LinkedIn, Facebook, X (Twitter), Threads, Pinterest, +Bluesky, Reddit, and Google Business Profile. + +Users connect their social accounts through Upload-Post's dashboard with two clicks +(no app creation, no developer tokens) and get a single API key for all platforms. +Free tier includes 10 uploads/month with no credit card required. + +Docs: https://docs.upload-post.com +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import Any + +from tools.base_tool import ( + BaseTool, + Determinism, + ExecutionMode, + ResourceProfile, + RetryPolicy, + ToolResult, + ToolRuntime, + ToolStability, + ToolStatus, + ToolTier, +) + +SUPPORTED_PLATFORMS = [ + "instagram", + "tiktok", + "youtube", + "linkedin", + "facebook", + "x", + "threads", + "pinterest", + "bluesky", + "reddit", + "google_business", +] + +BASE_URL = "https://api.upload-post.com/api" + + +class UploadPostPublisher(BaseTool): + name = "uploadpost_publisher" + version = "1.0.0" + tier = ToolTier.PUBLISH + capability = "social_publishing" + provider = "uploadpost" + stability = ToolStability.PRODUCTION + execution_mode = ExecutionMode.ASYNC + determinism = Determinism.DETERMINISTIC + runtime = ToolRuntime.API + + dependencies = ["env:UPLOADPOST_API_KEY"] + install_instructions = ( + "1. Create a free account at https://upload-post.com (no credit card needed).\n" + "2. Connect your social accounts with two clicks — no app creation or developer tokens required.\n" + "3. Generate an API key from the dashboard.\n" + "4. Set UPLOADPOST_API_KEY in your .env file.\n" + "Free tier: 10 uploads/month across all platforms." + ) + + capabilities = [ + "publish_video", + "publish_photo", + "publish_text", + "schedule_post", + "queue_post", + "multi_platform", + ] + supports = { + "video": True, + "photo": True, + "carousel": True, + "text_only": True, + "scheduling": True, + "queue": True, + "async_upload": True, + "first_comment": True, + "multi_platform_single_request": True, + "platforms": SUPPORTED_PLATFORMS, + } + best_for = [ + "publishing finished videos to multiple social platforms at once", + "scheduling posts across Instagram, TikTok, YouTube, LinkedIn, X, and more", + "zero-config social publishing — no app creation or OAuth setup per platform", + ] + not_good_for = [ + "video generation (use video generation tools instead)", + "editing or post-processing (use FFmpeg or Remotion tools)", + ] + fallback_tools = [] + + input_schema = { + "type": "object", + "required": ["platforms", "profile_username"], + "properties": { + "video_path": { + "type": "string", + "description": "Path to the video file to publish", + }, + "photo_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Paths to photo files (for photo posts or carousels)", + }, + "platforms": { + "type": "array", + "items": { + "type": "string", + "enum": SUPPORTED_PLATFORMS, + }, + "description": "Platforms to publish to", + }, + "profile_username": { + "type": "string", + "description": "Upload-Post profile username (linked social accounts)", + }, + "title": { + "type": "string", + "description": "Post title (required for YouTube, Reddit)", + }, + "description": { + "type": "string", + "description": "Post description / caption", + }, + "scheduled_date": { + "type": "string", + "description": "ISO-8601 datetime for scheduling (e.g. 2025-01-15T14:00:00Z)", + }, + "timezone": { + "type": "string", + "description": "IANA timezone (e.g. America/New_York, Europe/Madrid)", + }, + "add_to_queue": { + "type": "boolean", + "default": False, + "description": "Auto-schedule to next available queue slot", + }, + "first_comment": { + "type": "string", + "description": "Auto-post a comment after publishing (e.g. hashtags)", + }, + "facebook_page_id": {"type": "string"}, + "pinterest_board_id": {"type": "string"}, + "target_linkedin_page_id": {"type": "string"}, + "document_path": { + "type": "string", + "description": "Path to document file for LinkedIn (PDF, PPT, PPTX, DOC, DOCX)", + }, + "media_type": { + "type": "string", + "enum": ["IMAGE", "STORIES", "POSTS"], + "description": "Platform-specific media type override", + }, + }, + } + + resource_profile = ResourceProfile( + cpu_cores=1, ram_mb=256, vram_mb=0, disk_mb=50, network_required=True + ) + retry_policy = RetryPolicy( + max_retries=2, backoff_seconds=5.0, retryable_errors=["rate_limit", "timeout"] + ) + idempotency_key_fields = ["video_path", "platforms", "profile_username"] + side_effects = [ + "publishes content to social media platforms", + "calls Upload-Post API", + ] + user_visible_verification = [ + "Check post URL(s) returned in the publish log", + "Verify post appears on each target platform", + ] + + def _get_api_key(self) -> str | None: + return os.environ.get("UPLOADPOST_API_KEY") + + def get_status(self) -> ToolStatus: + if self._get_api_key(): + return ToolStatus.AVAILABLE + return ToolStatus.UNAVAILABLE + + def estimate_cost(self, inputs: dict[str, Any]) -> float: + # Upload-Post free tier: 10 uploads/month, no cost per call + return 0.0 + + def estimate_runtime(self, inputs: dict[str, Any]) -> float: + # Async upload + polling typically takes 30-120s depending on file size + return 60.0 + + def _poll_status( + self, + headers: dict[str, str], + request_id: str | None = None, + job_id: str | None = None, + timeout: int = 300, + ) -> dict[str, Any]: + """Poll Upload-Post status endpoint until completion or timeout.""" + import requests + + params: dict[str, str] = {} + if request_id: + params["request_id"] = request_id + elif job_id: + params["job_id"] = job_id + else: + return {"status": "ERROR", "error": "No request_id or job_id to poll"} + + deadline = time.time() + timeout + while time.time() < deadline: + resp = requests.get( + f"{BASE_URL}/uploadposts/status", + headers=headers, + params=params, + timeout=30, + ) + if not resp.ok: + return {"status": "ERROR", "error": f"Status poll failed: {resp.status_code}"} + data = resp.json() + status = data.get("status", "UNKNOWN") + if status in ("FINISHED", "ERROR"): + return data + time.sleep(5) + + return {"status": "ERROR", "error": "Upload timed out waiting for completion"} + + def execute(self, inputs: dict[str, Any]) -> ToolResult: + api_key = self._get_api_key() + if not api_key: + return ToolResult( + success=False, + error="UPLOADPOST_API_KEY not set. " + self.install_instructions, + ) + + import requests + + start = time.time() + headers = {"Authorization": f"Apikey {api_key}"} + + platforms = inputs["platforms"] + profile = inputs["profile_username"] + title = inputs.get("title", "") + description = inputs.get("description", "") + video_path = inputs.get("video_path") + photo_paths = inputs.get("photo_paths") or [] + + # Build the multipart form data + form_data: dict[str, Any] = {"user": profile} + if title: + form_data["title"] = title + if description: + form_data["description"] = description + if inputs.get("scheduled_date"): + form_data["scheduled_date"] = inputs["scheduled_date"] + if inputs.get("timezone"): + form_data["timezone"] = inputs["timezone"] + if inputs.get("add_to_queue"): + form_data["add_to_queue"] = "true" + if inputs.get("first_comment"): + form_data["first_comment"] = inputs["first_comment"] + if inputs.get("facebook_page_id"): + form_data["facebook_page_id"] = inputs["facebook_page_id"] + if inputs.get("pinterest_board_id"): + form_data["pinterest_board_id"] = inputs["pinterest_board_id"] + if inputs.get("target_linkedin_page_id"): + form_data["target_linkedin_page_id"] = inputs["target_linkedin_page_id"] + if inputs.get("media_type"): + form_data["media_type"] = inputs["media_type"] + + # Always use async for non-blocking pipeline execution + form_data["async_upload"] = "true" + + # Build platform[] fields + platform_fields = [("platform[]", p) for p in platforms] + + try: + # Decide endpoint based on content type + document_path = inputs.get("document_path") + if video_path and Path(video_path).exists(): + files = [("video", open(video_path, "rb"))] + endpoint = f"{BASE_URL}/upload" + elif photo_paths: + files = [] + for p in photo_paths: + if Path(p).exists(): + files.append(("photos[]", open(p, "rb"))) + endpoint = f"{BASE_URL}/upload_photos" + elif document_path and Path(document_path).exists(): + files = [("document", open(document_path, "rb"))] + endpoint = f"{BASE_URL}/upload_document" + elif title: + # Text-only post + files = [] + endpoint = f"{BASE_URL}/upload_text" + else: + return ToolResult( + success=False, + error="No video_path, photo_paths, or title provided — nothing to publish", + ) + + # Merge platform[] into form_data list for requests + form_fields = list(form_data.items()) + platform_fields + + resp = requests.post( + endpoint, + headers=headers, + data=form_fields, + files=files if files else None, + timeout=120, + ) + + # Close opened files + for _, f in (files if files else []): + if hasattr(f, "close"): + f.close() + + if not resp.ok: + detail = resp.text[:500] + return ToolResult( + success=False, + error=f"Upload-Post API error ({resp.status_code}): {detail}", + ) + + result_data = resp.json() + request_id = result_data.get("request_id") + job_id = result_data.get("job_id") + + # If scheduled, return immediately with job_id + if inputs.get("scheduled_date") or inputs.get("add_to_queue"): + return ToolResult( + success=True, + data={ + "provider": "uploadpost", + "status": "scheduled", + "job_id": job_id, + "request_id": request_id, + "platforms": platforms, + "profile": profile, + }, + artifacts=[], + cost_usd=0.0, + duration_seconds=round(time.time() - start, 2), + ) + + # Poll for async result + poll_result = self._poll_status( + headers, request_id=request_id, job_id=job_id + ) + + if poll_result.get("status") == "FINISHED": + platform_results = poll_result.get("result", {}) + publish_entries = [] + post_urls = [] + for plat, plat_data in platform_results.items(): + url = plat_data.get("post_url", "") + if url: + post_urls.append(url) + publish_entries.append( + { + "platform": plat, + "success": plat_data.get("success", False), + "post_url": url, + "platform_post_id": plat_data.get("platform_post_id", ""), + } + ) + + return ToolResult( + success=True, + data={ + "provider": "uploadpost", + "status": "published", + "platforms": platforms, + "profile": profile, + "results": publish_entries, + "post_urls": post_urls, + "request_id": request_id, + }, + artifacts=post_urls, + cost_usd=0.0, + duration_seconds=round(time.time() - start, 2), + ) + else: + error_msg = poll_result.get("error", "Unknown error during upload") + return ToolResult( + success=False, + error=f"Upload-Post publish failed: {error_msg}", + data={"request_id": request_id, "poll_result": poll_result}, + ) + + except Exception as e: + return ToolResult( + success=False, + error=f"Upload-Post publish failed: {e}", + ) + + def dry_run(self, inputs: dict[str, Any]) -> dict[str, Any]: + """Preflight check: verify API key and list target platforms.""" + api_key = self._get_api_key() + platforms = inputs.get("platforms", []) + video_path = inputs.get("video_path", "") + photo_paths = inputs.get("photo_paths", []) + + issues = [] + if not api_key: + issues.append("UPLOADPOST_API_KEY not set") + if video_path and not Path(video_path).exists(): + issues.append(f"Video file not found: {video_path}") + for p in photo_paths: + if not Path(p).exists(): + issues.append(f"Photo file not found: {p}") + + return { + "tool": self.name, + "estimated_cost_usd": 0.0, + "estimated_runtime_seconds": self.estimate_runtime(inputs), + "status": self.get_status().value, + "would_execute": len(issues) == 0, + "target_platforms": platforms, + "issues": issues, + }