feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Post;
|
|
|
|
|
|
|
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Services\Social\ContentSanitizer;
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Renders per-platform previews of a post — applies the platform-specific
|
|
|
|
|
* `ContentSanitizer` rules without publishing. Used by the REST API
|
|
|
|
|
* preview endpoint and the MCP `PreviewPostTool` so the rendering rules
|
|
|
|
|
* stay in one place.
|
|
|
|
|
*/
|
|
|
|
|
class PostPreviewer
|
|
|
|
|
{
|
|
|
|
|
public function __construct(private readonly ContentSanitizer $sanitizer) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array{
|
|
|
|
|
* post_id: string,
|
|
|
|
|
* original_content: string,
|
|
|
|
|
* original_length: int,
|
|
|
|
|
* platforms: array<int, array{
|
|
|
|
|
* post_platform_id: string,
|
|
|
|
|
* platform: string,
|
|
|
|
|
* content_type: ?string,
|
|
|
|
|
* sanitized_content: string,
|
|
|
|
|
* sanitized_length: int,
|
|
|
|
|
* max_content_length: int,
|
|
|
|
|
* truncated: bool
|
|
|
|
|
* }>
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
public function forPost(Post $post): array
|
|
|
|
|
{
|
|
|
|
|
$original = (string) $post->content;
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'original_content' => $original,
|
|
|
|
|
'original_length' => mb_strlen($original),
|
|
|
|
|
'platforms' => $this->platformPreviews($post, $original)->all(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return Collection<int, array<string, mixed>>
|
|
|
|
|
*/
|
|
|
|
|
private function platformPreviews(Post $post, string $original): Collection
|
|
|
|
|
{
|
|
|
|
|
return $post->postPlatforms
|
|
|
|
|
->where('enabled', true)
|
|
|
|
|
->values()
|
|
|
|
|
->map(function (PostPlatform $pp) use ($original) {
|
|
|
|
|
$platform = $pp->socialAccount?->platform ?? $pp->platform;
|
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
|
|
|
$sanitized = $this->sanitizer->sanitize($original, $platform);
|
feat: complete create + publish post flow via MCP and REST API
Lets ChatGPT (MCP) and external clients (REST API) drive the full lifecycle of
a post — create with platform selection, attach media from URLs, schedule or
publish immediately, and fetch engagement metrics — without touching the web UI.
MCP tools added: UpdatePostTool, PublishPostTool, AttachMediaFromUrlTool,
ListContentTypesTool, GetPostMetricsTool, PreviewPostTool. CreatePostTool now
accepts platforms[] + scheduled_at + label_ids; ListPostsTool gains
status/search/limit filters.
REST endpoints added: POST /api/posts/{post}/media, GET /api/posts/{post}/metrics,
GET /api/posts/{post}/preview, GET /api/content-types.
Also fixes a silent CreatePost::execute bug — the action validated platforms[]
but ignored it, so REST callers never saw their selection persisted. Adds cross
validation rules (ContentTypeMatchesPlatform / ContentTypeMatchesPostPlatform)
so a LinkedIn account can't be saddled with x_post, and rejects inactive social
accounts during validation instead of failing silently downstream.
Shared services (PostMetricsFetcher, PostPreviewer, MediaAttacher) back both
MCP tools and REST controllers so behaviour stays aligned. New Resources
(PlatformContentTypesResource, PostMetricsResource, PostPreviewResource,
PostMediaAttachResource) keep controllers free of inline model mapping.
Suite: 1.332 passing, 0 failing — covers web (PostControllerTest), REST
(PostApiTest, PlatformApiTest, PostMediaApiTest), MCP (66 tool tests), and
the publish job (PublishToSocialPlatformTest).
Removes /docs from git tracking and TIKTOK_REVIEW_VIDEO_SCRIPT.md.
2026-05-04 11:12:28 +00:00
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'post_platform_id' => $pp->id,
|
|
|
|
|
'platform' => $platform->value,
|
|
|
|
|
'content_type' => $pp->content_type?->value,
|
|
|
|
|
'sanitized_content' => $sanitized,
|
|
|
|
|
'sanitized_length' => mb_strlen($sanitized),
|
|
|
|
|
'max_content_length' => $platform->maxContentLength(),
|
|
|
|
|
'truncated' => mb_strlen($sanitized) < mb_strlen($original),
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|