Speedrunned Bluesky integration for news posts.

This commit is contained in:
flash 2025-01-30 21:31:27 +00:00
parent 0701298fa1
commit b3112ce433
6 changed files with 243 additions and 5 deletions

View file

@ -1 +1 @@
20250130.1 20250130.2

View file

@ -1,6 +1,8 @@
{ {
"require": { "require": {
"php": ">=8.4", "php": ">=8.4",
"ext-curl": "*",
"ext-mbstring": "*",
"flashwave/index": "^0.2501", "flashwave/index": "^0.2501",
"flashii/rpcii": "~4.0", "flashii/rpcii": "~4.0",
"erusev/parsedown": "~1.7", "erusev/parsedown": "~1.7",

View file

@ -2,6 +2,7 @@
namespace Misuzu; namespace Misuzu;
use RuntimeException; use RuntimeException;
use Index\XDateTime;
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext)) if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
die('Script must be called through the Misuzu route dispatcher.'); die('Script must be called through the Misuzu route dispatcher.');
@ -59,9 +60,73 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
[$postInfo->id] [$postInfo->id]
); );
if($isNew) { if($isNew && $postInfo->featured) {
if($postInfo->featured) { $bsky = $msz->config->scopeTo('bsky');
// Twitter integration used to be here, replace with Railgun Pulse integration if($bsky->hasValues(['identifier', 'password'])) {
try {
$xrpc = new \Misuzu\ATProto\XrpcClient($bsky->getString('pds', 'https://bsky.social'));
$session = $xrpc->call(
'com.atproto.server.createSession',
data: [
'identifier' => $bsky->getString('identifier'),
'password' => $bsky->getString('password'),
]
)->data;
if(isset($session->accessJwt) && is_string($session->accessJwt)
&& isset($session->did) && is_string($session->did)) {
$url = $msz->siteInfo->url . $msz->urls->format('news-post', ['post' => $postInfo->id]);
$body = sprintf("News :: %s\n", $postInfo->title);
$urlStart = strlen($body);
$body .= $url;
$urlEnd = strlen($body);
$xrpc->call(
'com.atproto.repo.createRecord',
headers: [
'authorization' => sprintf('Bearer %s', $session->accessJwt),
],
data: [
'repo' => $session->did,
'collection' => 'app.bsky.feed.post',
'record' => [
'$type' => 'app.bsky.feed.post',
'createdAt' => XDateTime::toIso8601String($postInfo->createdAt),
'text' => $body,
'embed' => [
'$type' => 'app.bsky.embed.external',
'external' => [
'uri' => $url,
'title' => $postInfo->title,
'description' => mb_substr($postInfo->firstParagraph, 0, 500),
],
],
'facets' => [
[
'index' => [
'byteStart' => $urlStart,
'byteEnd' => $urlEnd,
],
'features' => [
[
'$type' => 'app.bsky.richtext.facet#link',
'uri' => $url,
],
],
],
],
],
],
);
$xrpc->call(
'com.atproto.server.deleteSession',
headers: [
'authorization' => sprintf('Bearer %s', $session->accessJwt),
],
);
}
} catch(RuntimeException $ex) {}
} }
Tools::redirect($msz->urls->format('manage-news-post', ['post' => $postInfo->id])); Tools::redirect($msz->urls->format('manage-news-post', ['post' => $postInfo->id]));

154
src/ATProto/XrpcClient.php Normal file
View file

@ -0,0 +1,154 @@
<?php
namespace Misuzu\ATProto;
use CurlHandle;
use InvalidArgumentException;
use RuntimeException;
use UnexpectedValueException;
use Misuzu\Misuzu;
class XrpcClient {
private readonly CurlHandle $handle;
private readonly string $userAgent;
public function __construct(
private string $service
) {
if(!filter_var($service, FILTER_VALIDATE_URL))
throw new InvalidArgumentException('$service must be a valid URL');
$handle = curl_init();
if($handle === false)
throw new RuntimeException('Failed to initialise cURL.');
$version = curl_version();
if(!is_array($version) || !array_key_exists('version', $version) || !is_string($version['version']))
throw new RuntimeException('Failed to retrieve cURL version info.');
$this->handle = $handle;
$this->userAgent = sprintf('Misuzu/%s cURL/%s', Misuzu::version(), $version['version']);
}
public function __destruct() {
curl_close($this->handle);
}
private function request(
string $method,
string $nsid,
array $headers,
array $params,
array|object|string|null $data
): object {
$url = sprintf('%s/xrpc/%s', $this->service, $nsid);
if(!empty($params))
$url = sprintf('%s?%s', $url, http_build_query($params, encoding_type: PHP_QUERY_RFC3986));
$headers = (function($original) {
$sanitised = [];
foreach($original as $name => $value) {
$name = strtolower($name);
if(!array_key_exists($name, $sanitised))
$sanitised[$name] = $value;
}
return $sanitised;
})($headers);
if(is_object($data) || is_array($data)) {
$headers['content-type'] = 'application/json';
$data = json_encode($data);
}
curl_reset($this->handle);
curl_setopt_array($this->handle, [
CURLOPT_AUTOREFERER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_FAILONERROR => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => (function($headers) {
$lines = [];
foreach($headers as $name => $value)
$lines[] = sprintf('%s: %s', $name, $value);
return $lines;
})($headers),
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TCP_FASTOPEN => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_MAXREDIRS => 5,
CURLOPT_POSTFIELDS => $data,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 10,
CURLOPT_URL => $url,
CURLOPT_USERAGENT => $this->userAgent,
]);
$response = curl_exec($this->handle);
if($response === false)
throw new RuntimeException(curl_error($this->handle), curl_errno($this->handle));
if($response === true)
throw new UnexpectedValueException('Request executed successfully but cURL returned no data.');
$parts = explode("\r\n\r\n", $response, 2);
$headerLines = array_reverse(explode("\r\n", $parts[0]));
$statusLine = array_pop($headerLines);
if(!is_string($statusLine))
throw new RuntimeException('Unable to read status header.');
$statusLineScan = sscanf($statusLine, 'HTTP/%s %d %[^\t\r\n]');
if($statusLineScan === null)
throw new RuntimeException('Failed to decode HTTP status line.');
[$_, $statusCode, $statusLine] = $statusLineScan;
$statusCode = (int)$statusCode;
$headers = [];
while(($headerLine = array_pop($headerLines)) !== null) {
$headerLine = trim((string)$headerLine);
if($headerLine === '')
continue;
$headerParts = explode(':', $headerLine, 2);
$headers[strtolower(trim($headerParts[0]))] = count($headerParts) > 1 ? trim($headerParts[1]) : '';
}
if(array_key_exists('content-type', $headers) && str_starts_with($headers['content-type'], 'application/json'))
$data = json_decode($parts[1]);
else
$data = $parts[1];
if($statusCode !== 200) {
if(is_object($data)
&& isset($data->error) && is_string($data->error)
&& isset($data->message) && is_string($data->message))
throw new RuntimeException(sprintf('%s: %s', $data->error, $data->message), $statusCode);
throw new RuntimeException(sprintf('HTTP %03d', $statusCode), $statusCode);
}
return (object)[
'headers' => $headers,
'data' => $data,
];
}
public function query(
string $nsid,
array $headers = [],
array $params = []
): object {
return $this->request('GET', $nsid, $headers, $params, null);
}
public function call(
string $nsid,
array $headers = [],
array $params = [],
array|object|string|null $data = null
): object {
return $this->request('POST', $nsid, $headers, $params, $data);
}
}

17
src/Misuzu.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace Misuzu;
final class Misuzu {
public const string PATH_VERSION = MSZ_ROOT . DIRECTORY_SEPARATOR . 'VERSION';
public static function version(): string {
if(!is_file(self::PATH_VERSION))
return '20000101';
$version = file_get_contents(self::PATH_VERSION);
if($version === false)
return '20000101';
return trim($version);
}
}

View file

@ -14,7 +14,7 @@
{{ post.title }} | {{ post.title }} |
{{ post.featured ? 'Featured' : 'Normal' }} | {{ post.featured ? 'Featured' : 'Normal' }} |
User #{{ post.userId }} | User #{{ post.userId }} |
{% if post.commentsSectionId is not null %}Comments category #{{ post.commentsCategoryId }}{% else %}No comments category{% endif %} | {% if post.commentsSectionId is not null %}Comments category #{{ post.commentsSectionId }}{% else %}No comments category{% endif %} |
Created {{ post.createdAt }} | Created {{ post.createdAt }} |
{{ post.published ? 'published' : 'Published ' ~ post.scheduledAt }} | {{ post.published ? 'published' : 'Published ' ~ post.scheduledAt }} |
{{ post.edited ? 'Edited ' ~ post.updatedAt : 'not edited' }} | {{ post.edited ? 'Edited ' ~ post.updatedAt : 'not edited' }} |