Skip to content
Merged
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
5 changes: 3 additions & 2 deletions app/Http/Controllers/Auth/BlueskyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Services\Social\BlueskyLexicon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -57,7 +58,7 @@ public function store(Request $request): View|RedirectResponse

try {
// Authenticate with Bluesky
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
$response = Http::post("{$service}/xrpc/".BlueskyLexicon::CREATE_SESSION, [
'identifier' => $request->identifier,
'password' => $request->password,
]);
Expand All @@ -81,7 +82,7 @@ public function store(Request $request): View|RedirectResponse

// Get profile
$profileResponse = Http::withToken(data_get($data, 'accessJwt'))
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
->get("{$service}/xrpc/".BlueskyLexicon::GET_PROFILE, [
'actor' => data_get($data, 'did'),
]);

Expand Down
4 changes: 2 additions & 2 deletions app/Services/Social/BlueskyAnalytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array
}

$did = $account->platform_user_id;
$atUri = "at://{$did}/app.bsky.feed.post/{$postPlatform->platform_post_id}";
$atUri = "at://{$did}/".BlueskyLexicon::FEED_POST."/{$postPlatform->platform_post_id}";

// Read counts (likes, reposts, replies, quotes) live on the AT
// Protocol AppView, not the PDS. The user's PDS requires Bearer auth
// for this endpoint; the public AppView does not.
$appView = (string) config('trypost.platforms.bluesky.public_appview');

$response = $this->socialHttp()
->get("{$appView}/xrpc/app.bsky.feed.getPosts", [
->get("{$appView}/xrpc/".BlueskyLexicon::GET_POSTS, [
'uris' => [$atUri],
]);

Expand Down
37 changes: 37 additions & 0 deletions app/Services/Social/BlueskyLexicon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Services\Social;

/**
* AT Protocol / Bluesky lexicon identifiers (NSIDs) used across the Bluesky
* services. Centralized so a typo surfaces as an undefined-constant error
* instead of a silent runtime "Invalid request" from the API.
*/
final class BlueskyLexicon
{
public const RESOLVE_HANDLE = 'com.atproto.identity.resolveHandle';

public const CREATE_RECORD = 'com.atproto.repo.createRecord';

public const UPLOAD_BLOB = 'com.atproto.repo.uploadBlob';

public const CREATE_SESSION = 'com.atproto.server.createSession';

public const REFRESH_SESSION = 'com.atproto.server.refreshSession';

public const FEED_POST = 'app.bsky.feed.post';

public const GET_POSTS = 'app.bsky.feed.getPosts';

public const GET_PROFILE = 'app.bsky.actor.getProfile';

public const EMBED_IMAGES = 'app.bsky.embed.images';

public const FACET_LINK = 'app.bsky.richtext.facet#link';

public const FACET_MENTION = 'app.bsky.richtext.facet#mention';

public const FACET_TAG = 'app.bsky.richtext.facet#tag';
}
54 changes: 35 additions & 19 deletions app/Services/Social/BlueskyPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -53,7 +54,7 @@ public function publish(PostPlatform $postPlatform): array

if (count($images) > 0) {
$embed = [
'$type' => 'app.bsky.embed.images',
'$type' => BlueskyLexicon::EMBED_IMAGES,
'images' => $images,
];
}
Expand All @@ -65,7 +66,7 @@ public function publish(PostPlatform $postPlatform): array

// Create post record
$record = [
'$type' => 'app.bsky.feed.post',
'$type' => BlueskyLexicon::FEED_POST,
'text' => $text,
'createdAt' => now()->toIso8601ZuluString(),
];
Expand All @@ -79,9 +80,9 @@ public function publish(PostPlatform $postPlatform): array
}

$response = $this->socialHttp()->withToken($account->access_token)
->post("{$service}/xrpc/com.atproto.repo.createRecord", [
->post("{$service}/xrpc/".BlueskyLexicon::CREATE_RECORD, [
'repo' => $account->platform_user_id,
'collection' => 'app.bsky.feed.post',
'collection' => BlueskyLexicon::FEED_POST,
'record' => $record,
]);

Expand Down Expand Up @@ -114,7 +115,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url);

if ($downloadResponse->failed()) {
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
throw new Exception('Failed to download media: HTTP '.$downloadResponse->status());
}

$fileSize = filesize($tempFile);
Expand All @@ -139,7 +140,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
$response = $this->socialHttp()->withToken($account->access_token)
->withHeaders(['Content-Type' => $mimeType])
->withBody($stream, $mimeType)
->post("{$service}/xrpc/com.atproto.repo.uploadBlob");
->post("{$service}/xrpc/".BlueskyLexicon::UPLOAD_BLOB);

if (is_resource($stream)) {
fclose($stream);
Expand All @@ -154,8 +155,8 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
return null;
}

return $response->json()['blob'];
} catch (\Exception $e) {
return data_get($response->json(), 'blob');
} catch (Exception $e) {
Log::error('Bluesky blob upload exception', [
'error' => $e->getMessage(),
'url' => $url,
Expand All @@ -180,8 +181,8 @@ private function parseFacets(string $text): array
);

foreach ($urlMatches[0] as $match) {
$url = $match[0];
$start = $this->getUtf8ByteOffset($text, (int) $match[1]);
$url = $this->trimTrailingUrlPunctuation($match[0]);
$start = (int) $match[1];
$end = $start + strlen($url);

$facets[] = [
Expand All @@ -191,7 +192,7 @@ private function parseFacets(string $text): array
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#link',
'$type' => BlueskyLexicon::FACET_LINK,
'uri' => $url,
],
],
Expand Down Expand Up @@ -221,7 +222,7 @@ private function parseFacets(string $text): array
continue;
}

$start = $this->getUtf8ByteOffset($text, (int) $match[1]);
$start = (int) $match[1];
$end = $start + strlen($mention);

$facets[] = [
Expand All @@ -231,7 +232,7 @@ private function parseFacets(string $text): array
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#mention',
'$type' => BlueskyLexicon::FACET_MENTION,
'did' => $did,
],
],
Expand All @@ -249,7 +250,7 @@ private function parseFacets(string $text): array
foreach ($hashtagMatches[0] as $match) {
$hashtag = $match[0];
$tag = substr($hashtag, 1); // Remove #
$start = $this->getUtf8ByteOffset($text, (int) $match[1]);
$start = (int) $match[1];
$end = $start + strlen($hashtag);

$facets[] = [
Expand All @@ -259,7 +260,7 @@ private function parseFacets(string $text): array
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#tag',
'$type' => BlueskyLexicon::FACET_TAG,
'tag' => $tag,
],
],
Expand All @@ -282,7 +283,7 @@ private function resolveHandleToDid(string $handle): ?string

try {
$response = $this->socialHttp()->get(
"{$appView}/xrpc/com.atproto.identity.resolveHandle",
"{$appView}/xrpc/".BlueskyLexicon::RESOLVE_HANDLE,
['handle' => $handle],
);

Expand All @@ -294,14 +295,29 @@ private function resolveHandleToDid(string $handle): ?string
}
}

