Skip to content

Commit

Permalink
feat: support resolving website embeds (#1)
Browse files Browse the repository at this point in the history
  • Loading branch information
innocenzi authored Feb 11, 2024
1 parent 5ca868b commit a57fdf4
Show file tree
Hide file tree
Showing 31 changed files with 663 additions and 161 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ Bluesky doesn't provide a way to authenticate requests using classic API tokens.

Since these tokens expire, they cannot be stored in the environment. They are generated dynamically by creating and refreshing sessions and they need to be kept for as long as possible.

This notification channel implementation uses a session manager and an identity repository based on Laravel's cache. This may be overriden by replacing the binding in the container.
This notification channel implementation uses a session manager and an identity repository based on Laravel's cache. This may be overriden by swapping `NotificationChannels\Bluesky\IdentityRepository\IdentityRepository` in the container.

Additionnally, the key used by the cache-based identity repository may be configured by setting the `services.bluesky.identity_cache_key` option.


 

## Embeds

For Bluesky, embeds are a client-side responsibility, which means we have to generate website embeds ourselves.

This notification channel implementation uses Bluesky's own private API, `cardyb.bsky.app`, to fetch a website's metadata, including an URL to its thumbnail. However, that thumbnail stills has to be uploaded to Bluesky as a blob, so a reference to that blob can be added to the post's embed.

You may disable automatic embed generation by calling `withoutAutomaticEmbeds` on a `BlueskyPost` instance, or replace the implementation altogether by swapping `NotificationChannels\Bluesky\Embeds\EmbedResolver` in the container.
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
62 changes: 61 additions & 1 deletion 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 NotificationChannels\Bluesky\RichText\Facets\Facet;
use Illuminate\Support\Traits\Conditionable;
use Illuminate\Support\Traits\Macroable;
use Illuminate\Support\Traits\Tappable;
use NotificationChannels\Bluesky\Embeds\Embed;
use NotificationChannels\Bluesky\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
39 changes: 37 additions & 2 deletions src/BlueskyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace NotificationChannels\Bluesky;

use NotificationChannels\Bluesky\Embeds\EmbedResolver;
use NotificationChannels\Bluesky\Facets\FacetsResolver;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepository;

final class BlueskyService
Expand All @@ -10,14 +12,47 @@ public function __construct(
protected readonly BlueskyClient $client,
protected readonly IdentityRepository $identityRepository,
protected readonly SessionManager $sessionManager,
protected readonly EmbedResolver $embedResolver,
protected readonly FacetsResolver $facetsResolver,
) {
}

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: $this->facetsResolver->resolve($this, $post));
}

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

return $post;
}

public function getClient(): BlueskyClient
{
return $this->client;
}
}
10 changes: 10 additions & 0 deletions src/BlueskyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
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\Facets\DefaultFacetsResolver;
use NotificationChannels\Bluesky\Facets\FacetsResolver;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepository;
use NotificationChannels\Bluesky\IdentityRepository\IdentityRepositoryUsingCache;
use Spatie\LaravelPackageTools\Package;
Expand All @@ -19,13 +23,17 @@ public function configurePackage(Package $package): void

public function boot(): void
{
$this->app->singleton(FacetsResolver::class, fn () => new DefaultFacetsResolver());
$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 +48,8 @@ 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),
facetsResolver: $this->app->make(FacetsResolver::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\Support\SerializesToLexiconObject;

abstract class Embed
{
use SerializesToLexiconObject;
}
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;
}
26 changes: 26 additions & 0 deletions src/Embeds/External.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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 = null,
) {}

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

public function toArray(): array
{
return [
'$type' => $this->getType(),
'external' => $this->serializeProperties(),
];
}
}
Loading

0 comments on commit a57fdf4

Please sign in to comment.