Skip to content
Open
11 changes: 6 additions & 5 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,11 @@ public function supportsAltText(): bool
* - LinkedIn UGC: 3000 (`commentary` field)
* - X standard tweet: 280 (X Premium accepts 25K — ignored, conservative)
* - TikTok caption: 2200
* - YouTube Shorts: title=100, description=5000. We feed `content` to both
* (publisher derives title from the first line via `buildTitle`), and
* Shorts UX only shows ~100 chars before "more" — capping at 100 keeps
* posts appropriate for the format.
* - YouTube Shorts: description=5000 (the cap here). The title never
* overflows: it comes from `meta.title` (validated at 100) or is
* derived from the first content line and truncated by `buildTitle`;
* the full content goes to the description unless `meta.description`
* overrides it.
* - Facebook text status: 10000 (API allows 63206; we cap below
* that — 63k-char posts are unrealistic and emoji-heavy content
* risks overflowing the TEXT column's 65535-byte ceiling)
Expand All @@ -186,7 +187,7 @@ public function maxContentLength(): int
self::LinkedIn, self::LinkedInPage => 3000,
self::X => 280,
self::TikTok => 2200,
self::YouTube => 100,
self::YouTube => 5000,
self::Facebook => 10000,
self::Instagram, self::InstagramFacebook => 2200,
self::Threads => 500,
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Auth/InstagramController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class InstagramController extends SocialController
'instagram_business_basic',
'instagram_business_content_publish',
'instagram_business_manage_insights',
// first comment after publish (FirstCommentPoster)
'instagram_business_manage_comments',
];

public function connect(Request $request): Response
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Auth/InstagramFacebookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class InstagramFacebookController extends MetaController
'instagram_basic',
'instagram_content_publish',
'instagram_manage_insights',
// first comment after publish (FirstCommentPoster)
'instagram_manage_comments',
];

public function connect(Request $request): Response
Expand Down
10 changes: 10 additions & 0 deletions app/Jobs/PublishToSocialPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use App\Services\Social\Telegram\TelegramPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
use App\Services\Social\FirstCommentPoster;
use App\Services\Social\XPublisher;
use App\Services\Social\YouTubePublisher;
use App\Support\Social\GoogleBusinessDerivativeCleaner;
Expand Down Expand Up @@ -126,6 +127,15 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);

$this->recordPublishResult($result);

// The post is live; a failed first comment must never fail it —
// FirstCommentPoster logs and swallows its own errors. Posts
// held in pending review (Google Business) get no comment.
if ($this->postPlatform->status === PostPlatformStatus::Published
&& ($externalId = (string) data_get($result, 'id')) !== '') {
app(FirstCommentPoster::class)->post($this->postPlatform, $externalId);
}

break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Any platform: content (per-platform caption override; empty = shared post text). Instagram/Facebook: location_id (Facebook place ID) + location_name. YouTube: title (≤100), tags (string array), category_id, default_language, recording_location {lat,lng,description}. Merged with existing meta.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
Expand Down
14 changes: 14 additions & 0 deletions app/Models/PostPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ protected function casts(): array
];
}

/**
* The text this platform row publishes: the per-platform `meta.content`
* override when the user provided one (a short copy for X next to a long
* Instagram caption, without splitting the post), otherwise the post's
* shared content. Publishers and publish-time length validation must read
* content through this method, never `post->content` directly.
*/
public function resolvedContent(): ?string
{
$override = trim((string) data_get($this->meta, 'content'));

return $override !== '' ? $override : $this->post?->content;
}

public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
Expand Down
4 changes: 2 additions & 2 deletions app/Services/Social/AbstractLinkedInPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);

$content = $postPlatform->post->content
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
$content = $postPlatform->resolvedContent()
? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: null;

$this->account = $postPlatform->socialAccount;
Expand Down
6 changes: 5 additions & 1 deletion app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);

$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$content = $postPlatform->resolvedContent() ? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform) : null;

$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
Expand Down Expand Up @@ -100,6 +100,10 @@ public function publish(PostPlatform $postPlatform): array
'$type' => BlueskyLexicon::EMBED_VIDEO,
'video' => $videoBlob,
];

