From b8c4b704ada2751cda35ca19e3b062df4682cbec Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 18 Jan 2026 13:33:54 -0300 Subject: [PATCH] feat: Add Bluesky social media integration - Add Bluesky platform to Platform and ContentType enums - Create BlueskyController with custom auth flow (not OAuth) - Create BlueskyPublisher service for posting via AT Protocol - Add BlueskyConnect.vue page with handle/app password form - Add BlueskyPreview.vue component for post preview - Register Bluesky in PublishToSocialPlatform job - Update Edit.vue with Bluesky logo and content type options - Add Bluesky config toggle in trypost.php Co-Authored-By: Claude Opus 4.5 --- app/Enums/PostPlatform/ContentType.php | 10 + app/Enums/SocialAccount/Platform.php | 7 + .../Controllers/Auth/BlueskyController.php | 134 +++++++ app/Jobs/PublishToSocialPlatform.php | 4 +- app/Services/Social/BlueskyPublisher.php | 327 ++++++++++++++++++ config/trypost.php | 3 + .../posts/previews/BlueskyPreview.vue | 191 ++++++++++ .../posts/previews/PlatformPreview.vue | 3 + .../js/components/posts/previews/index.ts | 1 + .../js/pages/accounts/BlueskyConnect.vue | 100 ++++++ resources/js/pages/posts/Edit.vue | 6 + routes/web.php | 4 + 12 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Auth/BlueskyController.php create mode 100644 app/Services/Social/BlueskyPublisher.php create mode 100644 resources/js/components/posts/previews/BlueskyPreview.vue create mode 100644 resources/js/pages/accounts/BlueskyConnect.vue diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 18938818..541ded1a 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -41,6 +41,9 @@ enum ContentType: string case PinterestVideoPin = 'pinterest_video_pin'; case PinterestCarousel = 'pinterest_carousel'; + // Bluesky + case BlueskyPost = 'bluesky_post'; + public function label(): string { return match ($this) { @@ -59,6 +62,7 @@ public function label(): string self::PinterestPin => 'Pin', self::PinterestVideoPin => 'Video Pin', self::PinterestCarousel => 'Carousel', + self::BlueskyPost => 'Post', }; } @@ -80,6 +84,7 @@ public function description(): string self::PinterestPin => 'Standard image pin', self::PinterestVideoPin => 'Video pin (4s - 15min)', self::PinterestCarousel => 'Multi-image carousel (2-5 images)', + self::BlueskyPost => 'Text post with optional images', }; } @@ -95,6 +100,7 @@ public function platform(): SocialPlatform self::XPost => SocialPlatform::X, self::ThreadsPost => SocialPlatform::Threads, self::PinterestPin, self::PinterestVideoPin, self::PinterestCarousel => SocialPlatform::Pinterest, + self::BlueskyPost => SocialPlatform::Bluesky, }; } @@ -126,6 +132,7 @@ public function maxMediaCount(): int self::ThreadsPost => 10, self::PinterestPin, self::PinterestVideoPin => 1, self::PinterestCarousel => 5, + self::BlueskyPost => 4, }; } @@ -142,6 +149,7 @@ public function supportsVideo(): bool self::ThreadsPost => true, self::PinterestVideoPin => true, self::PinterestPin, self::PinterestCarousel => false, + self::BlueskyPost => true, }; } @@ -162,6 +170,7 @@ public function requiresMedia(): bool self::LinkedInPost, self::LinkedInPagePost => false, self::XPost => false, self::ThreadsPost => false, + self::BlueskyPost => false, default => true, }; } @@ -194,6 +203,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::X => self::XPost, SocialPlatform::Threads => self::ThreadsPost, SocialPlatform::Pinterest => self::PinterestPin, + SocialPlatform::Bluesky => self::BlueskyPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 0f5b9f44..a09da676 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -15,6 +15,7 @@ enum Platform: string case Instagram = 'instagram'; case Threads = 'threads'; case Pinterest = 'pinterest'; + case Bluesky = 'bluesky'; public function label(): string { @@ -28,6 +29,7 @@ public function label(): string self::Instagram => 'Instagram', self::Threads => 'Threads', self::Pinterest => 'Pinterest', + self::Bluesky => 'Bluesky', }; } @@ -42,6 +44,7 @@ public function color(): string self::Instagram => '#E4405F', self::Threads => '#000000', self::Pinterest => '#E60023', + self::Bluesky => '#0085FF', }; } @@ -56,6 +59,7 @@ public function allowedMediaTypes(): array self::Instagram => [MediaType::Image, MediaType::Video], self::Threads => [MediaType::Image, MediaType::Video], self::Pinterest => [MediaType::Image, MediaType::Video], + self::Bluesky => [MediaType::Image, MediaType::Video], }; } @@ -70,6 +74,7 @@ public function maxImages(): int self::Instagram => 10, self::Threads => 10, self::Pinterest => 5, + self::Bluesky => 4, }; } @@ -84,6 +89,7 @@ public function maxContentLength(): int self::Instagram => 2200, self::Threads => 500, self::Pinterest => 800, + self::Bluesky => 300, }; } @@ -98,6 +104,7 @@ public function supportsTextOnly(): bool self::Instagram => false, self::Threads => true, self::Pinterest => false, + self::Bluesky => true, }; } diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php new file mode 100644 index 00000000..68d03180 --- /dev/null +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -0,0 +1,134 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return Inertia::render('accounts/BlueskyConnect', [ + 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], + ]); + } + + public function store(Request $request): View|RedirectResponse + { + $this->ensurePlatformEnabled(); + + $request->validate([ + 'identifier' => 'required|string', + 'password' => 'required|string|min:3', + ]); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + $service = 'https://bsky.social'; + + try { + // Authenticate with Bluesky + $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $request->identifier, + 'password' => $request->password, + ]); + + if ($response->failed()) { + Log::error('Bluesky authentication failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + $errorMessage = 'Invalid credentials'; + $body = $response->json(); + if (isset($body['message'])) { + $errorMessage = $body['message']; + } + + return back()->withErrors(['password' => $errorMessage]); + } + + $data = $response->json(); + + // Get profile + $profileResponse = Http::withToken($data['accessJwt']) + ->get("{$service}/xrpc/app.bsky.actor.getProfile", [ + 'actor' => $data['did'], + ]); + + $profile = $profileResponse->successful() ? $profileResponse->json() : []; + + // Check existing + $existingAccount = $workspace->socialAccounts() + ->where('platform', $this->platform->value) + ->first(); + + if ($existingAccount && ! $existingAccount->isDisconnected()) { + return back()->withErrors(['identifier' => 'Bluesky is already connected.']); + } + + $avatarPath = isset($profile['avatar']) ? uploadFromUrl($profile['avatar']) : null; + + $accountData = [ + 'platform' => $this->platform->value, + 'platform_user_id' => $data['did'], + 'username' => $data['handle'], + 'display_name' => $profile['displayName'] ?? $data['handle'], + 'avatar_url' => $avatarPath, + 'access_token' => $data['accessJwt'], + 'refresh_token' => $data['refreshJwt'], + 'token_expires_at' => now()->addHours(2), + 'meta' => [ + 'service' => $service, + 'identifier' => $request->identifier, + 'password' => encrypt($request->password), + ], + ]; + + if ($existingAccount) { + $existingAccount->update($accountData); + $existingAccount->markAsConnected(); + + return $this->popupCallback(true, 'Bluesky account reconnected!', $this->platform->value); + } + + $accountData['status'] = Status::Connected; + $workspace->socialAccounts()->create($accountData); + + return $this->popupCallback(true, 'Bluesky account connected!', $this->platform->value); + } catch (\Exception $e) { + Log::error('Bluesky connection error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return back()->withErrors(['password' => 'Error connecting to Bluesky. Please try again.']); + } + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 6f0aab5d..c8027a7b 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -6,6 +6,7 @@ use App\Events\PostPlatformStatusUpdated; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; +use App\Services\Social\BlueskyPublisher; use App\Services\Social\FacebookPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; @@ -71,7 +72,7 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -83,6 +84,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Instagram => app(InstagramPublisher::class), SocialPlatform::Threads => app(ThreadsPublisher::class), SocialPlatform::Pinterest => app(PinterestPublisher::class), + SocialPlatform::Bluesky => app(BlueskyPublisher::class), }; } diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php new file mode 100644 index 00000000..923fc1dc --- /dev/null +++ b/app/Services/Social/BlueskyPublisher.php @@ -0,0 +1,327 @@ +socialAccount; + $service = $account->meta['service'] ?? 'https://bsky.social'; + + // Refresh token if needed + if ($account->is_token_expired || $account->is_token_expiring_soon) { + $this->refreshToken($account); + $account->refresh(); + } + + $medias = $postPlatform->media; + $embed = null; + + // Upload images if present (max 4) + if ($medias->count() > 0) { + $images = []; + foreach ($medias->take(4) as $media) { + if (str_starts_with($media->mime_type, 'image/')) { + $blob = $this->uploadBlob($account, $service, $media->url, $media->mime_type); + if ($blob) { + $images[] = [ + 'alt' => '', + 'image' => $blob, + ]; + } + } + } + + if (count($images) > 0) { + $embed = [ + '$type' => 'app.bsky.embed.images', + 'images' => $images, + ]; + } + } + + // Parse facets (links, mentions, hashtags) from text + $text = $postPlatform->content ?? ''; + $facets = $this->parseFacets($text); + + // Create post record + $record = [ + '$type' => 'app.bsky.feed.post', + 'text' => $text, + 'createdAt' => now()->toIso8601ZuluString(), + ]; + + if ($embed) { + $record['embed'] = $embed; + } + + if (! empty($facets)) { + $record['facets'] = $facets; + } + + Log::info('Bluesky publishing post', [ + 'user_id' => $account->platform_user_id, + 'has_embed' => $embed !== null, + 'facet_count' => count($facets), + ]); + + $response = Http::withToken($account->access_token) + ->post("{$service}/xrpc/com.atproto.repo.createRecord", [ + 'repo' => $account->platform_user_id, + 'collection' => 'app.bsky.feed.post', + 'record' => $record, + ]); + + if ($response->failed()) { + Log::error('Bluesky post failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + $this->handleApiError($response); + } + + $data = $response->json(); + + // Extract post ID from URI (at://did/app.bsky.feed.post/xxx) + $uri = $data['uri']; + $postId = basename($uri); + + Log::info('Bluesky post created successfully', [ + 'uri' => $uri, + 'post_id' => $postId, + ]); + + return [ + 'id' => $postId, + 'url' => $this->buildPostUrl($account->username, $postId), + ]; + } + + private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array + { + try { + $imageContent = file_get_contents($url); + + if ($imageContent === false) { + Log::error('Bluesky failed to read image', ['url' => $url]); + + return null; + } + + // Bluesky has 1MB limit for images + if (strlen($imageContent) > 1000000) { + Log::warning('Bluesky image exceeds 1MB limit', [ + 'size' => strlen($imageContent), + 'url' => $url, + ]); + // TODO: Resize image if needed + } + + $response = Http::withToken($account->access_token) + ->withHeaders(['Content-Type' => $mimeType]) + ->withBody($imageContent, $mimeType) + ->post("{$service}/xrpc/com.atproto.repo.uploadBlob"); + + if ($response->failed()) { + Log::error('Bluesky blob upload failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return null; + } + + return $response->json()['blob']; + } catch (\Exception $e) { + Log::error('Bluesky blob upload exception', [ + 'error' => $e->getMessage(), + 'url' => $url, + ]); + + return null; + } + } + + private function parseFacets(string $text): array + { + $facets = []; + + // Parse URLs + preg_match_all( + '/(https?:\/\/[^\s]+)/u', + $text, + $urlMatches, + PREG_OFFSET_CAPTURE + ); + + foreach ($urlMatches[0] as $match) { + $url = $match[0]; + $start = $this->getUtf8ByteOffset($text, $match[1]); + $end = $start + strlen($url); + + $facets[] = [ + 'index' => [ + 'byteStart' => $start, + 'byteEnd' => $end, + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#link', + 'uri' => $url, + ], + ], + ]; + } + + // Parse mentions (@handle.bsky.social) + preg_match_all( + '/@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/u', + $text, + $mentionMatches, + PREG_OFFSET_CAPTURE + ); + + foreach ($mentionMatches[0] as $match) { + $mention = $match[0]; + $handle = substr($mention, 1); // Remove @ + $start = $this->getUtf8ByteOffset($text, $match[1]); + $end = $start + strlen($mention); + + $facets[] = [ + 'index' => [ + 'byteStart' => $start, + 'byteEnd' => $end, + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#mention', + 'did' => $handle, // Will be resolved by Bluesky + ], + ], + ]; + } + + // Parse hashtags (#tag) + preg_match_all( + '/#[^\s\p{P}]+/u', + $text, + $hashtagMatches, + PREG_OFFSET_CAPTURE + ); + + foreach ($hashtagMatches[0] as $match) { + $hashtag = $match[0]; + $tag = substr($hashtag, 1); // Remove # + $start = $this->getUtf8ByteOffset($text, $match[1]); + $end = $start + strlen($hashtag); + + $facets[] = [ + 'index' => [ + 'byteStart' => $start, + 'byteEnd' => $end, + ], + 'features' => [ + [ + '$type' => 'app.bsky.richtext.facet#tag', + 'tag' => $tag, + ], + ], + ]; + } + + return $facets; + } + + private function getUtf8ByteOffset(string $text, int $charOffset): int + { + return strlen(substr($text, 0, $charOffset)); + } + + private function buildPostUrl(string $handle, string $postId): string + { + return "https://bsky.app/profile/{$handle}/post/{$postId}"; + } + + public function refreshToken(SocialAccount $account): void + { + $service = $account->meta['service'] ?? 'https://bsky.social'; + + Log::info('Bluesky refreshing token', ['user_id' => $account->platform_user_id]); + + // Try refresh first + $response = Http::withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + + if ($response->successful()) { + $data = $response->json(); + $account->update([ + 'access_token' => $data['accessJwt'], + 'refresh_token' => $data['refreshJwt'], + 'token_expires_at' => now()->addHours(2), + ]); + + Log::info('Bluesky token refreshed via refresh token'); + + return; + } + + Log::warning('Bluesky refresh token failed, trying re-authentication', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + // If refresh fails, re-authenticate with stored credentials + if (isset($account->meta['password'])) { + try { + $password = decrypt($account->meta['password']); + $identifier = $account->meta['identifier']; + + $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $identifier, + 'password' => $password, + ]); + + if ($response->successful()) { + $data = $response->json(); + $account->update([ + 'access_token' => $data['accessJwt'], + 'refresh_token' => $data['refreshJwt'], + 'token_expires_at' => now()->addHours(2), + ]); + + Log::info('Bluesky token refreshed via re-authentication'); + + return; + } + } catch (\Exception $e) { + Log::error('Bluesky re-authentication failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + throw new TokenExpiredException('Bluesky session expired'); + } + + private function handleApiError(Response $response): void + { + $body = $response->json() ?? []; + $error = $body['error'] ?? 'Unknown error'; + $message = $body['message'] ?? $response->body(); + + if ($error === 'ExpiredToken' || $error === 'InvalidToken') { + throw new TokenExpiredException("Bluesky: {$message}"); + } + + throw new \Exception("Bluesky API error: {$message}"); + } +} diff --git a/config/trypost.php b/config/trypost.php index 8aee8d69..75af179a 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -41,6 +41,9 @@ 'pinterest' => [ 'enabled' => env('TRYPOST_PINTEREST_ENABLED', true), ], + 'bluesky' => [ + 'enabled' => env('TRYPOST_BLUESKY_ENABLED', true), + ], ], ]; diff --git a/resources/js/components/posts/previews/BlueskyPreview.vue b/resources/js/components/posts/previews/BlueskyPreview.vue new file mode 100644 index 00000000..f0e8e878 --- /dev/null +++ b/resources/js/components/posts/previews/BlueskyPreview.vue @@ -0,0 +1,191 @@ + + +