From c3f3efbf33e65ea7922d5fab7c74435aa5959f08 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 25 Mar 2026 20:50:17 -0300 Subject: [PATCH] First commit --- .claude/settings.local.json | 7 + CLAUDE.md | 48 ++++ README.md | 49 ++++ configure.phtml | 23 ++ extension.php | 491 ++++++++++++++++++++++++++++++++++++ metadata.json | 8 + 6 files changed, 626 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 configure.phtml create mode 100644 extension.php create mode 100644 metadata.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bffbfc0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:docs.bsky.app)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8933460 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +A FreshRSS extension that enriches Bluesky posts in RSS feeds by fetching the full reply thread and embedded post content via the Bluesky public API, collapsing everything into a single article. + +## Extension structure + +FreshRSS expects the extension directory name to start with `x`. Required files: + +| File | Purpose | +|---|---| +| `metadata.json` | Extension metadata (`name`, `entrypoint` are required) | +| `extension.php` | Main class — must be named `{entrypoint}Extension extends Minz_Extension` | +| `configure.phtml` | Optional settings form; submitted values handled by `handleConfigureAction()` | + +The extension is installed by dropping this directory into FreshRSS's `extensions/` folder. + +## How it works + +Two hooks work in tandem so both the web UI and API sync clients (Fever, GReader, etc.) receive enriched content: + +1. **`EntryBeforeInsert`** (`fetchThread`) — fires once when a new entry is first saved to the DB. Fetches the thread immediately and stores it, so API clients get enriched content from the very first sync. +2. **`EntryBeforeDisplay`** (`refreshThread`) — fires on every web render. Checks the file cache for staleness; if stale, re-fetches and calls `FreshRSS_Factory::createEntryDao()->updateEntry($entry)` to write the refreshed HTML back to the DB, keeping API clients up to date. +3. **Staleness rules** (`needsRefetch`): posts ≥ 7 days old are frozen. Fresher posts use progressively tighter refresh windows — 10 min (< 1 h old), 1 h (< 24 h old), 12 h (< 7 d old). +4. **Cache:** JSON files in `DATA_PATH/BlueskyThreads/{md5(url)}.json`, each containing `{html, fetched_at}`. On API failure, the stale cache is served as a fallback. +5. **Handle → DID:** `GET https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={handle}` (skipped if the handle is already a `did:` URI). +6. **Thread fetch:** `GET https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://{did}/app.bsky.feed.post/{rkey}&depth={depth}&parentHeight=0` +7. **Rendering:** `renderThread()` walks the recursive `threadViewPost` structure. Root post uses a bordered card style; replies are indented under a left border. Each post renders its richtext facets (links, mentions, hashtags) and embeds (images, external links, quoted posts, video). + +## Key implementation details + +- **Facets** — Bluesky richtext uses UTF-8 *byte* offsets (`byteStart`/`byteEnd`). `applyFacets()` walks the raw PHP byte string directly rather than converting to characters first. +- **Embed type normalisation** — the API returns `$type` values like `app.bsky.embed.images#view`; the `#view` suffix is stripped before the switch statement. +- **User config** — `depth` (int, 1–1000, default 10) is stored via `setUserConfigurationValue`/`getUserConfigurationValue` and read in `handleConfigureAction()` on POST. +- **No auth required** — all requests go to `public.api.bsky.app` and need no credentials. + +## Bluesky API reference + +- Thread endpoint: `app.bsky.feed.getPostThread` — params: `uri` (AT-URI), `depth` (0–1000), `parentHeight` (0–1000) +- Handle resolution: `com.atproto.identity.resolveHandle` — param: `handle` +- Docs: https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread + +## FreshRSS extension docs + +https://freshrss.github.io/FreshRSS/en/developers/03_Backend/05_Extensions.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..71a74e2 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Bluesky Threads — FreshRSS Extension + +A FreshRSS extension that enriches Bluesky posts in your RSS feeds by fetching the full reply thread and embedded content via the Bluesky public API, collapsing everything into a single article. + +## Features + +- Fetches full reply threads when a post is first saved, so API sync clients (Fever, GReader, etc.) receive enriched content immediately +- Automatically refreshes thread content as it ages, with progressively relaxed refresh intervals +- Renders rich text with working links, mentions, and hashtags +- Renders all embed types: images, external link cards, quoted posts, and videos +- No Bluesky account or API credentials required + +## Installation + +1. Download or clone this repository into FreshRSS's `extensions/` directory. The folder must be named `xExtension-BlueskyThreads`. +2. In FreshRSS, go to **Extensions** and enable **Bluesky Threads**. + +## Configuration + +In the extension settings, you can set **Thread depth** (default: 10, max: 1000) — how many levels of replies to fetch per post. + +## How it works + +### Hooks + +Two hooks work in tandem so both the web UI and API sync clients receive enriched content: + +- **`EntryBeforeInsert`** — fires once when a new entry is first saved. Fetches the thread immediately and stores the rendered HTML. +- **`EntryBeforeDisplay`** — fires on every web render. Checks if the cached thread is stale; if so, re-fetches and writes the updated HTML back to the database. + +### Cache & staleness + +Thread HTML is cached as JSON files in `DATA_PATH/BlueskyThreads/{md5(url)}.json`. Staleness is determined by the post's age: + +| Post age | Cache refresh interval | +|--------------|------------------------| +| < 1 hour | Every 10 minutes | +| < 24 hours | Every 1 hour | +| < 7 days | Every 12 hours | +| 7+ days | Never (frozen) | + +On API failure, the stale cache is served as a fallback. + +### API endpoints used + +All requests go to `public.api.bsky.app` — no authentication required. + +- **Handle resolution:** `com.atproto.identity.resolveHandle` +- **Thread fetch:** `app.bsky.feed.getPostThread` diff --git a/configure.phtml b/configure.phtml new file mode 100644 index 0000000..a2f9e2d --- /dev/null +++ b/configure.phtml @@ -0,0 +1,23 @@ +getUserConfigurationValue('depth') ?: 10); +?> + + + + + +
+ + + +

