First commit

This commit is contained in:
2026-03-25 20:50:17 -03:00
commit c3f3efbf33
6 changed files with 626 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:docs.bsky.app)"
]
}
}

48
CLAUDE.md Normal file
View File

@@ -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, 11000, 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` (01000), `parentHeight` (01000)
- 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

49
README.md Normal file
View File

@@ -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`

23
configure.phtml Normal file
View File

@@ -0,0 +1,23 @@
<?php
if (!defined('FRESHRSS')) {
die();
}
/** @var BlueskyThreadsExtension $this */
$depth = (int) ($this->getUserConfigurationValue('depth') ?: 10);
?>
<table class="table-striped">
<tr>
<th>
<label for="depth">Thread depth</label>
</th>
<td>
<input type="number" id="depth" name="depth"
value="<?= $depth ?>" min="1" max="1000"
class="short">
<p class="help-block">
How many levels of replies to fetch (default: 10).
The Bluesky API allows up to 1000.
</p>
</td>
</tr>
</table>

491
extension.php Normal file
View File

@@ -0,0 +1,491 @@
<?php
declare(strict_types=1);
final class BlueskyThreadsExtension extends Minz_Extension {
private const API_BASE = 'https://public.api.bsky.app/xrpc';
private const POST_URL_PATTERN = '#bsky\.app/profile/([^/]+)/post/([^/?#\s]+)#';
// How long a cached thread is considered fresh, based on the post's age.
// Posts older than FREEZE_AGE are never re-fetched.
private const FREEZE_AGE = 7 * 86400; // 7 days
private const REFRESH_AGES = [
3600 => 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 .= '<div class="bsky-replies" style="border-left:2px solid #e1e8ed;margin-left:24px;padding-left:0;">';
foreach ($node['replies'] as $reply) {
if (isset($reply['post'])) {
$html .= $this->renderThread($reply, false);
}
}
$html .= '</div>';
}
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 !== ''
? '<img src="' . $this->e($avatar) . '" alt="" style="width:40px;height:40px;border-radius:50%;flex-shrink:0;">'
: '<span style="width:40px;height:40px;border-radius:50%;background:#ccc;display:inline-block;flex-shrink:0;"></span>';
$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 .= '<span style="margin-right:12px;">' . $n . ' ' . $label . ($n !== 1 ? 's' : '') . '</span>';
}
}
$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 <<<HTML
<div class="bsky-post" style="{$style}">
<div style="display:flex;align-items:flex-start;gap:10px;margin-bottom:8px;">
<a href="https://bsky.app/profile/{$this->e($handle)}" style="text-decoration:none;">{$avatarHtml}</a>
<div>
<a href="https://bsky.app/profile/{$this->e($handle)}" style="text-decoration:none;color:inherit;">
<strong>{$this->e($displayName)}</strong>
</a><br>
<span style="color:#536471;font-size:0.85em;">@{$this->e($handle)}</span>
</div>
</div>
<div class="bsky-text" style="margin-bottom:8px;white-space:pre-wrap;word-break:break-word;">{$textHtml}</div>
{$embedHtml}
<div style="margin-top:8px;font-size:0.8em;color:#536471;">
{$stats}
<a href="{$this->e($postUrl)}" style="color:#536471;">View on Bluesky ↗</a>
</div>
</div>
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 .= "<a href=\"{$full}\" target=\"_blank\" rel=\"noopener\">"
. "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"width:100%;height:180px;object-fit:cover;border-radius:8px;display:block;\">"
. '</a>';
}
return "<div style=\"display:grid;grid-template-columns:{$cols};gap:4px;margin-top:8px;\">{$inner}</div>";
}
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 !== ''
? "<img src=\"{$thumb}\" alt=\"\" style=\"width:100%;height:140px;object-fit:cover;border-radius:8px 8px 0 0;display:block;\">"
: '';
return <<<HTML
<div style="border:1px solid #cfd9de;border-radius:12px;overflow:hidden;margin-top:8px;">
{$thumbHtml}
<div style="padding:10px 12px;">
<div style="font-size:0.8em;color:#536471;margin-bottom:2px;">{$uri}</div>
<a href="{$uri}" target="_blank" rel="noopener" style="font-weight:600;text-decoration:none;">{$title}</a>
<div style="font-size:0.85em;color:#536471;margin-top:2px;">{$desc}</div>
</div>
</div>
HTML;
}
private function renderQuotedRecord(array $record): string {
if (empty($record)) {
return '';
}
if (!empty($record['notFound'])) {
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post not found]</div>';
}
if (!empty($record['blocked'])) {
return '<div style="border:1px solid #cfd9de;border-radius:12px;padding:10px 12px;margin-top:8px;color:#536471;">[Post from blocked account]</div>';
}
$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 <<<HTML
<div style="border:1px solid #0085ff;border-radius:12px;padding:10px 12px;margin-top:8px;">
<div style="font-size:0.85em;margin-bottom:6px;">
<strong>{$displayName}</strong>
<span style="color:#536471;margin-left:4px;">@{$this->e($handle)}</span>
</div>
<div style="white-space:pre-wrap;word-break:break-word;">{$text}</div>
{$embedsHtml}
<div style="margin-top:6px;font-size:0.75em;">
<a href="{$this->e($postUrl)}" style="color:#536471;">View on Bluesky ↗</a>
</div>
</div>
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 !== ''
? "<img src=\"{$thumb}\" alt=\"{$alt}\" style=\"max-width:100%;border-radius:8px;\">"
: '';
$playlistHtml = $playlist !== ''
? "<div style=\"margin-top:4px;font-size:0.8em;\"><a href=\"{$playlist}\" target=\"_blank\" rel=\"noopener\">▶ Watch video</a></div>"
: '';
return "<div style=\"margin-top:8px;\">{$thumbHtml}{$playlistHtml}</div>";
}
// -------------------------------------------------------------------------
// 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 .= "<a href=\"{$href}\" target=\"_blank\" rel=\"noopener\">{$escaped}</a>";
} elseif ($ftype === 'app.bsky.richtext.facet#mention') {
$did = $this->e($feature['did'] ?? '');
$result .= "<a href=\"https://bsky.app/profile/{$did}\">{$escaped}</a>";
} elseif ($ftype === 'app.bsky.richtext.facet#tag') {
$tag = $this->e($feature['tag'] ?? ltrim($slice, '#'));
$result .= "<a href=\"https://bsky.app/hashtag/{$tag}\">{$escaped}</a>";
} 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');
}
}

8
metadata.json Normal file
View File

@@ -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"
}