Skip to content

Commit

Permalink
feat: support resolving website embeds
Browse files Browse the repository at this point in the history
  • Loading branch information
innocenzi committed Feb 11, 2024
1 parent 5ca868b commit 6601b2a
Show file tree
Hide file tree
Showing 21 changed files with 520 additions and 70 deletions.
16 changes: 16 additions & 0 deletions src/Blob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace NotificationChannels\Bluesky;

final class Blob
{
public function __construct(
public readonly array $blob,
) {
}

public function toArray(): array
{
return $this->blob;
}
}
2 changes: 1 addition & 1 deletion src/BlueskyChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public function send(mixed $notifiable, Notification $notification): string
}

return $this->bluesky->createPost(
text: $notification->toBluesky($notifiable),
post: $notification->toBluesky($notifiable),
);
}
}
48 changes: 39 additions & 9 deletions src/BlueskyClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@

use Illuminate\Http\Client\Factory as HttpClient;
use Illuminate\Http\Client\Response;
use NotificationChannels\Bluesky\Embeds\EmbedResolver;
use NotificationChannels\Bluesky\Exceptions\CouldNotCreatePost;
use NotificationChannels\Bluesky\Exceptions\CouldNotCreateSession;
use NotificationChannels\Bluesky\Exceptions\CouldNotRefreshSession;
use NotificationChannels\Bluesky\Exceptions\CouldNotResolveHandle;
use NotificationChannels\Bluesky\RichText\Facets\Facet;
use NotificationChannels\Bluesky\Exceptions\CouldNotUploadBlob;
use ValueError;

final class BlueskyClient
{
public const DEFAULT_BASE_URL = 'https://bsky.social/xrpc';
public const REFRESH_SESSION_ENDPOINT = 'com.atproto.server.refreshSession';
public const CREATE_SESSION_ENDPOINT = 'com.atproto.server.createSession';
public const CREATE_RECORD_ENDPOINT = 'com.atproto.repo.createRecord';
public const UPLOAD_BLOB_ENDPOINT = 'com.atproto.repo.uploadBlob';
public const RESOLVE_HANDLE_ENDPOINT = 'com.atproto.identity.resolveHandle';

public function __construct(
protected readonly HttpClient $httpClient,
protected readonly EmbedResolver $embedResolver,
protected readonly string $baseUrl,
protected readonly string $username,
protected readonly string $password,
Expand Down Expand Up @@ -77,12 +81,8 @@ public function refreshIdentity(BlueskyIdentity $identity): BlueskyIdentity
);
}

public function createPost(BlueskyIdentity $identity, BlueskyPost|string $post): string
public function createPost(BlueskyIdentity $identity, BlueskyPost $post): string
{
if (\is_string($post)) {
$post = BlueskyPost::make()->text($post);
}

$response = $this->httpClient
->asJson()
->withHeader('Authorization', "Bearer {$identity->accessJwt}")
Expand All @@ -91,9 +91,7 @@ public function createPost(BlueskyIdentity $identity, BlueskyPost|string $post):
'collection' => 'app.bsky.feed.post',
'record' => [
'createdAt' => now()->toIso8601ZuluString(),
...$post
->facets(facets: Facet::resolveFacets($post->text, $this))
->toArray(),
...$post->toArray(),
],
]);

Expand All @@ -102,6 +100,38 @@ public function createPost(BlueskyIdentity $identity, BlueskyPost|string $post):
return $response->json('uri');
}

public function uploadBlob(BlueskyIdentity $identity, string $pathOrUrl): Blob
{
$content = null;

try {
$content = @file_get_contents($pathOrUrl);
} catch (ValueError) {
throw CouldNotUploadBlob::couldNotLoadImage();
}

if ($content === false) {
$content = $this->httpClient->get($pathOrUrl)->body();
}

if (!$content) {
throw CouldNotUploadBlob::couldNotLoadImage();
}

$response = $this->httpClient
->contentType('image/*')
->withHeader('Authorization', "Bearer {$identity->accessJwt}")
->send('POST', "{$this->baseUrl}/" . self::UPLOAD_BLOB_ENDPOINT, [
'body' => $content,
]);

$this->ensureResponseSucceeded($response, CouldNotUploadBlob::class);

return new Blob(
blob: $response->json('blob'),
);
}

