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
10 changes: 10 additions & 0 deletions app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,14 @@ public function supportsAltText(): bool
return $this->platform()->supportsAltText();
}

/**
* Whether the editor offers the Alt Text tab for videos of this type.
*/
public function supportsVideoAltText(): bool
{
return $this->platform()->supportsVideoAltText();
}

/**
* Whether the editor offers Tag People (Instagram posts only, not stories or reels).
*/
Expand Down Expand Up @@ -562,6 +570,7 @@ public function mediaRules(): array
'auto_fits_image' => $this->autoFitsImage(),
'crop_presets' => $this->cropPresets(),
'supports_alt_text' => $this->supportsAltText(),
'supports_video_alt_text' => $this->supportsVideoAltText(),
'supports_user_tags' => $this->supportsUserTags(),
'supports_video_cover' => $this->supportsVideoCover(),
'platform_label' => $this->platform()->label(),
Expand Down Expand Up @@ -643,6 +652,7 @@ public function toListingArray(): array
'image_max_width',
'image_max_height',
'supports_alt_text',
'supports_video_alt_text',
'supports_user_tags',
'supports_video_cover',
])),
Expand Down
13 changes: 13 additions & 0 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ public function supportsAltText(): bool
return $this->altTextMaxLength() !== null;
}

/**
* Whether the platform's API accepts an accessibility description on
* published VIDEO media too — Mastodon takes it as the media
* `description`, Bluesky as the video embed's `alt`.
*/
public function supportsVideoAltText(): bool
{
return match ($this) {
self::Bluesky, self::Mastodon => true,
default => false,
};
}

/**
* Hard cap (in characters) the platform's API will accept. Going over this
* means the post can't be published. Values are the documented API maxes:
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 @@ -24,6 +24,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 @@ -44,6 +44,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 @@ -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\GoogleBusinessDerivativeCleaner;
Expand Down Expand Up @@ -130,6 +131,15 @@ public function handle(): void
$result = $publisher->publish($this->post);

$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->post->publish_status === PublishStatus::Published
&& ($externalId = (string) data_get($result, 'id')) !== '') {
app(FirstCommentPoster::class)->post($this->post, $externalId);
}

break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
Expand Down
14 changes: 12 additions & 2 deletions app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,20 @@ private function mediaEmbed(SocialAccount $account, string $service, Collection
);
}

return $videoBlob ? [
if (! $videoBlob) {
return null;
}

$embed = [
'$type' => BlueskyLexicon::EMBED_VIDEO,
'video' => $videoBlob,
] : null;
];

if (($alt = $video->altTextFor(Platform::Bluesky)) !== null) {
$embed['alt'] = $alt;
}

return $embed;
}

/**
Expand Down
33 changes: 27 additions & 6 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(Post $post): array
$pageId = $account->platform_user_id;
$accessToken = $account->access_token;
$content = $this->sanitizedContent($post);
$this->placeId = trim((string) data_get($post->meta, 'location_id')) ?: null;
$media = $post->mediaItems;
$contentType = $post->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): array
{
$response = $this->postToGraph("{$pageId}/photos", [
$response = $this->postToGraph("{$pageId}/photos", $this->withPlace([
'url' => $media->url,
'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 @@ -572,6 +579,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\Post;
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(Post $post, string $externalId): void
{
$comment = trim((string) data_get($post->meta, 'first_comment'));

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

try {
match ($post->platform) {
Platform::YouTube => $this->postYouTubeComment($post, $externalId, $comment),
Platform::Instagram, Platform::InstagramFacebook => $this->postInstagramComment($post, $externalId, $comment),
default => Log::warning('First comment is not supported for this platform', [
'platform' => $post->platform->value,
'post_id' => $post->id,
]),
};
} catch (\Throwable $e) {
Log::warning('First comment failed (post itself is published)', [
'platform' => $post->platform->value,
'post_id' => $post->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(Post $post, string $videoId, string $comment): void
{
$account = $post->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_id' => $post->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(Post $post, string $mediaId, string $comment): void
{
$account = $post->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_id' => $post->id,
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
}
}
}
31 changes: 26 additions & 5 deletions app/Services/Social/InstagramPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,21 @@ class InstagramPublisher

private const string WORKFLOW_FINAL_CONTAINER = 'final_container';

/**
* Facebook place ID from the post's `meta.location_id` — tags the post
* with a location. Applied to feed/reel/carousel containers; the API
* does not accept it on stories or carousel children.
*/
private ?string $locationId = null;

public function publish(Post $post): array
{
$this->post = $post;
$this->validateContentLength($post);

$account = $post->socialAccount;
$this->baseUrl = $account->platform->instagramGraphBaseUrl();
$this->locationId = trim((string) data_get($post->meta, 'location_id')) ?: null;

if ($account->needsProactiveTokenRefresh()) {
app(ConnectionVerifier::class)->refreshToken($account);
Expand Down Expand Up @@ -101,6 +109,19 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
return $this->publishSingleImage($instagramId, $accessToken, $content, $firstMedia);
}

/**
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private function withLocation(array $params): array
{
if ($this->locationId !== null) {
$params['location_id'] = $this->locationId;
}

return $params;
}

private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media): array
{
$params = [
Expand All @@ -117,22 +138,22 @@ private function publishSingleImage(string $instagramId, string $accessToken, ?s

$params = [...$params, ...$this->userTagsParam($media->userTags()), ...$this->sharedOptions()];

$containerId = $this->createContainer($instagramId, $params, 'container');
$containerId = $this->createContainer($instagramId, $this->withLocation($params), 'container');

return $this->finishContainer($instagramId, $accessToken, $containerId);
}

private function publishReel(string $instagramId, string $accessToken, ?string $content, $media): array
{
$containerId = $this->createContainer($instagramId, [
$containerId = $this->createContainer($instagramId, $this->withLocation([
'video_url' => $media->url,
'caption' => $content,
'media_type' => 'REELS',
'access_token' => $accessToken,
...$this->thumbOffsetParam($media),
...$this->sharedOptions(),
...$this->reelOptions(),
], 'reel container');
]), 'reel container');

return $this->finishContainer($instagramId, $accessToken, $containerId);
}
Expand Down Expand Up @@ -296,13 +317,13 @@ private function finishCarousel(string $instagramId, string $accessToken, ?strin
$this->waitForMediaProcessing($childId, $accessToken, $workflow);
}

$carouselId = $this->createContainer($instagramId, [
$carouselId = $this->createContainer($instagramId, $this->withLocation([
'media_type' => 'CAROUSEL',
'caption' => $content,
'children' => implode(',', $childContainers),
'access_token' => $accessToken,
...$this->sharedOptions(),
], 'carousel container');
]), 'carousel container');

return $this->finishContainer($instagramId, $accessToken, $carouselId);
}
Expand Down
2 changes: 1 addition & 1 deletion app/Services/Social/MastodonPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ private function mediaPayload(SocialAccount $account, string $instance, Collecti
$mediaIds = [];

foreach ($media->take(4) as $item) {
$mediaIds[] = $this->uploadMedia($account, $instance, $item->url, $item->original_filename, $item->isImage() ? $item->altTextFor(Platform::Mastodon) : null);
$mediaIds[] = $this->uploadMedia($account, $instance, $item->url, $item->original_filename, $item->altTextFor(Platform::Mastodon));
}

return $mediaIds === [] ? [] : ['media_ids' => $mediaIds];
Expand Down
Loading
Loading