From b3112ce4338c972e7c936f0b4a2a6df6b72827cb Mon Sep 17 00:00:00 2001 From: flashwave Date: Thu, 30 Jan 2025 21:31:27 +0000 Subject: [PATCH] Speedrunned Bluesky integration for news posts. --- VERSION | 2 +- composer.json | 2 + public-legacy/manage/news/post.php | 71 ++++++++++++- src/ATProto/XrpcClient.php | 154 +++++++++++++++++++++++++++++ src/Misuzu.php | 17 ++++ templates/manage/news/posts.twig | 2 +- 6 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 src/ATProto/XrpcClient.php create mode 100644 src/Misuzu.php diff --git a/VERSION b/VERSION index bd0cfbab..c7de632f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250130.1 +20250130.2 diff --git a/composer.json b/composer.json index 18961187..e82149b2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,8 @@ { "require": { "php": ">=8.4", + "ext-curl": "*", + "ext-mbstring": "*", "flashwave/index": "^0.2501", "flashii/rpcii": "~4.0", "erusev/parsedown": "~1.7", diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php index 903f7f72..6673f69e 100644 --- a/public-legacy/manage/news/post.php +++ b/public-legacy/manage/news/post.php @@ -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])); diff --git a/src/ATProto/XrpcClient.php b/src/ATProto/XrpcClient.php new file mode 100644 index 00000000..1e9f20f6 --- /dev/null +++ b/src/ATProto/XrpcClient.php @@ -0,0 +1,154 @@ +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); + } +} diff --git a/src/Misuzu.php b/src/Misuzu.php new file mode 100644 index 00000000..d36cbf64 --- /dev/null +++ b/src/Misuzu.php @@ -0,0 +1,17 @@ +