private function ensureResponseSucceeded(Response $response, string $errorClass): void
{
if ($response->ok()) {
Expand Down
60 changes: 60 additions & 0 deletions src/BlueskyPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@
namespace NotificationChannels\Bluesky;

use Illuminate\Support\Arr;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Support\Traits\Tappable;
use NotificationChannels\Bluesky\Embeds\Embed;
use NotificationChannels\Bluesky\RichText\Facets\Facet;

class BlueskyPost
{
use Conditionable;
use Macroable;
use Tappable;

private bool $automaticallyResolvesEmbeds = true;
private bool $automaticallyResolvesFacets = true;

private function __construct(
public string $text = '',
public array $facets = [],
public ?Embed $embed = null,
public array $languages = [],
) {
}
Expand All @@ -22,6 +34,7 @@ public function toArray(): array
callback: fn (array|Facet $facet) => \is_array($facet) ? $facet : $facet->toArray(),
array: $this->facets,
),
'embed' => $this->embed?->toArray(),
'langs' => $this->languages,
]);
}
Expand All @@ -31,6 +44,53 @@ public static function make(): static
return new static();
}

/**
* Sets the embed for this post.
* Note that by default, supported embeds are resolved automatically.
*/
public function embed(?Embed $embed = null): static
{
$this->embed = $embed;

return $this;
}

/**
* Disables automatic embed resolution.
*/
public function withoutAutomaticEmbeds(): static
{
$this->automaticallyResolvesEmbeds = false;

return $this;
}

/**
* Whether automatic embed resolution is enabled.
*/
public function automaticallyResolvesEmbeds(): bool
{
return $this->automaticallyResolvesEmbeds;
}

/**
* Disables automatic facets resolution.
*/
public function withoutAutomaticFacets(): static
{
$this->automaticallyResolvesFacets = false;

return $this;
}

/**
* Whether automatic facet resolution is enabled.
*/
public function automaticallyResolvesFacets(): bool
{
return $this->automaticallyResolvesFacets;
}

