diff --git a/AI_API.md b/AI_API.md index 716cc17..1f09335 100644 --- a/AI_API.md +++ b/AI_API.md @@ -37,6 +37,8 @@ SocialAccount (login for a social network) Post (one piece of content, belongs to a Site, optionally a SocialAccount) └─ type: same vocabulary as SocialAccount.network, plus "blog" └─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit) + └─ image_url: nullable URL. Required to publish type=blog (WordPress) — + used as the featured image. Optional for every other type. └─ status: draft -> scheduled -> publishing -> published | failed └─ published_url: set once it's actually live somewhere └─ publish_error: set to the failure reason when status=failed (or when a @@ -145,6 +147,7 @@ Body: "title": "Optional title", "body": "Required content", "target": null, + "image_url": null, "status": "draft", "scheduled_at": null } @@ -153,8 +156,10 @@ Validation: `site_id` required, must exist. `social_account_id` nullable, must exist if given. `type` required, one of `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`. `body` required. `title` nullable. `target` nullable — a network-specific -destination, e.g. the subreddit name for `type=reddit`. `status` defaults to -`draft` if omitted. `scheduled_at` nullable datetime. +destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable +URL — not enforced at save time (you can draft a `type=blog` post without one), +but `WordPressPublisher` will reject publishing it until it's set. `status` +defaults to `draft` if omitted. `scheduled_at` nullable datetime. Note: `type` and the linked Social Account's `network` are **not** cross-validated — you can link a `type=blog` post to a Twitter account, or @@ -192,7 +197,7 @@ who/what is driving: - **Manually**, for anything without a wired-in publisher yet: do it yourself and `PATCH` the result back (see below). -Note the asymmetry: WordPress is synchronous (a single HTTP call, fast +Note the asymmetry: WordPress is synchronous (a few plain HTTP calls, fast enough to run inline), Reddit is not (it's real browser automation via Playwright, tens of seconds) — which is exactly why the UI path is queue-backed rather than a direct synchronous call. @@ -212,17 +217,20 @@ php artisan posts:publish {post_id} What it does: 1. Loads the Post and its Site. 2. Validates: `post.type` must be `blog`, `post.title` must be set, - `site.cms_type` must be `wordpress`, and the site must have - `wp_username`/`wp_app_password` set. Any failure here exits 1 with a - descriptive message and **does not touch** `Post.status`. -3. POSTs to `{site.url}/wp-json/wp/v2/posts` using HTTP Basic Auth with - the site's WordPress Application Password, `status: publish` (goes live - immediately, not a WP draft). -4. On success: sets `Post.status = published`, `Post.published_url` to the + `post.image_url` must be set, `site.cms_type` must be `wordpress`, and + the site must have `wp_username`/`wp_app_password` set. Any failure here + exits 1 with a descriptive message and **does not touch** `Post.status`. +3. Downloads `post.image_url`, then POSTs it to + `{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's + WordPress Application Password) to upload it as media and get back a + media id. +4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes + live immediately, not a WP draft) and `featured_media` set to that id. +5. On success: sets `Post.status = published`, `Post.published_url` to the new post's permalink, prints `{"success": true, "url": "..."}`, exits 0. -5. On failure (bad credentials, unreachable site, WordPress error): sets - `Post.status = failed`, prints `{"success": false, "error": "..."}`, - exits 1. +6. On failure at either HTTP step (bad credentials, unreachable site, + unreachable/rejected image, WordPress error): sets `Post.status = failed`, + prints `{"success": false, "error": "..."}`, exits 1. This is a **shell command**, not an HTTP endpoint — run it from wherever you have shell access to this app (e.g. `php artisan posts:publish 42`). diff --git a/README.md b/README.md index 4e8b022..2ad53e7 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS. (e.g. Reddit's browser automation) don't block the page. - Built-in publishers (`app/Support/Publishing/`), one per network, resolved by `Post.type`: - - **WordPress**: posts directly to a site's WordPress REST API. Also - available synchronously from the CLI: `php artisan posts:publish {post_id}`. + - **WordPress**: posts directly to a site's WordPress REST API, uploading + `Post.image_url` as the featured image first — required to publish a + WordPress post, optional for every other post type. Also available + synchronously from the CLI: `php artisan posts:publish {post_id}`. - **Reddit**: drives a standalone Playwright sidecar at `sidecars/reddit-post/` that logs in and creates a post. - Anything else: no publisher yet — publish however fits, then report diff --git a/app/Http/Requests/StorePostRequest.php b/app/Http/Requests/StorePostRequest.php index 9984127..5db55d9 100644 --- a/app/Http/Requests/StorePostRequest.php +++ b/app/Http/Requests/StorePostRequest.php @@ -21,6 +21,7 @@ class StorePostRequest extends FormRequest 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], 'target' => ['nullable', 'string', 'max:255'], + 'image_url' => ['nullable', 'url', 'max:2048'], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduled_at' => ['nullable', 'date'], ]; diff --git a/app/Http/Requests/UpdatePostRequest.php b/app/Http/Requests/UpdatePostRequest.php index 855c356..78dccd2 100644 --- a/app/Http/Requests/UpdatePostRequest.php +++ b/app/Http/Requests/UpdatePostRequest.php @@ -21,6 +21,7 @@ class UpdatePostRequest extends FormRequest 'title' => ['nullable', 'string', 'max:255'], 'body' => ['sometimes', 'required', 'string'], 'target' => ['nullable', 'string', 'max:255'], + 'image_url' => ['nullable', 'url', 'max:2048'], 'status' => ['sometimes', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduled_at' => ['nullable', 'date'], 'published_url' => ['nullable', 'string', 'max:255'], diff --git a/app/Http/Resources/PostResource.php b/app/Http/Resources/PostResource.php index 6d1f12f..7de6b8a 100644 --- a/app/Http/Resources/PostResource.php +++ b/app/Http/Resources/PostResource.php @@ -17,6 +17,7 @@ class PostResource extends JsonResource 'title' => $this->title, 'body' => $this->body, 'target' => $this->target, + 'image_url' => $this->image_url, 'status' => $this->status, 'scheduled_at' => $this->scheduled_at, 'published_url' => $this->published_url, diff --git a/app/Livewire/Posts/Index.php b/app/Livewire/Posts/Index.php index 74a47c4..cc8d036 100644 --- a/app/Livewire/Posts/Index.php +++ b/app/Livewire/Posts/Index.php @@ -29,6 +29,8 @@ class Index extends Component public string $target = ''; + public string $imageUrl = ''; + public string $status = 'draft'; public string $scheduledAt = ''; @@ -82,6 +84,7 @@ class Index extends Component $this->title = (string) $post->title; $this->body = $post->body; $this->target = (string) $post->target; + $this->imageUrl = (string) $post->image_url; $this->status = $post->status; $this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? ''; $this->showForm = true; @@ -96,6 +99,7 @@ class Index extends Component 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], 'target' => ['nullable', 'string', 'max:255'], + 'imageUrl' => ['nullable', 'url', 'max:2048'], 'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)], 'scheduledAt' => ['nullable', 'date'], ]); @@ -107,6 +111,7 @@ class Index extends Component 'title' => $validated['title'] ?: null, 'body' => $validated['body'], 'target' => $validated['target'] ?: null, + 'image_url' => $validated['imageUrl'] ?: null, 'status' => $validated['status'], 'scheduled_at' => $validated['scheduledAt'] ?: null, ]); @@ -129,6 +134,7 @@ class Index extends Component $this->title = ''; $this->body = ''; $this->target = ''; + $this->imageUrl = ''; $this->status = 'draft'; $this->scheduledAt = ''; $this->resetValidation(); diff --git a/app/Models/Post.php b/app/Models/Post.php index e47b53d..fdf386a 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -24,6 +24,7 @@ class Post extends Model 'title', 'body', 'target', + 'image_url', 'status', 'scheduled_at', 'published_url', diff --git a/app/Support/Publishing/WordPressPublisher.php b/app/Support/Publishing/WordPressPublisher.php index 9114604..4f919a7 100644 --- a/app/Support/Publishing/WordPressPublisher.php +++ b/app/Support/Publishing/WordPressPublisher.php @@ -3,6 +3,7 @@ namespace App\Support\Publishing; use App\Models\Post; +use App\Models\Site; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; @@ -19,6 +20,10 @@ class WordPressPublisher implements Publisher return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); } + if (! $post->image_url) { + return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress."); + } + $site = $post->site; if ($site->cms_type !== 'wordpress') { @@ -29,12 +34,19 @@ class WordPressPublisher implements Publisher return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials."); } + $mediaId = $this->uploadFeaturedImage($post, $site); + + if ($mediaId instanceof PublishResult) { + return $mediaId; + } + try { $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [ 'title' => $post->title, 'content' => $post->body, 'status' => 'publish', + 'featured_media' => $mediaId, ]); } catch (ConnectionException $e) { return PublishResult::failed("could not connect to site: {$e->getMessage()}"); @@ -52,4 +64,41 @@ class WordPressPublisher implements Publisher return PublishResult::success($link); } + + private function uploadFeaturedImage(Post $post, Site $site): int|PublishResult + { + try { + $image = Http::get($post->image_url); + } catch (ConnectionException $e) { + return PublishResult::failed("could not download image: {$e->getMessage()}"); + } + + if ($image->failed()) { + return PublishResult::failed('could not download image: unexpected response from image URL'); + } + + $filename = basename(parse_url($post->image_url, PHP_URL_PATH) ?: '') ?: 'image.jpg'; + $contentType = $image->header('Content-Type') ?: 'application/octet-stream'; + + try { + $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) + ->withBody($image->body(), $contentType) + ->withHeaders(['Content-Disposition' => "attachment; filename=\"{$filename}\""]) + ->post(rtrim($site->url, '/').'/wp-json/wp/v2/media'); + } catch (ConnectionException $e) { + return PublishResult::failed("could not connect to site: {$e->getMessage()}"); + } + + if ($response->failed()) { + return PublishResult::failed($response->json('message') ?? 'unexpected response from WordPress while uploading the image'); + } + + $mediaId = $response->json('id'); + + if (! $mediaId) { + return PublishResult::failed('unexpected response from WordPress: missing media id'); + } + + return $mediaId; + } } diff --git a/database/migrations/2026_07_21_195849_add_image_url_to_posts_table.php b/database/migrations/2026_07_21_195849_add_image_url_to_posts_table.php new file mode 100644 index 0000000..37d25da --- /dev/null +++ b/database/migrations/2026_07_21_195849_add_image_url_to_posts_table.php @@ -0,0 +1,28 @@ +string('image_url')->nullable()->after('target'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropColumn('image_url'); + }); + } +}; diff --git a/python_scripts/posts.py b/python_scripts/posts.py index 954b052..c62e889 100755 --- a/python_scripts/posts.py +++ b/python_scripts/posts.py @@ -6,10 +6,10 @@ Usage: python3 posts.py get POST_ID python3 posts.py create --site-id ID --type TYPE --body BODY [--social-account-id ID] [--title TITLE] [--target TARGET] - [--status STATUS] [--scheduled-at DATETIME] + [--image-url URL] [--status STATUS] [--scheduled-at DATETIME] python3 posts.py update POST_ID [--site-id ID] [--social-account-id ID] [--type TYPE] [--title TITLE] [--body BODY] [--target TARGET] - [--status STATUS] [--scheduled-at DATETIME] + [--image-url URL] [--status STATUS] [--scheduled-at DATETIME] [--published-url URL] python3 posts.py delete POST_ID @@ -47,6 +47,7 @@ def main() -> None: create_parser.add_argument("--social-account-id", type=int) create_parser.add_argument("--title") create_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") + create_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise") create_parser.add_argument("--status", choices=STATUSES) create_parser.add_argument("--scheduled-at", help="ISO 8601 datetime, e.g. 2026-07-20T09:00:00") @@ -58,6 +59,7 @@ def main() -> None: update_parser.add_argument("--title") update_parser.add_argument("--body") update_parser.add_argument("--target", help="Network-specific destination, e.g. a subreddit for Reddit posts") + update_parser.add_argument("--image-url", help="Image URL — required for WordPress (type=blog), optional otherwise") update_parser.add_argument("--status", choices=STATUSES) update_parser.add_argument("--scheduled-at", help="ISO 8601 datetime") update_parser.add_argument("--published-url") @@ -88,6 +90,7 @@ def main() -> None: "title": args.title, "body": args.body, "target": args.target, + "image_url": args.image_url, "status": args.status, "scheduled_at": args.scheduled_at, } @@ -102,6 +105,7 @@ def main() -> None: "title": args.title, "body": args.body, "target": args.target, + "image_url": args.image_url, "status": args.status, "scheduled_at": args.scheduled_at, "published_url": args.published_url, diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php index 2af717a..9c13f56 100644 --- a/resources/views/livewire/posts/index.blade.php +++ b/resources/views/livewire/posts/index.blade.php @@ -11,6 +11,7 @@
| Image | Site | Type | Title | @@ -23,6 +24,15 @@
|---|---|---|---|
|
+ @if ($post->image_url)
+ |
{{ $post->site->name }} | {{ ucfirst($post->type) }} | {{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }} | @@ -106,6 +116,14 @@ @error('target') {{ $message }} @enderror +