+ How many levels of replies to fetch (default: 10). + The Bluesky API allows up to 1000. +

+
diff --git a/extension.php b/extension.php new file mode 100644 index 0000000..eed2996 --- /dev/null +++ b/extension.php @@ -0,0 +1,491 @@ + 600, // post < 1 h old → refresh cache after 10 min + 86400 => 3600, // post < 24 h old → refresh cache after 1 h + self::FREEZE_AGE => 43200, // post < 7 d old → refresh cache after 12 h + ]; + + #[\Override] + public function init(): void { + parent::init(); + // EntryBeforeInsert stores the initial thread in the DB so API sync + // clients (Fever, GReader, etc.) receive enriched content immediately. + $this->registerHook(Minz_HookType::EntryBeforeInsert, [$this, 'fetchThread']); + // EntryBeforeDisplay re-checks staleness on every web render and writes + // refreshed content back to the DB so API clients also get updates. + $this->registerHook(Minz_HookType::EntryBeforeDisplay, [$this, 'refreshThread']); + } + + #[\Override] + public function handleConfigureAction(): void { + if (Minz_Request::isPost()) { + $depth = max(1, min(1000, (int) Minz_Request::paramString('depth') ?: 10)); + $this->setUserConfigurationValue('depth', $depth); + } + } + + // ------------------------------------------------------------------------- + // Hook callbacks + // ------------------------------------------------------------------------- + + /** + * EntryBeforeInsert — runs once when a new entry is first saved to the DB. + * Fetches the thread and stores enriched HTML so API sync clients get it. + */ + public function fetchThread(FreshRSS_Entry $entry): FreshRSS_Entry { + $url = $entry->link(); + if (!preg_match(self::POST_URL_PATTERN, $url, $m)) { + return $entry; + } + + [, $handle, $rkey] = $m; + + $html = $this->loadThread($handle, $rkey, $url); + if ($html !== null) { + $entry->_content($html); + } + return $entry; + } + + /** + * EntryBeforeDisplay — runs on every web render. + * Re-fetches if the cached thread is stale and writes the result back to + * the DB so API sync clients also receive the updated content. + */ + public function refreshThread(FreshRSS_Entry $entry): FreshRSS_Entry { + $url = $entry->link(); + if (!preg_match(self::POST_URL_PATTERN, $url, $m)) { + return $entry; + } + + [, $handle, $rkey] = $m; + + $cache = $this->readCache($url); + $publishedAt = (int) $entry->date(true); + + if (!$this->needsRefetch($publishedAt, $cache['fetched_at'] ?? null)) { + // Cache is fresh — serve without an API call. + if ($cache !== null) { + $entry->_content($cache['html']); + } + return $entry; + } + + $html = $this->loadThread($handle, $rkey, $url, $cache['html'] ?? null); + if ($html !== null) { + $entry->_content($html); + // Write back to DB so API sync clients receive the refreshed thread. + try { + FreshRSS_Factory::createEntryDao()->updateEntry($entry); + } catch (Throwable) { + // Non-fatal: the web UI still shows fresh content; DB update + // will be retried on the next stale render. + } + } + return $entry; + } + + // ------------------------------------------------------------------------- + // Shared fetch logic + // ------------------------------------------------------------------------- + + /** + * Resolves the handle, calls the API, renders the thread, updates the cache, + * and returns the HTML. Returns $fallback (null by default) on any failure. + */ + private function loadThread(string $handle, string $rkey, string $url, ?string $fallback = null): ?string { + $did = str_starts_with($handle, 'did:') ? $handle : $this->resolveHandle($handle); + if ($did === null) { + return $fallback; + } + + $atUri = "at://{$did}/app.bsky.feed.post/{$rkey}"; + $depth = (int) ($this->getUserConfigurationValue('depth') ?: 10); + + $data = $this->apiGet('app.bsky.feed.getPostThread', [ + 'uri' => $atUri, + 'depth' => $depth, + 'parentHeight' => 0, + ]); + + if ($data === null || !isset($data['thread'])) { + return $fallback; + } + + $html = $this->renderThread($data['thread'], true); + $this->writeCache($url, $html); + return $html; + } + + // ------------------------------------------------------------------------- + // Cache helpers + // ------------------------------------------------------------------------- + + private function cacheDir(): string { + $base = defined('DATA_PATH') ? DATA_PATH : sys_get_temp_dir(); + return rtrim((string) $base, '/') . '/BlueskyThreads'; + } + + private function cacheFile(string $url): string { + return $this->cacheDir() . '/' . md5($url) . '.json'; + } + + /** @return array{html:string,fetched_at:int}|null */ + private function readCache(string $url): ?array { + $file = $this->cacheFile($url); + if (!is_file($file)) { + return null; + } + $raw = file_get_contents($file); + if ($raw === false) { + return null; + } + $data = json_decode($raw, true); + return is_array($data) && isset($data['html'], $data['fetched_at']) ? $data : null; + } + + private function writeCache(string $url, string $html): void { + $dir = $this->cacheDir(); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + file_put_contents( + $this->cacheFile($url), + json_encode(['html' => $html, 'fetched_at' => time()], JSON_UNESCAPED_UNICODE) + ); + } + + /** + * Returns true if we should call the API again. + * + * @param int $publishedAt Unix timestamp of the post. + * @param int|null $fetchedAt Unix timestamp of the last cache write, or null if never cached. + */ + private function needsRefetch(int $publishedAt, ?int $fetchedAt): bool { + if ($fetchedAt === null) { + return true; + } + + $postAge = time() - $publishedAt; + $cacheAge = time() - $fetchedAt; + + if ($postAge >= self::FREEZE_AGE) { + return false; // Thread old enough to be considered complete. + } + + foreach (self::REFRESH_AGES as $maxPostAge => $minCacheAge) { + if ($postAge < $maxPostAge) { + return $cacheAge >= $minCacheAge; + } + } + + return false; + } + + // ------------------------------------------------------------------------- + // Bluesky API helpers + // ------------------------------------------------------------------------- + + private function resolveHandle(string $handle): ?string { + $data = $this->apiGet('com.atproto.identity.resolveHandle', ['handle' => $handle]); + return isset($data['did']) ? (string) $data['did'] : null; + } + + private function apiGet(string $method, array $params): ?array { + $url = self::API_BASE . '/' . $method . '?' . http_build_query($params); + $ctx = stream_context_create(['http' => [ + 'timeout' => 10, + 'ignore_errors' => true, + 'header' => "User-Agent: FreshRSS-BlueskyThreads/0.1\r\nAccept: application/json\r\n", + ]]); + $body = @file_get_contents($url, false, $ctx); + if ($body === false) { + return null; + } + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : null; + } + + // ------------------------------------------------------------------------- + // Thread rendering + // ------------------------------------------------------------------------- + + private function renderThread(array $node, bool $isRoot): string { + if (!isset($node['post'])) { + return ''; + } + + $html = $this->renderPost($node['post'], $isRoot); + + if (!empty($node['replies'])) { + $html .= '
'; + foreach ($node['replies'] as $reply) { + if (isset($reply['post'])) { + $html .= $this->renderThread($reply, false); + } + } + $html .= '
'; + } + + return $html; + } + + private function renderPost(array $post, bool $isRoot): string { + $author = $post['author'] ?? []; + $record = $post['record'] ?? []; + $embed = $post['embed'] ?? null; + + $handle = $author['handle'] ?? ''; + $displayName = $author['displayName'] ?? $handle; + $avatar = $author['avatar'] ?? ''; + $text = $record['text'] ?? ''; + $facets = $record['facets'] ?? []; + + $rkey = basename(rtrim($post['uri'] ?? '', '/')); + $postUrl = 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . $rkey; + + $avatarHtml = $avatar !== '' + ? '' + : ''; + + $textHtml = nl2br($this->applyFacets($text, $facets)); + $embedHtml = $embed !== null ? $this->renderEmbed($embed) : ''; + + $stats = ''; + foreach ([ + 'replyCount' => 'Reply', + 'repostCount' => 'Repost', + 'likeCount' => 'Like', + ] as $key => $label) { + if (isset($post[$key]) && (int) $post[$key] > 0) { + $n = (int) $post[$key]; + $stats .= '' . $n . ' ' . $label . ($n !== 1 ? 's' : '') . ''; + } + } + + $rootStyle = 'border:1px solid #cfd9de;border-radius:12px;padding:12px 16px;margin-bottom:12px;'; + $replyStyle = 'padding:10px 12px;margin:4px 0;'; + $style = $isRoot ? $rootStyle : $replyStyle; + + return << +
+ {$avatarHtml} +
+ + {$this->e($displayName)} +
+ @{$this->e($handle)} +
+
+
{$textHtml}
+ {$embedHtml} +
+ {$stats} + View on Bluesky ↗ +
+ + HTML; + } + + // ------------------------------------------------------------------------- + // Embed rendering + // ------------------------------------------------------------------------- + + private function renderEmbed(array $embed): string { + $type = preg_replace('/#view$/', '', $embed['$type'] ?? ''); + + switch ($type) { + case 'app.bsky.embed.images': + return $this->renderImages($embed['images'] ?? []); + + case 'app.bsky.embed.external': + return $this->renderExternal($embed['external'] ?? []); + + case 'app.bsky.embed.record': + return $this->renderQuotedRecord($embed['record'] ?? []); + + case 'app.bsky.embed.recordWithMedia': + return $this->renderEmbed($embed['media'] ?? []) + . $this->renderQuotedRecord($embed['record']['record'] ?? []); + + case 'app.bsky.embed.video': + return $this->renderVideo($embed); + + default: + return ''; + } + } + + private function renderImages(array $images): string { + if (empty($images)) { + return ''; + } + $cols = count($images) === 1 ? '1fr' : '1fr 1fr'; + $inner = ''; + foreach ($images as $img) { + $thumb = $this->e($img['thumb'] ?? ''); + $full = $this->e($img['fullsize'] ?? $img['thumb'] ?? ''); + $alt = $this->e($img['alt'] ?? ''); + if ($thumb === '') { + continue; + } + $inner .= "" + . "\"{$alt}\"" + . ''; + } + return "
{$inner}
"; + } + + private function renderExternal(array $ext): string { + $uri = $this->e($ext['uri'] ?? ''); + $title = $this->e($ext['title'] ?? $ext['uri'] ?? ''); + $desc = $this->e($ext['description'] ?? ''); + $thumb = $this->e($ext['thumb'] ?? ''); + + if ($uri === '') { + return ''; + } + + $thumbHtml = $thumb !== '' + ? "\"\"" + : ''; + + return << + {$thumbHtml} +
+
{$uri}
+ {$title} +
{$desc}
+
+ + HTML; + } + + private function renderQuotedRecord(array $record): string { + if (empty($record)) { + return ''; + } + if (!empty($record['notFound'])) { + return '
[Post not found]
'; + } + if (!empty($record['blocked'])) { + return '
[Post from blocked account]
'; + } + + $author = $record['author'] ?? []; + $value = $record['value'] ?? []; + $embeds = $record['embeds'] ?? []; + + $handle = $author['handle'] ?? ''; + $displayName = $this->e($author['displayName'] ?? $handle); + $text = nl2br($this->applyFacets($value['text'] ?? '', $value['facets'] ?? [])); + $rkey = basename(rtrim($record['uri'] ?? '', '/')); + $postUrl = 'https://bsky.app/profile/' . rawurlencode($handle) . '/post/' . $rkey; + + $embedsHtml = ''; + foreach ($embeds as $embed) { + $embedsHtml .= $this->renderEmbed($embed); + } + + return << +
+ {$displayName} + @{$this->e($handle)} +
+
{$text}
+ {$embedsHtml} +
+ View on Bluesky ↗ +
+ + HTML; + } + + private function renderVideo(array $embed): string { + $thumb = $this->e($embed['thumbnail'] ?? ''); + $playlist = $this->e($embed['playlist'] ?? ''); + $alt = $this->e($embed['alt'] ?? 'Video'); + $thumbHtml = $thumb !== '' + ? "\"{$alt}\"" + : ''; + $playlistHtml = $playlist !== '' + ? "
▶ Watch video
" + : ''; + return "
{$thumbHtml}{$playlistHtml}
"; + } + + // ------------------------------------------------------------------------- + // Rich text: apply facets (mentions, links, hashtags) + // ------------------------------------------------------------------------- + + /** + * Converts a Bluesky post's plain text + facets into HTML with working links. + * Facets use UTF-8 byte offsets, so we work on the raw bytes. + */ + private function applyFacets(string $text, array $facets): string { + if (empty($facets)) { + return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + usort($facets, static fn($a, $b) => ($a['index']['byteStart'] ?? 0) <=> ($b['index']['byteStart'] ?? 0)); + + $result = ''; + $bytePos = 0; + + foreach ($facets as $facet) { + $start = (int) ($facet['index']['byteStart'] ?? 0); + $end = (int) ($facet['index']['byteEnd'] ?? 0); + + if ($start < $bytePos || $end <= $start) { + continue; + } + + $result .= htmlspecialchars(substr($text, $bytePos, $start - $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + $slice = substr($text, $start, $end - $start); + $feature = $facet['features'][0] ?? []; + $ftype = $feature['$type'] ?? ''; + $escaped = htmlspecialchars($slice, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + if ($ftype === 'app.bsky.richtext.facet#link') { + $href = $this->e($feature['uri'] ?? '#'); + $result .= "{$escaped}"; + } elseif ($ftype === 'app.bsky.richtext.facet#mention') { + $did = $this->e($feature['did'] ?? ''); + $result .= "{$escaped}"; + } elseif ($ftype === 'app.bsky.richtext.facet#tag') { + $tag = $this->e($feature['tag'] ?? ltrim($slice, '#')); + $result .= "{$escaped}"; + } else { + $result .= $escaped; + } + + $bytePos = $end; + } + + $result .= htmlspecialchars(substr($text, $bytePos), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + return $result; + } + + // ------------------------------------------------------------------------- + // Utility + // ------------------------------------------------------------------------- + + private function e(string $s): string { + return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..9fe966b --- /dev/null +++ b/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "Bluesky Threads", + "author": "thatguygriff", + "description": "Fetches the full Bluesky reply thread and embedded post content into a single article.", + "version": "0.1.0", + "entrypoint": "BlueskyThreads", + "type": "user" +}