trypost/app/Services/Social/BlueskyPublisher.php
Paulo Castellano 1660084227 refactor(social): use config for OAuth hosts everywhere
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.

Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
2026-05-19 08:34:35 -03:00

329 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BlueskyPublisher
{
use HasSocialHttpClient;
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
$account = $postPlatform->socialAccount;
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
// Refresh token if needed
if ($account->is_token_expired || $account->is_token_expiring_soon) {
$this->refreshTokenWithLock($account, fn () => $this->refreshToken($account));
$account->refresh();
}
$medias = $postPlatform->post->mediaItems;
$embed = null;
// Upload images if present (max 4)
if ($medias->count() > 0) {
$images = [];
foreach ($medias->take(4) as $media) {
if ($media->isImage()) {
$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 = $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;
}
$response = $this->socialHttp()->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' => $this->redactResponseBody($response->body()),
]);
$this->handleApiError($response);
}
$data = $response->json();
// Extract post ID from URI (at://did/app.bsky.feed.post/xxx)
$uri = data_get($data, 'uri');
$postId = basename($uri);
return [
'id' => $postId,
'url' => $this->buildPostUrl($account->username, $postId),
];
}
private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array
{
$tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_');
try {
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);
if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
}
$fileSize = filesize($tempFile);
if ($fileSize === false || $fileSize === 0) {
Log::error('Bluesky failed to download media', ['url' => $url]);
return null;
}
// Optimize images for Bluesky's 1MB limit
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky);
@unlink($tempFile);
$tempFile = $optimizedPath;
$mimeType = 'image/jpeg';
}
$stream = fopen($tempFile, 'r');
$response = $this->socialHttp()->withToken($account->access_token)
->withHeaders(['Content-Type' => $mimeType])
->withBody($stream, $mimeType)
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
if (is_resource($stream)) {
fclose($stream);
}
if ($response->failed()) {
Log::error('Bluesky blob upload failed', [
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
return null;
}
return $response->json()['blob'];
} catch (\Exception $e) {
Log::error('Bluesky blob upload exception', [
'error' => $e->getMessage(),
'url' => $url,
]);
return null;
} finally {
@unlink($tempFile);
}
}
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'] ?? config('trypost.platforms.bluesky.default_service');
// Try refresh first
$response = $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
]);
// 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_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
return;
}
} catch (\Exception $e) {
Log::error('Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
}
}
throw new TokenExpiredException('Bluesky session expired');
}
private function handleApiError(Response $response): never
{
throw BlueskyPublishException::fromApiResponse($response);
}
}