if (($alt = $video->altTextFor(Platform::Bluesky)) !== null) {
$embed['alt'] = $alt;
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion app/Services/Social/Concerns/HasSocialHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ trait HasSocialHttpClient
*/
protected function validateContentLength(PostPlatform $postPlatform): void
{
$raw = $postPlatform->post->content ?? '';
$raw = $postPlatform->resolvedContent() ?? '';
$content = app(ContentSanitizer::class)->displayText($raw, $postPlatform->platform);

if ($postPlatform->platform->contentOverflow($content) === 0) {
Expand Down
4 changes: 2 additions & 2 deletions app/Services/Social/Discord/DiscordPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ public function publish(PostPlatform $postPlatform): array
// bot is in (including another workspace's).
$this->guardChannelBelongsToGuild($guildId, $channelId);

$content = $postPlatform->post->content
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
$content = $postPlatform->resolvedContent()
? app(ContentSanitizer::class)->sanitize($postPlatform->resolvedContent(), $postPlatform->platform)
: '';

$content = $this->appendMentions($content, $postPlatform);
Expand Down
35 changes: 28 additions & 7 deletions app/Services/Social/FacebookPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ class FacebookPublisher

private string $baseUrl;

/**
* Facebook place ID from the platform row's `meta.location_id` — tags feed
* and photo posts with a location. Stories do not accept it.
*/
private ?string $placeId = null;

public function __construct()
{
$this->baseUrl = config('trypost.platforms.facebook.graph_api');
Expand All @@ -53,6 +59,7 @@ public function publish(PostPlatform $postPlatform): array
$pageId = $account->platform_user_id;
$accessToken = $account->access_token;
$content = $this->sanitizedContent($postPlatform);
$this->placeId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;
$media = $postPlatform->post->mediaItems;
$contentType = $postPlatform->content_type;

Expand Down Expand Up @@ -149,24 +156,24 @@ private function postTextWithLink(string $pageId, string $accessToken, string $c
*/
private function postTextToFeed(string $pageId, string $accessToken, string $content, ?string $link, bool $reportFailure = true): Response
{
return $this->postToGraph("{$pageId}/feed", [
return $this->postToGraph("{$pageId}/feed", $this->withPlace([
'message' => $content,
'access_token' => $accessToken,
...$this->optionalField('link', $link),
], 'text post', $reportFailure);
]), 'text post', $reportFailure);
}

/**
* @return array{id: mixed, url: string}
*/
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, MediaItem $media, ?string $aspectRatio): array
{
$response = $this->postToGraph("{$pageId}/photos", [
$response = $this->postToGraph("{$pageId}/photos", $this->withPlace([
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'access_token' => $accessToken,
...$this->optionalField('message', $content),
...$this->altText($media),
], 'single image post');
]), 'single image post');

$data = $response->json();

Expand Down Expand Up @@ -196,13 +203,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
);
}

$response = $this->postToGraph("{$pageId}/feed", [
$response = $this->postToGraph("{$pageId}/feed", $this->withPlace([
'access_token' => $accessToken,
...$this->optionalField('message', $content),
...$photoIds
->mapWithKeys(fn (string $photoId, int $index): array => ["attached_media[{$index}]" => json_encode(['media_fbid' => $photoId])])
->all(),
], 'multi-image post');
]), 'multi-image post');

return $this->feedPostResult(data_get($response->json(), 'id'));
}
Expand Down Expand Up @@ -489,7 +496,7 @@ private function requireVideo(?MediaItem $media, string $format): MediaItem

private function sanitizedContent(PostPlatform $postPlatform): ?string
{
$content = $postPlatform->post->content;
$content = $postPlatform->resolvedContent();

return filled($content)
? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform)
Expand All @@ -505,6 +512,20 @@ private function facebookHttp(): PendingRequest
return $this->socialHttp()->asForm();
}


/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function withPlace(array $payload): array
{
if ($this->placeId !== null) {
$payload['place'] = $this->placeId;
}

return $payload;
}

/**
* POST a form payload to a Graph edge and turn any failure into the typed
* exception, logging the redacted body under `$label` first.
Expand Down
102 changes: 102 additions & 0 deletions app/Services/Social/FirstCommentPoster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace App\Services\Social;

use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Support\Facades\Log;

/**
* Posts the per-platform `meta.first_comment` under a just-published post.
* The classic "link in the first comment" move: YouTube descriptions bury
* links below the fold and Instagram captions don't render them at all.
*
* Runs after the post is already live, so it must never fail the publish —
* every failure is logged and swallowed.
*/
class FirstCommentPoster
{
use HasSocialHttpClient;

public function post(PostPlatform $postPlatform, string $externalId): void
{
$comment = trim((string) data_get($postPlatform->meta, 'first_comment'));

if ($comment === '') {
return;
}

try {
match ($postPlatform->platform) {
Platform::YouTube => $this->postYouTubeComment($postPlatform, $externalId, $comment),
Platform::Instagram, Platform::InstagramFacebook => $this->postInstagramComment($postPlatform, $externalId, $comment),
default => Log::warning('First comment is not supported for this platform', [
'platform' => $postPlatform->platform->value,
'post_platform_id' => $postPlatform->id,
]),
};
} catch (\Throwable $e) {
Log::warning('First comment failed (post itself is published)', [
'platform' => $postPlatform->platform->value,
'post_platform_id' => $postPlatform->id,
'error' => $e->getMessage(),
]);
}
}

/**
* commentThreads.insert needs the youtube.force-ssl scope — requested at
* connect time since the platform was added, so every account has it.
*/
private function postYouTubeComment(PostPlatform $postPlatform, string $videoId, string $comment): void
{
$account = $postPlatform->socialAccount;
$api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');

$response = $this->socialHttp()
->withToken($account->access_token)
->post("{$api}/commentThreads?part=snippet", [
'snippet' => [
'videoId' => $videoId,
'topLevelComment' => [
'snippet' => ['textOriginal' => $comment],
],
],
]);

if ($response->failed()) {
Log::warning('YouTube first comment failed', [
'post_platform_id' => $postPlatform->id,
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
}
}

/**
* Needs instagram_business_manage_comments (direct) / instagram_manage_comments
* (via Facebook Page) — accounts connected before the scope was requested
* get a 4xx here, which is logged and ignored; reconnecting fixes it.
*/
private function postInstagramComment(PostPlatform $postPlatform, string $mediaId, string $comment): void
{
$account = $postPlatform->socialAccount;
$baseUrl = $account->platform->instagramGraphBaseUrl();

$response = $this->socialHttp()->post("{$baseUrl}/{$mediaId}/comments", [
'message' => $comment,
'access_token' => $account->access_token,
]);

if ($response->failed()) {
Log::warning('Instagram first comment failed', [
'post_platform_id' => $postPlatform->id,
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
}
}
}
Loading
Loading