Speedrunned Bluesky integration for news posts.
This commit is contained in:
parent
0701298fa1
commit
b3112ce433
6 changed files with 243 additions and 5 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
20250130.1
|
||||
20250130.2
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"ext-curl": "*",
|
||||
"ext-mbstring": "*",
|
||||
"flashwave/index": "^0.2501",
|
||||
"flashii/rpcii": "~4.0",
|
||||
"erusev/parsedown": "~1.7",
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
namespace Misuzu;
|
||||
|
||||
use RuntimeException;
|
||||
use Index\XDateTime;
|
||||
|
||||
if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
|
||||
die('Script must be called through the Misuzu route dispatcher.');
|
||||
|
@ -59,9 +60,73 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
|
|||
[$postInfo->id]
|
||||
);
|
||||
|
||||
if($isNew) {
|
||||
if($postInfo->featured) {
|
||||
// Twitter integration used to be here, replace with Railgun Pulse integration
|
||||
if($isNew && $postInfo->featured) {
|
||||
$bsky = $msz->config->scopeTo('bsky');
|
||||
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]));
|
||||
|
|
154
src/ATProto/XrpcClient.php
Normal file
154
src/ATProto/XrpcClient.php
Normal 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
17
src/Misuzu.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@
|
|||
{{ post.title }} |
|
||||
{{ post.featured ? 'Featured' : 'Normal' }} |
|
||||
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 }} |
|
||||
{{ post.published ? 'published' : 'Published ' ~ post.scheduledAt }} |
|
||||
{{ post.edited ? 'Edited ' ~ post.updatedAt : 'not edited' }} |
|
||||
|
|
Loading…
Add table
Reference in a new issue