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('entry_before_insert', [$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('entry_before_display', [$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; } $rootAuthorDid = $data['thread']['post']['author']['did'] ?? null; $html = $this->renderThread($data['thread'], true, $rootAuthorDid); $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 $rootAuthorDid = null): string { if (!isset($node['post'])) { return ''; } $html = $this->renderPost($node['post'], $isRoot); if (!empty($node['replies'])) { $repliesHtml = ''; foreach ($node['replies'] as $reply) { if (!isset($reply['post'])) { continue; } if ($rootAuthorDid !== null && ($reply['post']['author']['did'] ?? null) !== $rootAuthorDid) { continue; } $repliesHtml .= $this->renderThread($reply, false, $rootAuthorDid); } if ($repliesHtml !== '') { $html .= '