feat: publishing hardening — content sanitization, validation, scopes, stuck recovery
- Add ContentSanitizer: strips HTML, converts bold/underline to Unicode for LinkedIn - Add backend content length validation in all 11 publishers via HasSocialHttpClient trait - Add scope verification before publishing — checks required scopes per platform - Add RecoverStuckPosts command (every 30min) — recovers posts stuck in publishing > 1h - Add requiredPublishScopes() to Platform enum - Update SocialAccount factory with correct scopes per platform state
This commit is contained in:
parent
8cbc372d91
commit
e034c0b572
22 changed files with 509 additions and 33 deletions
59
app/Console/Commands/RecoverStuckPosts.php
Normal file
59
app/Console/Commands/RecoverStuckPosts.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\Status as PlatformStatus;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RecoverStuckPosts extends Command
|
||||
{
|
||||
protected $signature = 'social:recover-stuck-posts';
|
||||
|
||||
protected $description = 'Recover posts stuck in publishing status for more than 1 hour';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
Post::query()
|
||||
->where('status', PostStatus::Publishing)
|
||||
->where('updated_at', '<=', now()->subHour())
|
||||
->each(function (Post $post) use (&$count) {
|
||||
// Mark stuck platforms as failed
|
||||
$post->postPlatforms()
|
||||
->where('enabled', true)
|
||||
->where('status', PlatformStatus::Publishing)
|
||||
->where('updated_at', '<=', now()->subHour())
|
||||
->update([
|
||||
'status' => PlatformStatus::Failed,
|
||||
'error_message' => 'Publishing timed out. Please try again.',
|
||||
'error_context' => json_encode([
|
||||
'category' => 'timeout',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
// Recalculate post status
|
||||
$enabledPlatforms = $post->postPlatforms()->where('enabled', true)->get();
|
||||
$total = $enabledPlatforms->count();
|
||||
$publishedCount = $enabledPlatforms->where('status', PlatformStatus::Published)->count();
|
||||
$failedCount = $enabledPlatforms->where('status', PlatformStatus::Failed)->count();
|
||||
|
||||
if ($publishedCount === $total) {
|
||||
$post->markAsPublished();
|
||||
} elseif ($publishedCount > 0) {
|
||||
$post->markAsPartiallyPublished();
|
||||
} else {
|
||||
$post->markAsFailed();
|
||||
}
|
||||
|
||||
$count++;
|
||||
});
|
||||
|
||||
$this->info("Recovered {$count} stuck posts.");
|
||||
}
|
||||
}
|
||||
|
|
@ -101,6 +101,26 @@ public function maxContentLength(): int
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function requiredPublishScopes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::Instagram => ['instagram_business_content_publish'],
|
||||
self::Facebook => ['pages_manage_posts'],
|
||||
self::TikTok => ['video.publish'],
|
||||
self::YouTube => ['https://www.googleapis.com/auth/youtube.upload'],
|
||||
self::LinkedIn => ['w_member_social'],
|
||||
self::LinkedInPage => ['w_organization_social'],
|
||||
self::X => ['tweet.write'],
|
||||
self::Threads => ['threads_content_publish'],
|
||||
self::Pinterest => ['pins:write'],
|
||||
self::Bluesky => [],
|
||||
self::Mastodon => ['write:statuses'],
|
||||
};
|
||||
}
|
||||
|
||||
public function supportsTextOnly(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,24 @@ public function handle(): void
|
|||
return;
|
||||
}
|
||||
|
||||
$requiredScopes = $this->postPlatform->platform->requiredPublishScopes();
|
||||
$accountScopes = $this->postPlatform->socialAccount->scopes ?? [];
|
||||
|
||||
if (! empty($requiredScopes)) {
|
||||
$missingScopes = array_diff($requiredScopes, $accountScopes);
|
||||
|
||||
if (! empty($missingScopes)) {
|
||||
$this->postPlatform->markAsFailed(
|
||||
'Missing permissions: '.implode(', ', $missingScopes).'. Please reconnect your account.',
|
||||
['category' => 'permission', 'missing_scopes' => $missingScopes, 'failed_at' => now()->toIso8601String()]
|
||||
);
|
||||
$this->updatePostStatus();
|
||||
$this->broadcastStatus();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->postPlatform->markAsPublishing();
|
||||
$this->broadcastStatus();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ class BlueskyPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
$service = $account->meta['service'] ?? 'https://bsky.social';
|
||||
|
||||
|
|
@ -57,7 +61,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
}
|
||||
|
||||
// Parse facets (links, mentions, hashtags) from text
|
||||
$text = $postPlatform->content ?? '';
|
||||
$text = $content ?? '';
|
||||
$facets = $this->parseFacets($text);
|
||||
|
||||
// Create post record
|
||||
|
|
|
|||
|
|
@ -4,11 +4,24 @@
|
|||
|
||||
namespace App\Services\Social\Concerns;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
trait HasSocialHttpClient
|
||||
{
|
||||
protected function validateContentLength(PostPlatform $postPlatform): void
|
||||
{
|
||||
$maxLength = $postPlatform->platform->maxContentLength();
|
||||
$contentLength = mb_strlen($postPlatform->content ?? '');
|
||||
|
||||
if ($contentLength > $maxLength) {
|
||||
throw new \Exception(
|
||||
"Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function socialHttp(): PendingRequest
|
||||
{
|
||||
return Http::retry(
|
||||
|
|
|
|||
91
app/Services/Social/ContentSanitizer.php
Normal file
91
app/Services/Social/ContentSanitizer.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
|
||||
class ContentSanitizer
|
||||
{
|
||||
public function sanitize(string $content, Platform $platform): string
|
||||
{
|
||||
return match ($platform) {
|
||||
Platform::LinkedIn, Platform::LinkedInPage => $this->convertBoldAndStrip($content),
|
||||
default => $this->stripHtml($content),
|
||||
};
|
||||
}
|
||||
|
||||
private function stripHtml(string $content): string
|
||||
{
|
||||
// Convert <p> tags to newlines
|
||||
$content = preg_replace('/<p[^>]*>/i', '', $content);
|
||||
$content = str_replace('</p>', "\n", $content);
|
||||
|
||||
// Convert <br> to newlines
|
||||
$content = preg_replace('/<br\s*\/?>/i', "\n", $content);
|
||||
|
||||
// Convert list items to dash prefix
|
||||
$content = preg_replace('/<li[^>]*>/i', '- ', $content);
|
||||
$content = str_replace('</li>', "\n", $content);
|
||||
|
||||
// Strip remaining HTML tags
|
||||
$content = strip_tags($content);
|
||||
|
||||
// Decode HTML entities
|
||||
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
|
||||
// Clean up excessive newlines (max 2 consecutive)
|
||||
$content = preg_replace("/\n{3,}/", "\n\n", $content);
|
||||
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function convertBoldAndStrip(string $content): string
|
||||
{
|
||||
// Convert <strong>/<b> to Unicode bold characters for LinkedIn
|
||||
$content = preg_replace_callback(
|
||||
'/<(?:strong|b)>(.*?)<\/(?:strong|b)>/si',
|
||||
fn ($matches) => $this->toUnicodeBold($matches[1]),
|
||||
$content
|
||||
);
|
||||
|
||||
// Convert <u> to Unicode underline
|
||||
$content = preg_replace_callback(
|
||||
'/<u>(.*?)<\/u>/si',
|
||||
fn ($matches) => $this->toUnicodeUnderline($matches[1]),
|
||||
$content
|
||||
);
|
||||
|
||||
return $this->stripHtml($content);
|
||||
}
|
||||
|
||||
private function toUnicodeBold(string $text): string
|
||||
{
|
||||
$map = [
|
||||
'a' => '𝗮', 'b' => '𝗯', 'c' => '𝗰', 'd' => '𝗱', 'e' => '𝗲',
|
||||
'f' => '𝗳', 'g' => '𝗴', 'h' => '𝗵', 'i' => '𝗶', 'j' => '𝗷',
|
||||
'k' => '𝗸', 'l' => '𝗹', 'm' => '𝗺', 'n' => '𝗻', 'o' => '𝗼',
|
||||
'p' => '𝗽', 'q' => '𝗾', 'r' => '𝗿', 's' => '𝘀', 't' => '𝘁',
|
||||
'u' => '𝘂', 'v' => '𝘃', 'w' => '𝘄', 'x' => '𝘅', 'y' => '𝘆', 'z' => '𝘇',
|
||||
'A' => '𝗔', 'B' => '𝗕', 'C' => '𝗖', 'D' => '𝗗', 'E' => '𝗘',
|
||||
'F' => '𝗙', 'G' => '𝗚', 'H' => '𝗛', 'I' => '𝗜', 'J' => '𝗝',
|
||||
'K' => '𝗞', 'L' => '𝗟', 'M' => '𝗠', 'N' => '𝗡', 'O' => '𝗢',
|
||||
'P' => '𝗣', 'Q' => '𝗤', 'R' => '𝗥', 'S' => '𝗦', 'T' => '𝗧',
|
||||
'U' => '𝗨', 'V' => '𝗩', 'W' => '𝗪', 'X' => '𝗫', 'Y' => '𝗬', 'Z' => '𝗭',
|
||||
'0' => '𝟬', '1' => '𝟭', '2' => '𝟮', '3' => '𝟯', '4' => '𝟰',
|
||||
'5' => '𝟱', '6' => '𝟲', '7' => '𝟳', '8' => '𝟴', '9' => '𝟵',
|
||||
];
|
||||
|
||||
return strtr($text, $map);
|
||||
}
|
||||
|
||||
private function toUnicodeUnderline(string $text): string
|
||||
{
|
||||
// Unicode combining underline character
|
||||
return implode('', array_map(
|
||||
fn ($char) => $char."\u{0332}",
|
||||
mb_str_split($text)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,10 @@ class FacebookPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
$pageId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
|
@ -28,9 +32,9 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$contentType = $postPlatform->content_type;
|
||||
|
||||
return match ($contentType) {
|
||||
ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $postPlatform->content, $media->first()),
|
||||
ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $media->first()),
|
||||
ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $media->first()),
|
||||
ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $postPlatform->content, $media),
|
||||
ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media),
|
||||
default => throw new \Exception("Unsupported Facebook content type: {$contentType?->value}"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class InstagramPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
|
|
@ -32,6 +34,8 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$instagramId = $account->platform_user_id;
|
||||
$accessToken = $account->access_token;
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$media = $postPlatform->media;
|
||||
|
||||
if ($media->isEmpty()) {
|
||||
|
|
@ -42,9 +46,9 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$contentType = $postPlatform->content_type;
|
||||
|
||||
return match ($contentType) {
|
||||
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $postPlatform->content, $firstMedia),
|
||||
ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia),
|
||||
ContentType::InstagramStory => $this->publishStory($instagramId, $accessToken, $firstMedia),
|
||||
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $postPlatform->content, $media),
|
||||
ContentType::InstagramFeed => $this->publishFeed($instagramId, $accessToken, $content, $media),
|
||||
default => throw new \Exception("Unsupported Instagram content type: {$contentType?->value}"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class LinkedInPagePublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$this->account = $postPlatform->socialAccount;
|
||||
$this->hasRetried = false;
|
||||
|
||||
|
|
@ -54,16 +58,16 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
try {
|
||||
return match ($contentType) {
|
||||
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
|
||||
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
|
||||
};
|
||||
} catch (TokenExpiredException $e) {
|
||||
return $this->retryWithRefresh($postPlatform, $e);
|
||||
return $this->retryWithRefresh($postPlatform, $content, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array
|
||||
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
|
||||
{
|
||||
if ($this->hasRetried) {
|
||||
throw $originalException;
|
||||
|
|
@ -81,8 +85,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept
|
|||
$contentType = $postPlatform->content_type;
|
||||
|
||||
return match ($contentType) {
|
||||
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $content, $postPlatform->media, $this->account),
|
||||
ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $content, $postPlatform->media, $this->account),
|
||||
default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class LinkedInPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$this->account = $postPlatform->socialAccount;
|
||||
$this->hasRetried = false;
|
||||
|
||||
|
|
@ -48,16 +52,16 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
try {
|
||||
return match ($contentType) {
|
||||
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media),
|
||||
ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media),
|
||||
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
|
||||
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
|
||||
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
|
||||
};
|
||||
} catch (TokenExpiredException $e) {
|
||||
return $this->retryWithRefresh($postPlatform, $e);
|
||||
return $this->retryWithRefresh($postPlatform, $content, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array
|
||||
private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array
|
||||
{
|
||||
if ($this->hasRetried) {
|
||||
throw $originalException;
|
||||
|
|
@ -74,8 +78,8 @@ private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredExcept
|
|||
$contentType = $postPlatform->content_type;
|
||||
|
||||
return match ($contentType) {
|
||||
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media),
|
||||
ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media),
|
||||
ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $content, $postPlatform->media),
|
||||
ContentType::LinkedInPost => $this->publishPost($personUrn, $content, $postPlatform->media),
|
||||
default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ class MastodonPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
$instance = $account->meta['instance'] ?? 'https://mastodon.social';
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ class PinterestPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ class ThreadsPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
|
|
@ -34,11 +38,11 @@ public function publish(PostPlatform $postPlatform): array
|
|||
|
||||
// Text only post
|
||||
if ($media->isEmpty()) {
|
||||
if (empty($postPlatform->content)) {
|
||||
if (empty($content)) {
|
||||
throw new \Exception('Threads text posts require content. Please add text to your post.');
|
||||
}
|
||||
|
||||
return $this->publishTextPost($userId, $accessToken, $postPlatform->content);
|
||||
return $this->publishTextPost($userId, $accessToken, $content);
|
||||
}
|
||||
|
||||
$firstMedia = $media->first();
|
||||
|
|
@ -47,14 +51,14 @@ public function publish(PostPlatform $postPlatform): array
|
|||
// Single media
|
||||
if ($media->count() === 1) {
|
||||
if ($isVideo) {
|
||||
return $this->publishVideoPost($userId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
return $this->publishVideoPost($userId, $accessToken, $content, $firstMedia);
|
||||
}
|
||||
|
||||
return $this->publishImagePost($userId, $accessToken, $postPlatform->content, $firstMedia);
|
||||
return $this->publishImagePost($userId, $accessToken, $content, $firstMedia);
|
||||
}
|
||||
|
||||
// Multiple media - carousel
|
||||
return $this->publishCarousel($userId, $accessToken, $postPlatform->content, $media);
|
||||
return $this->publishCarousel($userId, $accessToken, $content, $media);
|
||||
}
|
||||
|
||||
private function publishTextPost(string $userId, string $accessToken, string $content): array
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class TikTokPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
|
|
@ -44,11 +48,11 @@ public function publish(PostPlatform $postPlatform): array
|
|||
$isImage = $firstMedia->isImage();
|
||||
|
||||
if ($isVideo) {
|
||||
return $this->publishVideo($postPlatform, $firstMedia);
|
||||
return $this->publishVideo($postPlatform, $firstMedia, $content);
|
||||
}
|
||||
|
||||
if ($isImage) {
|
||||
return $this->publishPhotos($postPlatform, $media);
|
||||
return $this->publishPhotos($postPlatform, $media, $content);
|
||||
}
|
||||
|
||||
throw new \Exception('TikTok only supports video or image content.');
|
||||
|
|
@ -93,14 +97,14 @@ private function queryCreatorInfo(): array
|
|||
];
|
||||
}
|
||||
|
||||
private function publishVideo(PostPlatform $postPlatform, $media): array
|
||||
private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array
|
||||
{
|
||||
$creatorInfo = $this->queryCreatorInfo();
|
||||
|
||||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/video/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content ?? '',
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_duet' => false,
|
||||
'disable_comment' => false,
|
||||
|
|
@ -137,7 +141,7 @@ private function publishVideo(PostPlatform $postPlatform, $media): array
|
|||
];
|
||||
}
|
||||
|
||||
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): array
|
||||
private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array
|
||||
{
|
||||
$photoUrls = $mediaCollection
|
||||
->filter(fn ($m) => $m->isImage())
|
||||
|
|
@ -154,7 +158,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection): ar
|
|||
$response = $this->getHttpClient()
|
||||
->post("{$this->baseUrl}/post/publish/content/init/", [
|
||||
'post_info' => [
|
||||
'title' => $postPlatform->content ?? '',
|
||||
'title' => $content ?? '',
|
||||
'privacy_level' => data_get($creatorInfo, 'privacy_level'),
|
||||
'disable_comment' => false,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class XPublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
// Refresh token if expired or expiring soon
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ class YouTubePublisher
|
|||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$content = $postPlatform->content ? app(ContentSanitizer::class)->sanitize($postPlatform->content, $postPlatform->platform) : null;
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
|
||||
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
||||
|
|
@ -45,7 +49,7 @@ public function publish(PostPlatform $postPlatform): array
|
|||
throw new \Exception('YouTube Shorts only supports video content.');
|
||||
}
|
||||
|
||||
return $this->publishShort($postPlatform, $firstMedia, $account);
|
||||
return $this->publishShort($postPlatform, $firstMedia, $account, $content);
|
||||
}
|
||||
|
||||
private function createGoogleClient(SocialAccount $account): GoogleClient
|
||||
|
|
@ -73,14 +77,14 @@ private function createGoogleClient(SocialAccount $account): GoogleClient
|
|||
return $client;
|
||||
}
|
||||
|
||||
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account): array
|
||||
private function publishShort(PostPlatform $postPlatform, $media, SocialAccount $account, ?string $content): array
|
||||
{
|
||||
if (empty($postPlatform->content)) {
|
||||
if (empty($content)) {
|
||||
throw new \Exception('YouTube Shorts require a title. Please add text to your post.');
|
||||
}
|
||||
|
||||
$title = $this->buildTitle($postPlatform->content);
|
||||
$description = $postPlatform->content;
|
||||
$title = $this->buildTitle($content);
|
||||
$description = $content;
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
|
||||
$handle = null;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public function linkedin(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::LinkedIn,
|
||||
'scopes' => Platform::LinkedIn->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ public function linkedinPage(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::LinkedInPage,
|
||||
'scopes' => Platform::LinkedInPage->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +57,7 @@ public function x(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::X,
|
||||
'scopes' => Platform::X->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +65,7 @@ public function tiktok(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::TikTok,
|
||||
'scopes' => Platform::TikTok->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +73,7 @@ public function youtube(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::YouTube,
|
||||
'scopes' => Platform::YouTube->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ public function facebook(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Facebook,
|
||||
'scopes' => Platform::Facebook->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +89,7 @@ public function instagram(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Instagram,
|
||||
'scopes' => Platform::Instagram->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +97,7 @@ public function threads(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Threads,
|
||||
'scopes' => Platform::Threads->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +105,7 @@ public function pinterest(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Pinterest,
|
||||
'scopes' => Platform::Pinterest->requiredPublishScopes(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +113,7 @@ public function bluesky(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Bluesky,
|
||||
'scopes' => Platform::Bluesky->requiredPublishScopes(),
|
||||
'token_expires_at' => now()->addHours(2),
|
||||
'meta' => [
|
||||
'service' => 'https://bsky.social',
|
||||
|
|
@ -117,6 +127,7 @@ public function mastodon(): static
|
|||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Mastodon,
|
||||
'scopes' => Platform::Mastodon->requiredPublishScopes(),
|
||||
'token_expires_at' => null, // Mastodon tokens don't expire
|
||||
'meta' => [
|
||||
'instance' => 'https://mastodon.social',
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
|
||||
use App\Console\Commands\CheckSocialConnections;
|
||||
use App\Console\Commands\ProcessScheduledPosts;
|
||||
use App\Console\Commands\RecoverStuckPosts;
|
||||
use App\Console\Commands\RefreshExpiringTokens;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Schedule::command(ProcessScheduledPosts::class)->everyMinute();
|
||||
Schedule::command(CheckSocialConnections::class)->daily();
|
||||
Schedule::command(RefreshExpiringTokens::class)->hourly();
|
||||
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes();
|
||||
|
|
|
|||
106
tests/Feature/Commands/RecoverStuckPostsTest.php
Normal file
106
tests/Feature/Commands/RecoverStuckPostsTest.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\Status as PlatformStatus;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->socialAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
]);
|
||||
});
|
||||
|
||||
test('it recovers posts stuck in publishing for over 1 hour', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Publishing,
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$platform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'status' => PlatformStatus::Publishing,
|
||||
'enabled' => true,
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
|
||||
|
||||
$platform->refresh();
|
||||
$post->refresh();
|
||||
|
||||
expect($platform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($platform->error_message)->toBe('Publishing timed out. Please try again.');
|
||||
expect($post->status)->toBe(PostStatus::Failed);
|
||||
});
|
||||
|
||||
test('it does not touch posts publishing for less than 1 hour', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Publishing,
|
||||
'updated_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
$platform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'status' => PlatformStatus::Publishing,
|
||||
'enabled' => true,
|
||||
'updated_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
|
||||
|
||||
$platform->refresh();
|
||||
expect($platform->status)->toBe(PlatformStatus::Publishing);
|
||||
});
|
||||
|
||||
test('it marks post as partially published when some platforms succeeded', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Publishing,
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
// One succeeded
|
||||
PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'status' => PlatformStatus::Published,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
// One stuck
|
||||
$stuckPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Instagram,
|
||||
])->id,
|
||||
'status' => PlatformStatus::Publishing,
|
||||
'enabled' => true,
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$this->artisan('social:recover-stuck-posts')->assertSuccessful();
|
||||
|
||||
$stuckPlatform->refresh();
|
||||
$post->refresh();
|
||||
|
||||
expect($stuckPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($post->status)->toBe(PostStatus::PartiallyPublished);
|
||||
});
|
||||
|
|
@ -385,6 +385,21 @@
|
|||
expect($this->postPlatform->error_context['content_length'])->toBe(11);
|
||||
});
|
||||
|
||||
test('publish to social platform fails when scopes are missing', function () {
|
||||
Event::fake();
|
||||
|
||||
$this->socialAccount->update(['scopes' => ['user.info.basic']]); // missing w_member_social
|
||||
$this->postPlatform->refresh();
|
||||
|
||||
(new PublishToSocialPlatform($this->postPlatform))->handle();
|
||||
|
||||
$this->postPlatform->refresh();
|
||||
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
|
||||
expect($this->postPlatform->error_message)->toContain('Missing permissions');
|
||||
expect($this->postPlatform->error_context['category'])->toBe('permission');
|
||||
expect($this->postPlatform->error_context['missing_scopes'])->toContain('w_member_social');
|
||||
});
|
||||
|
||||
test('publish to social platform saves error context on token expired', function () {
|
||||
Event::fake();
|
||||
Mail::fake();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -15,6 +22,11 @@ public function makeRequest(string $url): Response
|
|||
{
|
||||
return $this->socialHttp()->get($url);
|
||||
}
|
||||
|
||||
public function callValidateContentLength(PostPlatform $postPlatform): void
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -66,3 +78,38 @@ public function makeRequest(string $url): Response
|
|||
expect($response->status())->toBe(200);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('validateContentLength passes when content is within limit', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
||||
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
|
||||
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $socialAccount->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'content_type' => ContentType::LinkedInPost,
|
||||
'content' => str_repeat('a', 100), // well within LinkedIn's 3000 limit
|
||||
]);
|
||||
|
||||
expect(fn () => $this->client->callValidateContentLength($postPlatform))->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('validateContentLength throws when content exceeds limit', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$socialAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]);
|
||||
$post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]);
|
||||
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $socialAccount->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
'content_type' => ContentType::LinkedInPost,
|
||||
'content' => str_repeat('a', 4000), // exceeds LinkedIn's 3000 limit
|
||||
]);
|
||||
|
||||
expect(fn () => $this->client->callValidateContentLength($postPlatform))
|
||||
->toThrow(Exception::class, '3000');
|
||||
});
|
||||
|
|
|
|||
56
tests/Unit/Services/Social/ContentSanitizerTest.php
Normal file
56
tests/Unit/Services/Social/ContentSanitizerTest.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Services\Social\ContentSanitizer;
|
||||
|
||||
test('it strips html tags for plain text platforms', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong></p>', Platform::Instagram);
|
||||
expect($result)->toBe('Hello world');
|
||||
});
|
||||
|
||||
test('it converts bold to unicode for linkedin', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<p>Hello <strong>world</strong></p>', Platform::LinkedIn);
|
||||
expect($result)->toContain('𝘄𝗼𝗿𝗹𝗱');
|
||||
});
|
||||
|
||||
test('it converts p tags to newlines', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<p>First paragraph</p><p>Second paragraph</p>', Platform::X);
|
||||
expect($result)->toBe("First paragraph\nSecond paragraph");
|
||||
});
|
||||
|
||||
test('it converts br to newlines', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('Line one<br>Line two', Platform::Facebook);
|
||||
expect($result)->toBe("Line one\nLine two");
|
||||
});
|
||||
|
||||
test('it decodes html entities', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('Tom & Jerry <3', Platform::X);
|
||||
expect($result)->toBe('Tom & Jerry <3');
|
||||
});
|
||||
|
||||
test('it returns plain text unchanged', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('Just plain text', Platform::Instagram);
|
||||
expect($result)->toBe('Just plain text');
|
||||
});
|
||||
|
||||
test('it handles null-safe empty content', function () {
|
||||
// The publisher handles null check, sanitizer should handle empty string
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('', Platform::X);
|
||||
expect($result)->toBe('');
|
||||
});
|
||||
|
||||
test('it converts list items to dashes', function () {
|
||||
$sanitizer = new ContentSanitizer;
|
||||
$result = $sanitizer->sanitize('<ul><li>Item one</li><li>Item two</li></ul>', Platform::Facebook);
|
||||
expect($result)->toContain('- Item one');
|
||||
expect($result)->toContain('- Item two');
|
||||
});
|
||||
Loading…
Reference in a new issue