/**
* Sets the language(s) of the post.
*
Expand Down
33 changes: 31 additions & 2 deletions src/BlueskyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,51 @@

namespace NotificationChannels\Bluesky;

use NotificationChannels\Bluesky\Embeds\EmbedResolver;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepository;
use NotificationChannels\Bluesky\RichText\Facets\Facet;

final class BlueskyService
{
public function __construct(
protected readonly BlueskyClient $client,
protected readonly IdentityRepository $identityRepository,
protected readonly SessionManager $sessionManager,
protected readonly EmbedResolver $embedResolver,
) {
}

public function createPost(BlueskyPost|string $text): string
public function createPost(BlueskyPost|string $post): string
{
return $this->client->createPost(
identity: $this->sessionManager->getIdentity(),
post: $text,
post: $this->resolvePost($post),
);
}

public function uploadBlob(string $pathOrUrl): Blob
{
return $this->client->uploadBlob(
identity: $this->sessionManager->getIdentity(),
pathOrUrl: $pathOrUrl,
);
}

public function resolvePost(string|BlueskyPost $post): BlueskyPost
{
if (\is_string($post)) {
$post = BlueskyPost::make()->text($post);
}

if ($post->automaticallyResolvesFacets()) {
$post->facets(facets: Facet::resolveFacets($post->text, $this->client));
}

// Embeds depends on facets, so they must be resolved after
if ($post->automaticallyResolvesEmbeds()) {
$post->embed(embed: $this->embedResolver->resolve($this, $post));
}

return $post;
}
}
6 changes: 6 additions & 0 deletions src/BlueskyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
use Illuminate\Config\Repository as Config;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Http\Client\Factory as HttpClient;
use NotificationChannels\Bluesky\Embeds\EmbedResolver;
use NotificationChannels\Bluesky\Embeds\LinkEmbedResolverUsingCardyb;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepository;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepositoryUsingCache;
use Spatie\LaravelPackageTools\Package;
Expand All @@ -19,13 +21,16 @@ public function configurePackage(Package $package): void

public function boot(): void
{
$this->app->singleton(EmbedResolver::class, fn () => new LinkEmbedResolverUsingCardyb());

$this->app->singleton(IdentityRepository::class, fn () => new IdentityRepositoryUsingCache(
cache: $this->app->make(Cache::class),
key: $this->app->make(Config::class)->get('services.bluesky.identity_cache_key', default: IdentityRepositoryUsingCache::DEFAULT_CACHE_KEY),
));

$this->app->singleton(BlueskyClient::class, fn () => new BlueskyClient(
httpClient: $this->app->make(HttpClient::class),
embedResolver: $this->app->make(EmbedResolver::class),
baseUrl: $this->app->make(Config::class)->get('services.bluesky.base_url', default: BlueskyClient::DEFAULT_BASE_URL),
username: $this->app->make(Config::class)->get('services.bluesky.username'),
password: $this->app->make(Config::class)->get('services.bluesky.password'),
Expand All @@ -40,6 +45,7 @@ public function boot(): void
client: $this->app->make(BlueskyClient::class),
identityRepository: $this->app->make(IdentityRepository::class),
sessionManager: $this->app->make(SessionManager::class),
embedResolver: $this->app->make(EmbedResolver::class),
));
}
}
10 changes: 10 additions & 0 deletions src/Embeds/Embed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace NotificationChannels\Bluesky\Embeds;

use NotificationChannels\Bluesky\RichText\SerializesIntoPost;

abstract class Embed
{
use SerializesIntoPost;
}
14 changes: 14 additions & 0 deletions src/Embeds/EmbedResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace NotificationChannels\Bluesky\Embeds;

use NotificationChannels\Bluesky\BlueskyPost;
use NotificationChannels\Bluesky\BlueskyService;

interface EmbedResolver
{
/**
* Resolves an embed from the given post.
*/
public function resolve(BlueskyService $bluesky, BlueskyPost $post): ?Embed;
}
27 changes: 27 additions & 0 deletions src/Embeds/External.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace NotificationChannels\Bluesky\Embeds;

final class External extends Embed
{
public function __construct(
public readonly string $uri,
public readonly string $title,
public readonly string $description,
public readonly array $thumb,
) {
}

public function getType(): string
{
return 'app.bsky.embed.external';
}

public function toArray(): array
{
return [
'$type' => $this->getType(),
'external' => $this->serializeProperties(),
];
}
}
47 changes: 47 additions & 0 deletions src/Embeds/LinkEmbedResolverUsingCardyb.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace NotificationChannels\Bluesky\Embeds;

use Illuminate\Support\Facades\Http;
use NotificationChannels\Bluesky\BlueskyPost;
use NotificationChannels\Bluesky\BlueskyService;
use NotificationChannels\Bluesky\RichText\Facets\Facet;
use NotificationChannels\Bluesky\RichText\Facets\FacetFeature;

final class LinkEmbedResolverUsingCardyb implements EmbedResolver
{
public function resolve(BlueskyService $bluesky, BlueskyPost $post): ?Embed
{
if (\count($post->facets) === 0) {
return null;
}

/** @var Facet */
$firstLink = collect($post->facets)->first(
callback: fn (Facet $facet) => collect($facet->getFeatures())->first(
callback: fn (FacetFeature $feature) => $feature->getType() === 'app.bsky.richtext.facet#link',
),
);

if (!$firstLink) {
return null;
}

$embed = Http::get('https://cardyb.bsky.app/v1/extract', [
'url' => $firstLink->getFeatures()[0]->uri,
]);

if ($embed->json('error')) {
return null;
}

return new External(
uri: $embed->json('url'),
title: $embed->json('title'),
description: $embed->json('description'),
thumb: $bluesky
->uploadBlob($embed->json('image'))
->toArray(),
);
}
}
Loading

0 comments on commit 6601b2a

Please sign in to comment.