private function getUtf8ByteOffset(string $text, int $charOffset): int
/**
* Trailing sentence punctuation and an unmatched closing paren are almost
* never part of a URL (e.g. "see https://x.com)."). Mirrors the official
* atproto link tokenizer so the link facet doesn't over-extend past the URL.
*/
private function trimTrailingUrlPunctuation(string $url): string
{
return strlen(substr($text, 0, $charOffset));
if (preg_match('/[.,;:!?]$/', $url)) {
$url = substr($url, 0, -1);
}

if (str_ends_with($url, ')') && ! str_contains($url, '(')) {
$url = substr($url, 0, -1);
}

return $url;
}

private function buildPostUrl(string $handle, string $postId): string
{
return "https://bsky.app/profile/{$handle}/post/{$postId}";
$webApp = (string) config('trypost.platforms.bluesky.web_app');

return "{$webApp}/profile/{$handle}/post/{$postId}";
}

private function handleApiError(Response $response): never
Expand Down
6 changes: 3 additions & 3 deletions app/Services/Social/ConnectionVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ private function refreshBlueskyToken(SocialAccount $account): void

try {
$response = $client->send(fn () => Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
->post("{$service}/xrpc/".BlueskyLexicon::REFRESH_SESSION));

$data = $response->json();
$account->update([
Expand All @@ -185,7 +185,7 @@ private function refreshBlueskyToken(SocialAccount $account): void

if (isset($account->meta['password'])) {
try {
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/".BlueskyLexicon::CREATE_SESSION, [
'identifier' => $account->meta['identifier'],
'password' => decrypt($account->meta['password']),
]));
Expand Down Expand Up @@ -490,7 +490,7 @@ private function verifyBluesky(SocialAccount $account): bool
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');

$response = Http::withToken($account->access_token)
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
->get("{$service}/xrpc/".BlueskyLexicon::GET_PROFILE, [
'actor' => $account->platform_user_id,
]);

Expand Down
2 changes: 2 additions & 0 deletions config/trypost.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@
'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'),
// Default PDS used when the account has no `meta.service` override.
'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'),
// Web client where published posts are viewed (profile/post URLs).
'web_app' => env('BLUESKY_WEB_APP', 'https://bsky.app'),
],
'mastodon' => [
'enabled' => env('MASTODON_ENABLED', true),
Expand Down
Loading
Loading