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
11 changes: 6 additions & 5 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,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 @@ -188,7 +189,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
8 changes: 8 additions & 0 deletions app/Jobs/PublishToSocialPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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\TikTokPhotoDerivativeCleaner;
Expand Down Expand Up @@ -125,6 +126,13 @@ public function handle(): void
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));

// The post is live; a failed first comment must never fail it —
// FirstCommentPoster logs and swallows its own errors.
if (($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 (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). 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 (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). 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 (required to publish) + flags. 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 (required to publish) + flags. 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 @@ -53,6 +53,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 @@ -61,8 +61,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
31 changes: 26 additions & 5 deletions app/Services/Social/FacebookPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class FacebookPublisher

private string $baseUrl;

/**
* Facebook place ID from the platform row's `meta.location_id` — tags feed
* and photo posts with a location (`place` param). Reels/stories don't
* accept it.
*/
private ?string $placeId = null;

public function __construct()
{
$this->baseUrl = config('trypost.platforms.facebook.graph_api');
Expand All @@ -42,7 +49,8 @@ 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;
$this->placeId = trim((string) data_get($postPlatform->meta, 'location_id')) ?: null;

$account = $postPlatform->socialAccount;
$pageId = $account->platform_user_id;
Expand Down Expand Up @@ -100,12 +108,25 @@ private function publishPost(string $pageId, string $accessToken, ?string $conte
);
}

/**
* @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;
}

private function publishTextPost(string $pageId, string $accessToken, string $content): array
{
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", [
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $this->withPlace([
'message' => $content,
'access_token' => $accessToken,
]);
]));

if ($response->failed()) {
Log::error('Facebook text post failed', [
Expand Down Expand Up @@ -141,7 +162,7 @@ private function publishSingleImagePost(string $pageId, string $accessToken, ?st
$payload['alt_text_custom'] = $alt;
}

$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", $this->withPlace($payload));

if ($response->failed()) {
Log::error('Facebook single image post failed', [
Expand Down Expand Up @@ -216,7 +237,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
$postData["attached_media[{$index}]"] = json_encode($media);
}

$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $postData);
$response = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/feed", $this->withPlace($postData));

if ($response->failed()) {
Log::error('Facebook multi-image post failed', [
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