Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}]). YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'),
'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 Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description. 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, overrides the content-derived title), 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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'),
'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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description. 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, overrides the content-derived title), tags (string array), category_id, default_language, recording_location {lat,lng,description}.'),
]))
->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 @@ -60,6 +60,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