[MTM] Switch Bridgy syndication from Micropub to webmention #99
9 changed files with 167 additions and 94 deletions
|
|
@ -81,5 +81,3 @@ LOG_SLACK_WEBHOOK_URL=
|
||||||
FLARE_KEY=
|
FLARE_KEY=
|
||||||
|
|
||||||
IGNITION_OPEN_AI_KEY=
|
IGNITION_OPEN_AI_KEY=
|
||||||
|
|
||||||
BRIDGY_MASTODON_TOKEN=
|
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,20 @@ class ReprocessMediaImages extends Command
|
||||||
|
|
||||||
$dryRun = $this->option('dry-run');
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
$this->info("Found {$media->count()} images to reprocess." . ($dryRun ? ' (dry run)' : ''));
|
$this->info("Found {$media->count()} images to reprocess.".($dryRun ? ' (dry run)' : ''));
|
||||||
|
|
||||||
foreach ($media as $item) {
|
foreach ($media as $item) {
|
||||||
$path = $item->path;
|
$path = $item->path;
|
||||||
|
|
||||||
if (! Storage::disk('public')->exists($path)) {
|
if (! Storage::disk('public')->exists($path)) {
|
||||||
$this->warn("{$path}: original not found on public disk, skipping.");
|
$this->warn("{$path}: original not found on public disk, skipping.");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->line("{$path} ({$item->image_widths}px wide)");
|
$this->line("{$path} ({$item->image_widths}px wide)");
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +48,8 @@ class ReprocessMediaImages extends Command
|
||||||
$file = Storage::disk('public')->get($path);
|
$file = Storage::disk('public')->get($path);
|
||||||
$image = $manager->read($file);
|
$image = $manager->read($file);
|
||||||
} catch (DecoderException) {
|
} catch (DecoderException) {
|
||||||
$this->warn(" Could not decode image, skipping.");
|
$this->warn(' Could not decode image, skipping.');
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,9 +63,9 @@ class ReprocessMediaImages extends Command
|
||||||
$small = $image->scale(width: 500);
|
$small = $image->scale(width: 500);
|
||||||
Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode());
|
Storage::disk('public')->put($basename.'-small.'.$extension, (string) $small->encode());
|
||||||
|
|
||||||
$this->info(" Done.");
|
$this->info(' Done.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Reprocessing complete.' . ($dryRun ? ' (dry run — no files were changed)' : ''));
|
$this->info('Reprocessing complete.'.($dryRun ? ' (dry run — no files were changed)' : ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only try once — retrying would send Bridgy a duplicate publish webmention.
|
||||||
|
*/
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,32 +36,32 @@ class SyndicateNoteToBluesky implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public function handle(Client $guzzle): void
|
public function handle(Client $guzzle): void
|
||||||
{
|
{
|
||||||
// We can only make the request if we have an access token
|
|
||||||
if (config('bridgy.bluesky_token') === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make micropub request
|
|
||||||
$response = $guzzle->request(
|
$response = $guzzle->request(
|
||||||
'POST',
|
'POST',
|
||||||
'https://brid.gy/micropub',
|
'https://brid.gy/publish/webmention',
|
||||||
[
|
[
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Authorization' => 'Bearer '.config('bridgy.bluesky_token'),
|
'Accept' => 'application/json',
|
||||||
],
|
],
|
||||||
'json' => [
|
'form_params' => [
|
||||||
'type' => ['h-entry'],
|
'source' => $this->note->uri,
|
||||||
'properties' => [
|
'target' => 'https://brid.gy/publish/bluesky',
|
||||||
'content' => [$this->note->getRawOriginal('note')],
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
'http_errors' => false,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse for syndication URL
|
$body = json_decode((string) $response->getBody(), true);
|
||||||
|
|
||||||
if ($response->getStatusCode() === 201) {
|
if ($response->getStatusCode() === 201) {
|
||||||
$this->note->bluesky_url = $response->getHeader('Location')[0];
|
$this->note->bluesky_url = $body['url'];
|
||||||
$this->note->save();
|
$this->note->save();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only try once — retrying would send Bridgy a duplicate publish webmention.
|
||||||
|
*/
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,33 +36,32 @@ class SyndicateNoteToMastodon implements ShouldQueue
|
||||||
*/
|
*/
|
||||||
public function handle(Client $guzzle): void
|
public function handle(Client $guzzle): void
|
||||||
{
|
{
|
||||||
// We can only make the request if we have an access token
|
|
||||||
if (config('bridgy.mastodon_token') === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make micropub request
|
|
||||||
$response = $guzzle->request(
|
$response = $guzzle->request(
|
||||||
'POST',
|
'POST',
|
||||||
'https://brid.gy/micropub',
|
'https://brid.gy/publish/webmention',
|
||||||
[
|
[
|
||||||
'headers' => [
|
'headers' => [
|
||||||
'Authorization' => 'Bearer '.config('bridgy.mastodon_token'),
|
'Accept' => 'application/json',
|
||||||
],
|
],
|
||||||
'json' => [
|
'form_params' => [
|
||||||
'type' => ['h-entry'],
|
'source' => $this->note->uri,
|
||||||
'properties' => [
|
'target' => 'https://brid.gy/publish/mastodon',
|
||||||
'content' => [$this->note->getRawOriginal('note')],
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
|
'http_errors' => false,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse for syndication URL
|
$body = json_decode((string) $response->getBody(), true);
|
||||||
|
|
||||||
if ($response->getStatusCode() === 201) {
|
if ($response->getStatusCode() === 201) {
|
||||||
$mastodonUrl = $response->getHeader('Location')[0];
|
$this->note->mastodon_url = $body['url'];
|
||||||
$this->note->mastodon_url = $mastodonUrl;
|
|
||||||
$this->note->save();
|
$this->note->save();
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Mastodon Token
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| When syndicating posts to Mastodon using Brid.gy’s Micropub endpoint, we
|
|
||||||
| need to provide an access token. This token can be generated by going to
|
|
||||||
| https://brid.gy/mastodon and clicking the “Get token” button.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'mastodon_token' => env('BRIDGY_MASTODON_TOKEN'),
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Bluesky Token
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| When syndicating posts to Bluesky using Brid.gy’s Micropub endpoint, we
|
|
||||||
| need to provide an access token. This token can be generated by going to
|
|
||||||
| https://brid.gy/bluesky and clicking the “Get token” button.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'bluesky_token' => env('BRIDGY_BLUESKY_TOKEN'),
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
<div class="h-entry">
|
<div class="h-entry">
|
||||||
|
@unless($note->mastodon_url)
|
||||||
|
<a href="https://brid.gy/publish/mastodon" style="display:none"></a>
|
||||||
|
@endunless
|
||||||
|
@unless($note->bluesky_url)
|
||||||
|
<a href="https://brid.gy/publish/bluesky" style="display:none"></a>
|
||||||
|
@endunless
|
||||||
@if ($note->in_reply_to)
|
@if ($note->in_reply_to)
|
||||||
<div class="u-in-reply-to h-cite reply-to">
|
<div class="u-in-reply-to h-cite reply-to">
|
||||||
In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a>
|
In reply to <a href="{{ $note->in_reply_to }}" class="u-url">{{ $note->in_reply_to }}</a>
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,38 @@ class NotesControllerTest extends TestCase
|
||||||
$response = $this->get('/notes/photou-photologo');
|
$response = $this->get('/notes/photou-photologo');
|
||||||
$response->assertNotFound();
|
$response->assertNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes not yet syndicated should carry the hidden Bridgy publish
|
||||||
|
* verification links, so Bridgy can confirm intent when it re-fetches
|
||||||
|
* the page after receiving our webmention.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function note_page_includes_bridgy_verification_links_when_not_yet_syndicated(): void
|
||||||
|
{
|
||||||
|
$note = Note::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->get($note->uri);
|
||||||
|
|
||||||
|
$response->assertSee('https://brid.gy/publish/mastodon', false);
|
||||||
|
$response->assertSee('https://brid.gy/publish/bluesky', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once a note has been syndicated to a silo, the hidden verification
|
||||||
|
* link for that silo should no longer be rendered.
|
||||||
|
*/
|
||||||
|
#[Test]
|
||||||
|
public function note_page_omits_bridgy_verification_link_once_syndicated(): void
|
||||||
|
{
|
||||||
|
$note = Note::factory()->create();
|
||||||
|
$note->mastodon_url = 'https://mastodon.example/@jonny/1';
|
||||||
|
$note->bluesky_url = 'https://bsky.app/profile/jonnybarnes.uk/1';
|
||||||
|
$note->save();
|
||||||
|
|
||||||
|
$response = $this->get($note->uri);
|
||||||
|
|
||||||
|
$response->assertDontSee('https://brid.gy/publish/mastodon', false);
|
||||||
|
$response->assertDontSee('https://brid.gy/publish/bluesky', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,15 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function we_syndicate_notes_to_bluesky(): void
|
public function we_syndicate_notes_to_bluesky(): void
|
||||||
{
|
{
|
||||||
config(['bridgy.bluesky_token' => 'test']);
|
|
||||||
$faker = Factory::create();
|
$faker = Factory::create();
|
||||||
$randomNumber = $faker->randomNumber();
|
$randomNumber = $faker->randomNumber();
|
||||||
|
$blueskyUrl = 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber;
|
||||||
$mock = new MockHandler([
|
$mock = new MockHandler([
|
||||||
new Response(201, ['Location' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber]),
|
new Response(201, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'url' => $blueskyUrl,
|
||||||
|
'id' => (string) $randomNumber,
|
||||||
|
'type' => ['h-entry'],
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$handler = HandlerStack::create($mock);
|
$handler = HandlerStack::create($mock);
|
||||||
$client = new Client(['handler' => $handler]);
|
$client = new Client(['handler' => $handler]);
|
||||||
|
|
@ -35,21 +39,19 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
|
||||||
$job->handle($client);
|
$job->handle($client);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notes', [
|
$this->assertDatabaseHas('notes', [
|
||||||
'bluesky_url' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber,
|
'bluesky_url' => $blueskyUrl,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function we_syndicate_the_original_markdown_to_bluesky(): void
|
public function we_post_the_correct_source_and_target(): void
|
||||||
{
|
{
|
||||||
config(['bridgy.bluesky_token' => 'test']);
|
|
||||||
$faker = Factory::create();
|
|
||||||
$randomNumber = $faker->randomNumber();
|
|
||||||
|
|
||||||
$container = [];
|
$container = [];
|
||||||
$history = Middleware::history($container);
|
$history = Middleware::history($container);
|
||||||
$mock = new MockHandler([
|
$mock = new MockHandler([
|
||||||
new Response(201, ['Location' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber]),
|
new Response(201, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'url' => 'https://bsky.app/profile/jonnybarnes.uk/1',
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$handler = HandlerStack::create($mock);
|
$handler = HandlerStack::create($mock);
|
||||||
$handler->push($history);
|
$handler->push($history);
|
||||||
|
|
@ -59,12 +61,37 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
|
||||||
$job = new SyndicateNoteToBluesky($note);
|
$job = new SyndicateNoteToBluesky($note);
|
||||||
$job->handle($client);
|
$job->handle($client);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notes', [
|
$request = $container[0]['request'];
|
||||||
'bluesky_url' => 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber,
|
$body = [];
|
||||||
|
parse_str((string) $request->getBody(), $body);
|
||||||
|
|
||||||
|
$this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri());
|
||||||
|
$this->assertSame($note->uri, $body['source']);
|
||||||
|
$this->assertSame('https://brid.gy/publish/bluesky', $body['target']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_bridgy_failure_throws_and_does_not_set_bluesky_url(): void
|
||||||
|
{
|
||||||
|
$mock = new MockHandler([
|
||||||
|
new Response(400, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'error' => 'Could not find target link',
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
|
$client = new Client(['handler' => HandlerStack::create($mock)]);
|
||||||
|
|
||||||
$expectedRequestContent = '{"type":["h-entry"],"properties":{"content":["This is a **test**"]}}';
|
$note = Note::factory()->create();
|
||||||
|
$job = new SyndicateNoteToBluesky($note);
|
||||||
|
|
||||||
$this->assertEquals($expectedRequestContent, $container[0]['request']->getBody()->getContents());
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$job->handle($client);
|
||||||
|
} finally {
|
||||||
|
$this->assertDatabaseHas('notes', [
|
||||||
|
'id' => $note->id,
|
||||||
|
'bluesky_url' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,15 @@ class SyndicateNoteToMastodonJobTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function we_syndicate_notes_to_mastodon(): void
|
public function we_syndicate_notes_to_mastodon(): void
|
||||||
{
|
{
|
||||||
config(['bridgy.mastodon_token' => 'test']);
|
|
||||||
$faker = Factory::create();
|
$faker = Factory::create();
|
||||||
$randomNumber = $faker->randomNumber();
|
$randomNumber = $faker->randomNumber();
|
||||||
|
$mastodonUrl = 'https://mastodon.example/@jonny/'.$randomNumber;
|
||||||
$mock = new MockHandler([
|
$mock = new MockHandler([
|
||||||
new Response(201, ['Location' => 'https://mastodon.example/@jonny/'.$randomNumber]),
|
new Response(201, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'url' => $mastodonUrl,
|
||||||
|
'id' => (string) $randomNumber,
|
||||||
|
'type' => ['h-entry'],
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$handler = HandlerStack::create($mock);
|
$handler = HandlerStack::create($mock);
|
||||||
$client = new Client(['handler' => $handler]);
|
$client = new Client(['handler' => $handler]);
|
||||||
|
|
@ -35,21 +39,19 @@ class SyndicateNoteToMastodonJobTest extends TestCase
|
||||||
$job->handle($client);
|
$job->handle($client);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notes', [
|
$this->assertDatabaseHas('notes', [
|
||||||
'mastodon_url' => 'https://mastodon.example/@jonny/'.$randomNumber,
|
'mastodon_url' => $mastodonUrl,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function we_syndicate_the_original_markdown(): void
|
public function we_post_the_correct_source_and_target(): void
|
||||||
{
|
{
|
||||||
config(['bridgy.mastodon_token' => 'test']);
|
|
||||||
$faker = Factory::create();
|
|
||||||
$randomNumber = $faker->randomNumber();
|
|
||||||
|
|
||||||
$container = [];
|
$container = [];
|
||||||
$history = Middleware::history($container);
|
$history = Middleware::history($container);
|
||||||
$mock = new MockHandler([
|
$mock = new MockHandler([
|
||||||
new Response(201, ['Location' => 'https://mastodon.example/@jonny/'.$randomNumber]),
|
new Response(201, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'url' => 'https://mastodon.example/@jonny/1',
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$handler = HandlerStack::create($mock);
|
$handler = HandlerStack::create($mock);
|
||||||
$handler->push($history);
|
$handler->push($history);
|
||||||
|
|
@ -59,12 +61,37 @@ class SyndicateNoteToMastodonJobTest extends TestCase
|
||||||
$job = new SyndicateNoteToMastodon($note);
|
$job = new SyndicateNoteToMastodon($note);
|
||||||
$job->handle($client);
|
$job->handle($client);
|
||||||
|
|
||||||
$this->assertDatabaseHas('notes', [
|
$request = $container[0]['request'];
|
||||||
'mastodon_url' => 'https://mastodon.example/@jonny/'.$randomNumber,
|
$body = [];
|
||||||
|
parse_str((string) $request->getBody(), $body);
|
||||||
|
|
||||||
|
$this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri());
|
||||||
|
$this->assertSame($note->uri, $body['source']);
|
||||||
|
$this->assertSame('https://brid.gy/publish/mastodon', $body['target']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_bridgy_failure_throws_and_does_not_set_mastodon_url(): void
|
||||||
|
{
|
||||||
|
$mock = new MockHandler([
|
||||||
|
new Response(400, ['Content-Type' => 'application/json'], json_encode([
|
||||||
|
'error' => 'Could not find target link',
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
|
$client = new Client(['handler' => HandlerStack::create($mock)]);
|
||||||
|
|
||||||
$expectedRequestContent = '{"type":["h-entry"],"properties":{"content":["This is a **test**"]}}';
|
$note = Note::factory()->create();
|
||||||
|
$job = new SyndicateNoteToMastodon($note);
|
||||||
|
|
||||||
$this->assertEquals($expectedRequestContent, $container[0]['request']->getBody()->getContents());
|
$this->expectException(\RuntimeException::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$job->handle($client);
|
||||||
|
} finally {
|
||||||
|
$this->assertDatabaseHas('notes', [
|
||||||
|
'id' => $note->id,
|
||||||
|
'mastodon_url' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue