Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(news): ingest @artistnewsnetwork Bluesky headlines as external news-posts

- add news-posts.external schema + sparse unique index on external.postUri
- new backend module fetches trusted Bluesky accounts via app view and
projects external link cards / facet links into news-posts
- ac-news CLI gets a pull-bluesky subcommand for manual backfill
- silo runs a periodic ingest loop (default 10m) + admin endpoints
(/api/news/bluesky/status, /api/news/bluesky/pull) so future posts
auto-appear in the headline list
- SSR: external posts attribute to the Bluesky handle with a link to
bsky.app and a source badge, still commentable via the usual AC flow

+1161 -9
+64
at/news-cli.mjs
··· 18 18 19 19 import { MongoClient } from "mongodb"; 20 20 import { AtpAgent } from "@atproto/api"; 21 + import { 22 + ingestAll, 23 + ingestFromActor, 24 + getConfiguredSources, 25 + } from "../system/backend/news-bluesky-ingest.mjs"; 21 26 import { config } from "dotenv"; 22 27 import { execSync } from "child_process"; 23 28 import { randomBytes } from "crypto"; ··· 433 438 } 434 439 435 440 // --------------------------------------------------------------------------- 441 + // Bluesky ingest (external headlines from trusted sources) 442 + // --------------------------------------------------------------------------- 443 + 444 + async function commandPullBluesky(args) { 445 + const actor = args._[1]; 446 + const limit = parseInt(args.limit) || 30; 447 + 448 + await withDb(async (db) => { 449 + const database = { db }; 450 + const runOne = !!actor; 451 + 452 + if (runOne) { 453 + console.log(`\n Pulling Bluesky feed: ${actor} (limit ${limit})\n`); 454 + const result = await ingestFromActor(database, actor, { 455 + limit, 456 + log: (line) => console.log(` ${line}`), 457 + }); 458 + console.log( 459 + `\n ${actor}: +${result.inserted} inserted, ${result.skipped} skipped, ${result.errors.length} errors`, 460 + ); 461 + if (result.errors.length) { 462 + for (const err of result.errors) { 463 + console.log(` ! ${err.uri || ""} ${err.message}`); 464 + } 465 + } 466 + return; 467 + } 468 + 469 + const sources = getConfiguredSources(); 470 + console.log(`\n Pulling ${sources.length} Bluesky source(s):\n`); 471 + for (const src of sources) console.log(` - ${src}`); 472 + console.log(); 473 + 474 + const results = await ingestAll(database, { 475 + limit, 476 + log: (line) => console.log(` ${line}`), 477 + }); 478 + for (const r of results) { 479 + console.log( 480 + `\n ${r.actor}: +${r.inserted} inserted, ${r.skipped} skipped, ${r.errors.length} errors`, 481 + ); 482 + if (r.errors.length) { 483 + for (const err of r.errors) { 484 + console.log(` ! ${err.uri || ""} ${err.message}`); 485 + } 486 + } 487 + } 488 + }); 489 + } 490 + 491 + // --------------------------------------------------------------------------- 436 492 // Screenshot (via oven) 437 493 // --------------------------------------------------------------------------- 438 494 ··· 502 558 edit ... --dry-run Preview without saving 503 559 delete <code> Delete a post (admin) 504 560 561 + External (trusted third-party sources): 562 + pull-bluesky Pull all configured Bluesky sources 563 + pull-bluesky <handle-or-did> Pull a specific Bluesky account 564 + pull-bluesky ... --limit 30 Override per-source post limit 565 + 505 566 Examples: 506 567 ac-news commits --since "1 week ago" 507 568 ac-news post "Dev Update" "The native OS build system got a major overhaul..." ··· 510 571 ac-news edit ncd2 --replace "https://aesthetic.computer)" --with "https://aesthetic.computer/chat)" 511 572 ac-news screenshot notepat 512 573 ac-news list 574 + ac-news pull-bluesky # pull all trusted Bluesky sources 575 + ac-news pull-bluesky artistnewsnetwork.bsky.social 513 576 `); 514 577 } 515 578 ··· 524 587 edit: commandEdit, 525 588 delete: commandDelete, 526 589 screenshot: commandScreenshot, 590 + "pull-bluesky": commandPullBluesky, 527 591 }; 528 592 529 593 async function main() {
+224
silo/bluesky-ingest.mjs
··· 1 + // bluesky-ingest.mjs 2 + // Pulls headlines from trusted Bluesky accounts into news-posts. 3 + // Self-contained: uses fetch + crypto, no extra deps beyond what silo ships. 4 + // 5 + // Mirrors the logic of ../system/backend/news-bluesky-ingest.mjs, but rewritten 6 + // to avoid the @atproto/api and nanoid dependencies so it can run inside silo. 7 + 8 + import { randomBytes } from "node:crypto"; 9 + 10 + const BSKY_APPVIEW = "https://public.api.bsky.app"; 11 + const DEFAULT_LIMIT = 30; 12 + const MAX_TITLE = 200; 13 + const MAX_TEXT = 5000; 14 + 15 + // ---- Config ---------------------------------------------------------------- 16 + 17 + export function getConfiguredSources(env = process.env) { 18 + const raw = env.NEWS_EXTERNAL_SOURCES; 19 + if (!raw) return ["artistnewsnetwork.bsky.social"]; 20 + return String(raw).split(",").map((s) => s.trim()).filter(Boolean); 21 + } 22 + 23 + // ---- Short-code generator (same alphabet as the rest of the system) -------- 24 + 25 + const CODE_ALPHABET = "bcdfghjklmnpqrstvwxyzaeiou23456789"; 26 + const CODE_LEN = 3; 27 + 28 + function makeCandidate() { 29 + const bytes = randomBytes(CODE_LEN); 30 + return Array.from(bytes).map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join(""); 31 + } 32 + 33 + async function generateUniqueCode(posts) { 34 + for (let i = 0; i < 100; i++) { 35 + const candidate = `n${makeCandidate()}`; 36 + const existing = await posts.findOne({ code: candidate }); 37 + if (!existing) return candidate; 38 + } 39 + throw new Error("Could not generate a unique news code after 100 attempts"); 40 + } 41 + 42 + // ---- Pure helpers ---------------------------------------------------------- 43 + 44 + function truncate(value, max) { 45 + if (!value) return ""; 46 + const trimmed = String(value).trim(); 47 + return trimmed.length > max ? trimmed.slice(0, max) : trimmed; 48 + } 49 + 50 + function stripUrls(text) { 51 + if (!text) return ""; 52 + return text.replace(/https?:\/\/\S+/gi, "").replace(/\s+/g, " ").trim(); 53 + } 54 + 55 + function extractExternalEmbed(post) { 56 + const embed = post?.embed; 57 + if (embed?.$type === "app.bsky.embed.external#view" && embed.external?.uri) { 58 + return { uri: embed.external.uri, title: embed.external.title || "" }; 59 + } 60 + const recordEmbed = post?.record?.embed; 61 + if (recordEmbed?.$type === "app.bsky.embed.external" && recordEmbed.external?.uri) { 62 + return { uri: recordEmbed.external.uri, title: recordEmbed.external.title || "" }; 63 + } 64 + return null; 65 + } 66 + 67 + function extractFirstFacetLink(post) { 68 + const facets = post?.record?.facets; 69 + if (!Array.isArray(facets)) return null; 70 + for (const f of facets) { 71 + const link = f?.features?.find((x) => x?.$type === "app.bsky.richtext.facet#link" && x.uri); 72 + if (link) return link.uri; 73 + } 74 + return null; 75 + } 76 + 77 + function projectPost(post) { 78 + const record = post?.record; 79 + if (!record) return null; 80 + const rawText = record.text || ""; 81 + const external = extractExternalEmbed(post); 82 + if (external) { 83 + return { 84 + title: truncate(external.title || stripUrls(rawText) || "Untitled link", MAX_TITLE), 85 + url: external.uri, 86 + text: truncate(stripUrls(rawText), MAX_TEXT), 87 + }; 88 + } 89 + const facetLink = extractFirstFacetLink(post); 90 + if (facetLink) { 91 + return { 92 + title: truncate(stripUrls(rawText) || "Untitled link", MAX_TITLE), 93 + url: facetLink, 94 + text: truncate(stripUrls(rawText), MAX_TEXT), 95 + }; 96 + } 97 + return null; 98 + } 99 + 100 + function parseAtUri(uri) { 101 + const match = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/.exec(uri || ""); 102 + if (!match) return null; 103 + return { did: match[1], collection: match[2], rkey: match[3] }; 104 + } 105 + 106 + // ---- Bluesky appview calls (fetch, no auth) ------------------------------- 107 + 108 + async function xrpcGet(method, params) { 109 + const qs = new URLSearchParams(params).toString(); 110 + const url = `${BSKY_APPVIEW}/xrpc/${method}?${qs}`; 111 + const res = await fetch(url, { 112 + headers: { "User-Agent": "AestheticNewsBot/1.0 (+https://news.aesthetic.computer)" }, 113 + }); 114 + if (!res.ok) { 115 + const body = await res.text().catch(() => ""); 116 + throw new Error(`${method} ${res.status}: ${body.slice(0, 200)}`); 117 + } 118 + return res.json(); 119 + } 120 + 121 + async function resolveProfile(actor) { 122 + const data = await xrpcGet("app.bsky.actor.getProfile", { actor }); 123 + if (!data?.did) throw new Error(`Could not resolve Bluesky actor: ${actor}`); 124 + return { did: data.did, handle: data.handle || actor }; 125 + } 126 + 127 + async function getAuthorFeed(did, limit) { 128 + const data = await xrpcGet("app.bsky.feed.getAuthorFeed", { 129 + actor: did, 130 + filter: "posts_no_replies", 131 + limit: String(limit), 132 + }); 133 + return data?.feed || []; 134 + } 135 + 136 + // ---- Core ingest ---------------------------------------------------------- 137 + 138 + /** 139 + * Pull posts from one Bluesky actor into db.collection("news-posts"). 140 + * Returns { actor, did, handle, inserted, skipped, errors, createdCodes }. 141 + */ 142 + export async function ingestFromActor(db, actor, options = {}) { 143 + const { limit = DEFAULT_LIMIT, now = () => new Date(), log = () => {} } = options; 144 + const posts = db.collection("news-posts"); 145 + 146 + const profile = await resolveProfile(actor); 147 + const feed = await getAuthorFeed(profile.did, limit); 148 + 149 + let inserted = 0; 150 + let skipped = 0; 151 + const errors = []; 152 + const createdCodes = []; 153 + 154 + for (const item of feed) { 155 + const post = item?.post; 156 + if (!post?.uri) { skipped++; continue; } 157 + if (item?.reason?.$type === "app.bsky.feed.defs#reasonRepost") { skipped++; continue; } 158 + if (post?.record?.reply) { skipped++; continue; } 159 + 160 + const existing = await posts.findOne({ "external.postUri": post.uri }); 161 + if (existing) { skipped++; continue; } 162 + 163 + const projected = projectPost(post); 164 + if (!projected?.title) { skipped++; continue; } 165 + 166 + const parsed = parseAtUri(post.uri); 167 + const postedAt = post.record?.createdAt ? new Date(post.record.createdAt) : now(); 168 + const fetchedAt = now(); 169 + 170 + try { 171 + const code = await generateUniqueCode(posts); 172 + const doc = { 173 + code, 174 + title: projected.title, 175 + url: projected.url, 176 + text: projected.text, 177 + user: null, 178 + when: postedAt, 179 + updated: fetchedAt, 180 + score: 1, 181 + commentCount: 0, 182 + status: "live", 183 + external: { 184 + source: "bsky", 185 + did: profile.did, 186 + handle: profile.handle, 187 + postUri: post.uri, 188 + postCid: post.cid || null, 189 + postedAt, 190 + fetchedAt, 191 + }, 192 + atproto: parsed ? { did: parsed.did, uri: post.uri, rkey: parsed.rkey } : undefined, 193 + }; 194 + await posts.insertOne(doc); 195 + inserted++; 196 + createdCodes.push(code); 197 + log(`✅ ${code} ← ${post.uri}`); 198 + } catch (error) { 199 + if (error?.code === 11000) { skipped++; continue; } 200 + errors.push({ uri: post.uri, message: error.message }); 201 + log(`❌ ${post.uri}: ${error.message}`); 202 + } 203 + } 204 + 205 + return { actor, did: profile.did, handle: profile.handle, inserted, skipped, errors, createdCodes }; 206 + } 207 + 208 + /** 209 + * Pull from every configured source. Returns an array of result summaries. 210 + */ 211 + export async function ingestAll(db, options = {}) { 212 + const sources = options.sources || getConfiguredSources(); 213 + const results = []; 214 + for (const actor of sources) { 215 + try { 216 + results.push(await ingestFromActor(db, actor, options)); 217 + } catch (error) { 218 + results.push({ actor, inserted: 0, skipped: 0, errors: [{ message: error.message }] }); 219 + } 220 + } 221 + return results; 222 + } 223 + 224 + export const __testing = { projectPost, stripUrls, parseAtUri, extractExternalEmbed, extractFirstFacetLink };
+1
silo/deploy.fish
··· 60 60 echo -e "$GREEN-> Uploading silo files...$NC" 61 61 scp -i $SSH_KEY -o StrictHostKeyChecking=no \ 62 62 $SCRIPT_DIR/server.mjs \ 63 + $SCRIPT_DIR/bluesky-ingest.mjs \ 63 64 $SCRIPT_DIR/dashboard.html \ 64 65 $SCRIPT_DIR/package.json \ 65 66 $SCRIPT_DIR/package-lock.json \
+69
silo/server.mjs
··· 18 18 IgLoginTwoFactorRequiredError, 19 19 IgLoginBadPasswordError, 20 20 } from "instagram-private-api"; 21 + import { ingestAll as ingestBluesky, ingestFromActor as ingestBlueskyActor, getConfiguredSources as getBlueskySources } from "./bluesky-ingest.mjs"; 21 22 22 23 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 23 24 ··· 1766 1767 res.send(dashboardHtml); 1767 1768 }); 1768 1769 1770 + // --- Bluesky ingest (external news headlines from trusted sources) --- 1771 + const BSKY_INGEST_INTERVAL_MS = parseInt(process.env.NEWS_BLUESKY_INTERVAL_MS || "", 10) || 10 * 60 * 1000; // default 10 min 1772 + const BSKY_INGEST_LIMIT = parseInt(process.env.NEWS_BLUESKY_LIMIT || "", 10) || 30; 1773 + let bskyIngestTimer = null; 1774 + let bskyLastRun = { when: null, results: [], error: null, runCount: 0 }; 1775 + 1776 + async function runBlueskyIngestOnce(actor) { 1777 + if (!db) { 1778 + const err = "mongo not connected"; 1779 + bskyLastRun = { when: new Date().toISOString(), results: [], error: err, runCount: bskyLastRun.runCount }; 1780 + return bskyLastRun; 1781 + } 1782 + try { 1783 + const results = actor 1784 + ? [await ingestBlueskyActor(db, actor, { limit: BSKY_INGEST_LIMIT, log: (line) => log("info", `bsky: ${line}`) })] 1785 + : await ingestBluesky(db, { limit: BSKY_INGEST_LIMIT, log: (line) => log("info", `bsky: ${line}`) }); 1786 + const totalInserted = results.reduce((n, r) => n + (r.inserted || 0), 0); 1787 + const totalErrors = results.reduce((n, r) => n + (r.errors?.length || 0), 0); 1788 + bskyLastRun = { 1789 + when: new Date().toISOString(), 1790 + results, 1791 + error: null, 1792 + runCount: (bskyLastRun.runCount || 0) + 1, 1793 + }; 1794 + if (totalInserted > 0 || totalErrors > 0) { 1795 + log("info", `bsky ingest: +${totalInserted} new, ${totalErrors} errors`); 1796 + } 1797 + return bskyLastRun; 1798 + } catch (err) { 1799 + bskyLastRun = { 1800 + when: new Date().toISOString(), 1801 + results: [], 1802 + error: err.message, 1803 + runCount: (bskyLastRun.runCount || 0) + 1, 1804 + }; 1805 + log("error", `bsky ingest failed: ${err.message}`); 1806 + return bskyLastRun; 1807 + } 1808 + } 1809 + 1810 + function startBlueskyIngestLoop() { 1811 + if (bskyIngestTimer) return; 1812 + const sources = getBlueskySources(); 1813 + log("info", `bsky ingest: ${sources.length} source(s), every ${Math.round(BSKY_INGEST_INTERVAL_MS / 60000)}m — ${sources.join(", ")}`); 1814 + // Kick off a first run ~30s after boot to avoid colliding with other startup work. 1815 + setTimeout(() => runBlueskyIngestOnce().catch(() => {}), 30_000); 1816 + bskyIngestTimer = setInterval(() => runBlueskyIngestOnce().catch(() => {}), BSKY_INGEST_INTERVAL_MS); 1817 + } 1818 + 1819 + app.get("/api/news/bluesky/status", (req, res) => { 1820 + res.json({ 1821 + sources: getBlueskySources(), 1822 + intervalMs: BSKY_INGEST_INTERVAL_MS, 1823 + limit: BSKY_INGEST_LIMIT, 1824 + lastRun: bskyLastRun, 1825 + }); 1826 + }); 1827 + 1828 + app.post("/api/news/bluesky/pull", async (req, res) => { 1829 + const actor = typeof req.body?.actor === "string" ? req.body.actor.trim() : ""; 1830 + const result = await runBlueskyIngestOnce(actor || undefined); 1831 + res.json(result); 1832 + }); 1833 + 1769 1834 // --- 404 --- 1770 1835 app.use((req, res) => res.status(404).json({ error: "Not found" })); 1771 1836 ··· 1810 1875 // Restore TikTok session on startup 1811 1876 loadTiktokSession().catch(() => {}); 1812 1877 1878 + // Start the Bluesky news ingest loop (pulls headlines from trusted sources). 1879 + startBlueskyIngestLoop(); 1880 + 1813 1881 server.listen(PORT, () => { 1814 1882 const proto = dev ? "https" : "http"; 1815 1883 log("info", `silo running on ${proto}://localhost:${PORT}`); ··· 1818 1886 // --- Shutdown --- 1819 1887 function shutdown(signal) { 1820 1888 log("info", `received ${signal}, shutting down...`); 1889 + if (bskyIngestTimer) { clearInterval(bskyIngestTimer); bskyIngestTimer = null; } 1821 1890 if (changeStream) changeStream.close().catch(() => {}); 1822 1891 wss.clients.forEach((ws) => ws.close()); 1823 1892 server.close();
+264
system/backend/news-bluesky-ingest.mjs
··· 1 + // news-bluesky-ingest.mjs 2 + // Pulls posts from trusted Bluesky accounts into news-posts as "external" entries. 3 + // External posts are attributed to a Bluesky DID rather than an AC @handle. 4 + 5 + import { AtpAgent } from "@atproto/api"; 6 + import { generateUniqueCode } from "./generate-short-code.mjs"; 7 + 8 + const BSKY_APPVIEW = "https://public.api.bsky.app"; 9 + const DEFAULT_LIMIT = 30; 10 + const MAX_TITLE = 200; 11 + const MAX_TEXT = 5000; 12 + 13 + // Comma-separated list of trusted Bluesky sources (DIDs or handles). 14 + // Example: NEWS_EXTERNAL_SOURCES="artistnewsnetwork.bsky.social" 15 + function parseTrustedSources(raw) { 16 + if (!raw) return []; 17 + return String(raw) 18 + .split(",") 19 + .map((s) => s.trim()) 20 + .filter(Boolean); 21 + } 22 + 23 + export function getConfiguredSources(env = process.env) { 24 + const configured = parseTrustedSources(env.NEWS_EXTERNAL_SOURCES); 25 + if (configured.length) return configured; 26 + return ["artistnewsnetwork.bsky.social"]; 27 + } 28 + 29 + function truncate(value, max) { 30 + if (!value) return ""; 31 + const trimmed = String(value).trim(); 32 + return trimmed.length > max ? trimmed.slice(0, max) : trimmed; 33 + } 34 + 35 + // Strip URLs from the prose body — they're already surfaced as the `url` field. 36 + function stripUrls(text) { 37 + if (!text) return ""; 38 + return text.replace(/https?:\/\/\S+/gi, "").replace(/\s+/g, " ").trim(); 39 + } 40 + 41 + function extractExternalEmbed(post) { 42 + const embed = post?.embed; 43 + if (!embed) return null; 44 + // Hydrated appview shape 45 + if (embed.$type === "app.bsky.embed.external#view" && embed.external?.uri) { 46 + return { 47 + uri: embed.external.uri, 48 + title: embed.external.title || "", 49 + description: embed.external.description || "", 50 + }; 51 + } 52 + // Raw record shape (fallback) 53 + const recordEmbed = post?.record?.embed; 54 + if (recordEmbed?.$type === "app.bsky.embed.external" && recordEmbed.external?.uri) { 55 + return { 56 + uri: recordEmbed.external.uri, 57 + title: recordEmbed.external.title || "", 58 + description: recordEmbed.external.description || "", 59 + }; 60 + } 61 + return null; 62 + } 63 + 64 + // First link facet in the post record, if any. 65 + function extractFirstFacetLink(post) { 66 + const facets = post?.record?.facets; 67 + if (!Array.isArray(facets)) return null; 68 + for (const facet of facets) { 69 + const feature = facet?.features?.find( 70 + (f) => f?.$type === "app.bsky.richtext.facet#link" && f.uri, 71 + ); 72 + if (feature) return feature.uri; 73 + } 74 + return null; 75 + } 76 + 77 + // Produce { title, url, text } for insertion, or null to skip. 78 + function projectPost(post) { 79 + const record = post?.record; 80 + if (!record) return null; 81 + const rawText = record.text || ""; 82 + const external = extractExternalEmbed(post); 83 + 84 + if (external) { 85 + const title = truncate(external.title || stripUrls(rawText) || "Untitled link", MAX_TITLE); 86 + const text = truncate(stripUrls(rawText), MAX_TEXT); 87 + return { title, url: external.uri, text }; 88 + } 89 + 90 + const facetLink = extractFirstFacetLink(post); 91 + if (facetLink) { 92 + const title = truncate(stripUrls(rawText) || "Untitled link", MAX_TITLE); 93 + const text = truncate(stripUrls(rawText), MAX_TEXT); 94 + return { title, url: facetLink, text }; 95 + } 96 + 97 + // No link at all — skip. We only ingest headline-style posts. 98 + return null; 99 + } 100 + 101 + async function resolveProfile(agent, actor) { 102 + const result = await agent.app.bsky.actor.getProfile({ actor }); 103 + const data = result?.data || result; 104 + if (!data?.did) throw new Error(`Could not resolve Bluesky actor: ${actor}`); 105 + return { did: data.did, handle: data.handle || actor }; 106 + } 107 + 108 + async function fetchAuthorFeed(agent, did, limit) { 109 + const result = await agent.app.bsky.feed.getAuthorFeed({ 110 + actor: did, 111 + filter: "posts_no_replies", 112 + limit, 113 + }); 114 + const data = result?.data || result; 115 + return data?.feed || []; 116 + } 117 + 118 + function parseAtUri(uri) { 119 + // at://did:plc:xxx/app.bsky.feed.post/abc 120 + const match = /^at:\/\/([^/]+)\/([^/]+)\/(.+)$/.exec(uri || ""); 121 + if (!match) return null; 122 + return { did: match[1], collection: match[2], rkey: match[3] }; 123 + } 124 + 125 + /** 126 + * Ingest recent posts from one Bluesky actor into news-posts. 127 + * Returns { inserted, skipped, errors, actor }. 128 + */ 129 + export async function ingestFromActor(database, actor, options = {}) { 130 + const { 131 + limit = DEFAULT_LIMIT, 132 + agent: providedAgent, 133 + now = () => new Date(), 134 + generateCode = generateUniqueCode, 135 + log = () => {}, 136 + } = options; 137 + 138 + const posts = database.db.collection("news-posts"); 139 + 140 + const agent = providedAgent || new AtpAgent({ service: BSKY_APPVIEW }); 141 + const profile = await resolveProfile(agent, actor); 142 + const feed = await fetchAuthorFeed(agent, profile.did, limit); 143 + 144 + let inserted = 0; 145 + let skipped = 0; 146 + const errors = []; 147 + const createdCodes = []; 148 + 149 + for (const item of feed) { 150 + const post = item?.post; 151 + if (!post?.uri) { 152 + skipped++; 153 + continue; 154 + } 155 + 156 + // Skip reposts — we only want the actor's original posts. 157 + if (item?.reason?.$type === "app.bsky.feed.defs#reasonRepost") { 158 + skipped++; 159 + continue; 160 + } 161 + // Skip replies 162 + if (post?.record?.reply) { 163 + skipped++; 164 + continue; 165 + } 166 + 167 + const existing = await posts.findOne({ "external.postUri": post.uri }); 168 + if (existing) { 169 + skipped++; 170 + continue; 171 + } 172 + 173 + const projected = projectPost(post); 174 + if (!projected || !projected.title) { 175 + skipped++; 176 + continue; 177 + } 178 + 179 + const parsed = parseAtUri(post.uri); 180 + const postedAt = post.record?.createdAt 181 + ? new Date(post.record.createdAt) 182 + : now(); 183 + const fetchedAt = now(); 184 + 185 + try { 186 + const shortCode = await generateCode(posts, { mode: "random" }); 187 + const code = `n${shortCode}`; 188 + const doc = { 189 + code, 190 + title: projected.title, 191 + url: projected.url, 192 + text: projected.text, 193 + user: null, 194 + when: postedAt, 195 + updated: fetchedAt, 196 + score: 1, 197 + commentCount: 0, 198 + status: "live", 199 + external: { 200 + source: "bsky", 201 + did: profile.did, 202 + handle: profile.handle, 203 + postUri: post.uri, 204 + postCid: post.cid || null, 205 + postedAt, 206 + fetchedAt, 207 + }, 208 + // Mirror the Bluesky record into the `atproto` field so the 209 + // existing AT permalink affordance works for external posts too. 210 + atproto: parsed 211 + ? { did: parsed.did, uri: post.uri, rkey: parsed.rkey } 212 + : undefined, 213 + }; 214 + 215 + await posts.insertOne(doc); 216 + inserted++; 217 + createdCodes.push(code); 218 + log(`✅ ${code} ← ${post.uri}`); 219 + } catch (error) { 220 + // Unique-index collision means a concurrent ingest already created it. 221 + if (error?.code === 11000) { 222 + skipped++; 223 + continue; 224 + } 225 + errors.push({ uri: post.uri, message: error.message }); 226 + log(`❌ ${post.uri}: ${error.message}`); 227 + } 228 + } 229 + 230 + return { 231 + actor, 232 + did: profile.did, 233 + handle: profile.handle, 234 + inserted, 235 + skipped, 236 + errors, 237 + createdCodes, 238 + }; 239 + } 240 + 241 + /** 242 + * Ingest from every configured trusted source. 243 + * Returns an array of per-actor result summaries. 244 + */ 245 + export async function ingestAll(database, options = {}) { 246 + const sources = options.sources || getConfiguredSources(); 247 + const results = []; 248 + for (const actor of sources) { 249 + try { 250 + const result = await ingestFromActor(database, actor, options); 251 + results.push(result); 252 + } catch (error) { 253 + results.push({ 254 + actor, 255 + inserted: 0, 256 + skipped: 0, 257 + errors: [{ message: error.message }], 258 + }); 259 + } 260 + } 261 + return results; 262 + } 263 + 264 + export const __testing = { projectPost, extractExternalEmbed, extractFirstFacetLink, stripUrls, parseAtUri };
+21
system/netlify/functions/news-api.mjs
··· 7 7 import { generateUniqueCode } from "../../backend/generate-short-code.mjs"; 8 8 import { ObjectId } from "mongodb"; 9 9 import { createNewsOnAtproto } from "../../backend/news-atproto.mjs"; 10 + import { ingestAll, ingestFromActor } from "../../backend/news-bluesky-ingest.mjs"; 10 11 11 12 // Admin users who can delete/censor content 12 13 const ADMIN_SUBS = [process.env.ADMIN_SUB].filter(Boolean); ··· 68 69 await posts.createIndex({ score: -1 }, { background: true }); 69 70 await posts.createIndex({ user: 1 }, { background: true }); 70 71 await posts.createIndex({ status: 1 }, { background: true }); 72 + await posts.createIndex( 73 + { "external.postUri": 1 }, 74 + { unique: true, sparse: true, background: true }, 75 + ); 71 76 72 77 await comments.createIndex({ postCode: 1 }, { background: true }); 73 78 await comments.createIndex({ parentId: 1 }, { background: true, sparse: true }); ··· 462 467 return redirect(redirectTo); 463 468 } 464 469 return respondFn(200, { ok: true, deleted: itemId, redirect: redirectTo }); 470 + } 471 + 472 + // Admin-only: pull fresh headlines from trusted Bluesky sources. 473 + if (route === "ingest-external") { 474 + const user = await requireUserWith(event); 475 + const isAdmin = await hasAdmin(user, "aesthetic"); 476 + if (!isAdmin) { 477 + return respondFn(403, { error: "Admin only" }); 478 + } 479 + const body = parseBody(event); 480 + const actor = sanitizeText(body.actor, 256); 481 + const limit = Math.min(parseInt(body.limit || "30", 10) || 30, 100); 482 + const results = actor 483 + ? [await ingestFromActor(database, actor, { limit })] 484 + : await ingestAll(database, { limit }); 485 + return respondFn(200, { ok: true, results }); 465 486 } 466 487 } 467 488
+64 -9
system/netlify/functions/news.mjs
··· 399 399 return { pdsLs: pdsLsUrl, uri: atprotoData.uri }; 400 400 } 401 401 402 - function renderHandle(handle) { 402 + function renderHandle(handle, options = {}) { 403 403 const safeHandle = escapeHtml(handle || "@anon"); 404 404 // Extract username without @ for the URL 405 405 const username = safeHandle.startsWith("@") ? safeHandle.slice(1) : safeHandle; 406 406 if (username === "anon") return safeHandle; 407 + // External sources (e.g. Bluesky) point at their own profile and render 408 + // as a plain external link rather than opening in an AC modal. 409 + if (options.external) { 410 + const href = escapeHtml(options.profileUrl || "#"); 411 + const badge = options.sourceLabel 412 + ? ` <span class="news-external-badge">${escapeHtml(options.sourceLabel)}</span>` 413 + : ""; 414 + return `<a href="${href}" class="news-handle-link news-external-handle" target="_blank" rel="noopener">${safeHandle}</a>${badge}`; 415 + } 407 416 const profileUrl = `https://aesthetic.computer/@${username}`; 408 417 return `<a href="${profileUrl}" class="news-modal-link news-handle-link" data-modal-url="${profileUrl}">${safeHandle}</a>`; 409 418 } ··· 552 561 ${displayUrl ? `<span class="news-domain">(<a href="${url}" target="_blank" rel="noreferrer">${displayUrl}</a>)</span>` : ""} 553 562 </div> 554 563 <div class="news-meta"> 555 - <span>by ${renderHandle(post.handle)}</span> 564 + <span>by ${renderHandle(post.handle, post.externalAttribution ? { 565 + external: true, 566 + profileUrl: post.externalAttribution.profileUrl, 567 + sourceLabel: post.externalAttribution.sourceLabel, 568 + } : undefined)}</span> 556 569 <span><a href="${itemUrl}">${formatDate(post.when)}</a></span> 557 570 <span><a href="${itemUrl}">${post.commentCount || 0} comments</a></span> 558 571 </div> ··· 601 614 return applyCommentCounts(database, docs); 602 615 } 603 616 617 + // Short display form of a Bluesky handle: "artistnewsnetwork.bsky.social" → "artistnewsnetwork" 618 + function shortBskyHandle(handle) { 619 + if (!handle) return "anon"; 620 + return handle.replace(/\.bsky\.social$/i, ""); 621 + } 622 + 604 623 async function hydrateHandles(database, docs) { 605 624 const handles = database.db.collection("@handles"); 606 625 const subs = docs.map((doc) => doc.user).filter(Boolean); 607 - if (subs.length === 0) return docs; 608 - const handleDocs = await handles.find({ _id: { $in: subs } }).toArray(); 626 + const handleDocs = subs.length 627 + ? await handles.find({ _id: { $in: subs } }).toArray() 628 + : []; 609 629 const map = new Map(handleDocs.map((h) => [h._id, h.handle])); 610 - return docs.map((doc) => ({ 611 - ...doc, 612 - handle: doc.user ? `@${map.get(doc.user) || "anon"}` : "@anon", 613 - })); 630 + return docs.map((doc) => { 631 + // External source takes precedence — attribute to the Bluesky handle/DID 632 + // rather than an AC @handle, and mark the doc so renderers link externally. 633 + if (doc.external?.source === "bsky" && doc.external?.handle) { 634 + const short = shortBskyHandle(doc.external.handle); 635 + return { 636 + ...doc, 637 + handle: `@${short}`, 638 + externalAttribution: { 639 + source: "bsky", 640 + sourceLabel: "bsky", 641 + did: doc.external.did, 642 + fullHandle: doc.external.handle, 643 + profileUrl: `https://bsky.app/profile/${doc.external.handle}`, 644 + postUrl: doc.external.postUri 645 + ? `https://bsky.app/profile/${doc.external.did}/post/${doc.external.postUri.split("/").pop()}` 646 + : null, 647 + }, 648 + }; 649 + } 650 + return { 651 + ...doc, 652 + handle: doc.user ? `@${map.get(doc.user) || "anon"}` : "@anon", 653 + }; 654 + }); 614 655 } 615 656 616 657 async function renderFrontPage(database, basePath, sort) { ··· 673 714 <span class="news-at-link"> 674 715 <a href="${atLinks.pdsLs}" target="_blank" rel="noopener" title="View on ATProto (${atLinks.uri})">🔗 AT</a> 675 716 </span>` : ''; 717 + 718 + // For external posts without body text, still show attribution so readers 719 + // can see it came from a trusted third-party source. 720 + const externalAttribHtml = (hydratedPost.externalAttribution && !hydratedPost.text) ? ` 721 + <span class="news-external-attrib">via ${renderHandle(hydratedPost.handle, { 722 + external: true, 723 + profileUrl: hydratedPost.externalAttribution.profileUrl, 724 + sourceLabel: hydratedPost.externalAttribution.sourceLabel, 725 + })} ${formatDate(hydratedPost.when)}${hydratedPost.externalAttribution.postUrl ? ` · <a href="${escapeHtml(hydratedPost.externalAttribution.postUrl)}" target="_blank" rel="noopener">original</a>` : ''}</span>` : ''; 676 726 677 727 // Check for YouTube embed 678 728 const youtubeId = parseYouTubeUrl(hydratedPost.url); ··· 831 881 </form> 832 882 </span> 833 883 <div class="news-item-meta"> 884 + ${externalAttribHtml} 834 885 ${atLinkHtml} 835 886 </div> 836 887 </td> ··· 840 891 </div> 841 892 ${hydratedPost.text ? ` 842 893 <div class="news-op-text"> 843 - <div class="news-op-meta">${renderHandle(hydratedPost.handle)} ${formatDate(hydratedPost.when)}</div> 894 + <div class="news-op-meta">${renderHandle(hydratedPost.handle, hydratedPost.externalAttribution ? { 895 + external: true, 896 + profileUrl: hydratedPost.externalAttribution.profileUrl, 897 + sourceLabel: hydratedPost.externalAttribution.sourceLabel, 898 + } : undefined)} ${formatDate(hydratedPost.when)}</div> 844 899 <div class="news-op-body">${renderMarkdown(hydratedPost.text)}</div> 845 900 </div>` : ""} 846 901 <div class="news-comments">
+21
system/public/news.aesthetic.computer/main.css
··· 455 455 text-decoration: underline; 456 456 } 457 457 458 + /* External-source attribution (e.g. Bluesky) */ 459 + .news-external-badge { 460 + display: inline-block; 461 + margin-left: 4px; 462 + padding: 0 4px; 463 + font-size: 0.7em; 464 + font-weight: normal; 465 + text-transform: uppercase; 466 + letter-spacing: 0.04em; 467 + border: 1px solid currentColor; 468 + border-radius: 3px; 469 + opacity: 0.65; 470 + vertical-align: 1px; 471 + } 472 + 473 + .news-external-attrib { 474 + opacity: 0.75; 475 + font-size: 0.9em; 476 + margin-right: 8px; 477 + } 478 + 458 479 /* ===== Item Page (HN-style table layout) ===== */ 459 480 .news-item-table { 460 481 margin-bottom: 10px;
+433
tests/news-bluesky.test.mjs
··· 1 + import assert from "node:assert/strict"; 2 + import { 3 + ingestFromActor, 4 + getConfiguredSources, 5 + __testing, 6 + } from "../system/backend/news-bluesky-ingest.mjs"; 7 + 8 + const { projectPost, stripUrls, parseAtUri } = __testing; 9 + 10 + // --- Fixtures --------------------------------------------------------------- 11 + 12 + const DID = "did:plc:testartist"; 13 + const HANDLE = "artistnewsnetwork.bsky.social"; 14 + 15 + function makePostWithExternal(overrides = {}) { 16 + return { 17 + post: { 18 + uri: `at://${DID}/app.bsky.feed.post/rkey${overrides.rkey || "a"}`, 19 + cid: `bafyre${overrides.cid || "a"}`, 20 + author: { did: DID, handle: HANDLE }, 21 + record: { 22 + $type: "app.bsky.feed.post", 23 + text: overrides.text ?? "A great exhibition opening this weekend https://gallery.example/show", 24 + createdAt: overrides.createdAt || "2026-04-10T12:00:00.000Z", 25 + embed: { 26 + $type: "app.bsky.embed.external", 27 + external: { 28 + uri: "https://gallery.example/show", 29 + title: "Gallery Show: Weekend Opening", 30 + description: "A new exhibition.", 31 + }, 32 + }, 33 + }, 34 + embed: { 35 + $type: "app.bsky.embed.external#view", 36 + external: { 37 + uri: "https://gallery.example/show", 38 + title: "Gallery Show: Weekend Opening", 39 + description: "A new exhibition.", 40 + }, 41 + }, 42 + }, 43 + }; 44 + } 45 + 46 + function makePostWithFacetLink() { 47 + return { 48 + post: { 49 + uri: `at://${DID}/app.bsky.feed.post/facetpost`, 50 + cid: "bafyrefacet", 51 + author: { did: DID, handle: HANDLE }, 52 + record: { 53 + text: "Check this out https://museum.example/piece", 54 + createdAt: "2026-04-10T13:00:00.000Z", 55 + facets: [ 56 + { 57 + features: [ 58 + { $type: "app.bsky.richtext.facet#link", uri: "https://museum.example/piece" }, 59 + ], 60 + }, 61 + ], 62 + }, 63 + }, 64 + }; 65 + } 66 + 67 + function makePlainTextPost() { 68 + return { 69 + post: { 70 + uri: `at://${DID}/app.bsky.feed.post/plain`, 71 + cid: "bafyreplain", 72 + author: { did: DID, handle: HANDLE }, 73 + record: { 74 + text: "Just thinking out loud today.", 75 + createdAt: "2026-04-10T14:00:00.000Z", 76 + }, 77 + }, 78 + }; 79 + } 80 + 81 + function makeReply() { 82 + return { 83 + post: { 84 + uri: `at://${DID}/app.bsky.feed.post/reply`, 85 + cid: "bafyrereply", 86 + author: { did: DID, handle: HANDLE }, 87 + record: { 88 + text: "Good point https://other.example/thing", 89 + createdAt: "2026-04-10T15:00:00.000Z", 90 + reply: { root: { uri: "at://x/y/z" }, parent: { uri: "at://x/y/z" } }, 91 + embed: { 92 + $type: "app.bsky.embed.external", 93 + external: { uri: "https://other.example/thing", title: "Thing" }, 94 + }, 95 + }, 96 + embed: { 97 + $type: "app.bsky.embed.external#view", 98 + external: { uri: "https://other.example/thing", title: "Thing" }, 99 + }, 100 + }, 101 + }; 102 + } 103 + 104 + function makeRepost() { 105 + return { 106 + reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 107 + post: { 108 + uri: `at://${DID}/app.bsky.feed.post/reposted`, 109 + cid: "bafyrerepost", 110 + author: { did: "did:plc:someone-else", handle: "someone.bsky.social" }, 111 + record: { 112 + text: "Repost body https://x.example/reposted", 113 + createdAt: "2026-04-10T16:00:00.000Z", 114 + embed: { 115 + $type: "app.bsky.embed.external", 116 + external: { uri: "https://x.example/reposted", title: "Reposted" }, 117 + }, 118 + }, 119 + }, 120 + }; 121 + } 122 + 123 + // --- Pure helpers ----------------------------------------------------------- 124 + 125 + function testStripUrls() { 126 + assert.equal(stripUrls("before https://a.com after"), "before after"); 127 + assert.equal(stripUrls("only text"), "only text"); 128 + assert.equal(stripUrls(""), ""); 129 + } 130 + 131 + function testParseAtUri() { 132 + const parsed = parseAtUri("at://did:plc:abc/app.bsky.feed.post/rkey123"); 133 + assert.deepEqual(parsed, { 134 + did: "did:plc:abc", 135 + collection: "app.bsky.feed.post", 136 + rkey: "rkey123", 137 + }); 138 + assert.equal(parseAtUri("not-an-at-uri"), null); 139 + } 140 + 141 + function testProjectPostExternalEmbed() { 142 + const { post } = makePostWithExternal(); 143 + const projected = projectPost(post); 144 + assert.equal(projected.title, "Gallery Show: Weekend Opening"); 145 + assert.equal(projected.url, "https://gallery.example/show"); 146 + assert.equal(projected.text, "A great exhibition opening this weekend"); 147 + } 148 + 149 + function testProjectPostFacetLinkFallback() { 150 + const { post } = makePostWithFacetLink(); 151 + const projected = projectPost(post); 152 + assert.ok(projected, "should project a post with a link facet"); 153 + assert.equal(projected.url, "https://museum.example/piece"); 154 + assert.equal(projected.title, "Check this out"); 155 + } 156 + 157 + function testProjectPostPlainTextSkipped() { 158 + const { post } = makePlainTextPost(); 159 + assert.equal(projectPost(post), null, "plain text posts with no link are skipped"); 160 + } 161 + 162 + function testConfiguredSourcesDefault() { 163 + const sources = getConfiguredSources({}); 164 + assert.ok(sources.includes("artistnewsnetwork.bsky.social")); 165 + } 166 + 167 + function testConfiguredSourcesOverride() { 168 + const sources = getConfiguredSources({ 169 + NEWS_EXTERNAL_SOURCES: "a.bsky.social, did:plc:xyz ,", 170 + }); 171 + assert.deepEqual(sources, ["a.bsky.social", "did:plc:xyz"]); 172 + } 173 + 174 + // --- Integration: ingestFromActor ------------------------------------------ 175 + 176 + function makePostsCollection() { 177 + const docs = []; 178 + return { 179 + docs, 180 + async findOne(query) { 181 + if (query?.["external.postUri"]) { 182 + return docs.find((d) => d.external?.postUri === query["external.postUri"]) || null; 183 + } 184 + return docs.find((d) => { 185 + return Object.entries(query).every(([k, v]) => d[k] === v); 186 + }) || null; 187 + }, 188 + async insertOne(doc) { 189 + const existing = docs.find((d) => d.external?.postUri === doc.external?.postUri); 190 + if (existing) { 191 + const err = new Error("dup"); 192 + err.code = 11000; 193 + throw err; 194 + } 195 + docs.push(doc); 196 + return { insertedId: doc.code }; 197 + }, 198 + }; 199 + } 200 + 201 + function makeFakeAgent(feed) { 202 + return { 203 + app: { 204 + bsky: { 205 + actor: { 206 + async getProfile({ actor }) { 207 + return { data: { did: DID, handle: HANDLE } }; 208 + }, 209 + }, 210 + feed: { 211 + async getAuthorFeed() { 212 + return { data: { feed } }; 213 + }, 214 + }, 215 + }, 216 + }, 217 + }; 218 + } 219 + 220 + async function testIngestInsertsOnlyExternalAndFacetPosts() { 221 + const posts = makePostsCollection(); 222 + const database = { 223 + db: { collection: (name) => (name === "news-posts" ? posts : null) }, 224 + }; 225 + const feed = [ 226 + makePostWithExternal({ rkey: "a" }), 227 + makePostWithFacetLink(), 228 + makePlainTextPost(), 229 + makeReply(), 230 + makeRepost(), 231 + ]; 232 + let codeCounter = 0; 233 + const result = await ingestFromActor(database, HANDLE, { 234 + agent: makeFakeAgent(feed), 235 + generateCode: async () => `gen${++codeCounter}`, 236 + }); 237 + assert.equal(result.inserted, 2, "should insert external + facet posts only"); 238 + assert.equal(result.skipped, 3, "plain-text, reply, repost are skipped"); 239 + assert.equal(posts.docs.length, 2); 240 + const first = posts.docs[0]; 241 + assert.equal(first.external.source, "bsky"); 242 + assert.equal(first.external.did, DID); 243 + assert.equal(first.external.handle, HANDLE); 244 + assert.ok(first.external.postUri.startsWith("at://")); 245 + assert.equal(first.user, null, "external posts have no AC user sub"); 246 + assert.ok(first.atproto?.uri, "external posts mirror atproto fields"); 247 + } 248 + 249 + async function testIngestDeduplicatesOnSecondRun() { 250 + const posts = makePostsCollection(); 251 + const database = { 252 + db: { collection: (name) => (name === "news-posts" ? posts : null) }, 253 + }; 254 + const feed = [makePostWithExternal({ rkey: "dup" })]; 255 + const agent = makeFakeAgent(feed); 256 + let codeCounter = 0; 257 + const genCode = async () => `dup${++codeCounter}`; 258 + 259 + const first = await ingestFromActor(database, HANDLE, { agent, generateCode: genCode }); 260 + assert.equal(first.inserted, 1); 261 + 262 + const second = await ingestFromActor(database, HANDLE, { agent, generateCode: genCode }); 263 + assert.equal(second.inserted, 0, "rerun should not insert duplicates"); 264 + assert.equal(second.skipped, 1, "rerun should skip the already-ingested post"); 265 + assert.equal(posts.docs.length, 1); 266 + } 267 + 268 + // --- SSR rendering for external posts -------------------------------------- 269 + 270 + async function testExternalAttributionRendersOnFrontPage() { 271 + const { createHandler } = await import("../system/netlify/functions/news.mjs"); 272 + 273 + const externalPost = { 274 + code: "nextrnl", 275 + title: "External Headline", 276 + url: "https://gallery.example/show", 277 + user: null, 278 + when: new Date("2026-04-10T12:00:00Z"), 279 + score: 1, 280 + commentCount: 0, 281 + status: "live", 282 + external: { 283 + source: "bsky", 284 + did: DID, 285 + handle: HANDLE, 286 + postUri: `at://${DID}/app.bsky.feed.post/rkeyA`, 287 + postedAt: new Date("2026-04-10T12:00:00Z"), 288 + fetchedAt: new Date("2026-04-10T12:05:00Z"), 289 + }, 290 + }; 291 + 292 + const collections = new Map([ 293 + ["news-posts", mockCollection([externalPost])], 294 + ["news-comments", mockCollection([])], 295 + ["@handles", mockCollection([])], 296 + ]); 297 + const database = { 298 + db: { collection: (name) => collections.get(name) || mockCollection([]) }, 299 + disconnect: async () => null, 300 + }; 301 + const handler = createHandler({ 302 + connect: async () => database, 303 + respond: (statusCode, body, headers = {}) => ({ statusCode, headers, body }), 304 + }); 305 + const res = await handler({ 306 + httpMethod: "GET", 307 + headers: { host: "localhost:8888" }, 308 + queryStringParameters: { path: "" }, 309 + }); 310 + assert.equal(res.statusCode, 200); 311 + assert.ok(res.body.includes("External Headline"), "title should render"); 312 + assert.ok( 313 + res.body.includes("bsky.app/profile/artistnewsnetwork.bsky.social"), 314 + "handle should link to bluesky profile", 315 + ); 316 + assert.ok(res.body.includes("@artistnewsnetwork"), "short handle should render"); 317 + assert.ok(res.body.includes("news-external-handle"), "external handle class should render"); 318 + } 319 + 320 + async function testExternalAttributionRendersOnItemPage() { 321 + const { createHandler } = await import("../system/netlify/functions/news.mjs"); 322 + 323 + const externalPost = { 324 + code: "nexitem", 325 + title: "External Item", 326 + url: "https://gallery.example/item", 327 + user: null, 328 + when: new Date("2026-04-10T12:00:00Z"), 329 + score: 1, 330 + commentCount: 0, 331 + status: "live", 332 + external: { 333 + source: "bsky", 334 + did: DID, 335 + handle: HANDLE, 336 + postUri: `at://${DID}/app.bsky.feed.post/itemrkey`, 337 + postedAt: new Date("2026-04-10T12:00:00Z"), 338 + fetchedAt: new Date("2026-04-10T12:05:00Z"), 339 + }, 340 + }; 341 + const collections = new Map([ 342 + ["news-posts", mockCollection([externalPost])], 343 + ["news-comments", mockCollection([])], 344 + ["@handles", mockCollection([])], 345 + ]); 346 + const database = { 347 + db: { collection: (name) => collections.get(name) || mockCollection([]) }, 348 + disconnect: async () => null, 349 + }; 350 + const handler = createHandler({ 351 + connect: async () => database, 352 + respond: (statusCode, body, headers = {}) => ({ statusCode, headers, body }), 353 + }); 354 + const res = await handler({ 355 + httpMethod: "GET", 356 + headers: { host: "localhost:8888" }, 357 + queryStringParameters: { path: "nexitem" }, 358 + }); 359 + assert.equal(res.statusCode, 200); 360 + assert.ok(res.body.includes("External Item")); 361 + assert.ok( 362 + res.body.includes("bsky.app/profile/did:plc:testartist/post/itemrkey"), 363 + "should link to original Bluesky post", 364 + ); 365 + assert.ok(res.body.includes("news-external-attrib"), "item page should tag external attribution"); 366 + } 367 + 368 + // Tiny mock collection factory (same shape news.mjs expects). 369 + function mockCollection(initial) { 370 + const docs = [...initial]; 371 + function matches(doc, query) { 372 + if (!query) return true; 373 + return Object.entries(query).every(([k, v]) => { 374 + if (v && typeof v === "object" && !Array.isArray(v)) { 375 + if (Object.hasOwn(v, "$ne")) return doc[k] !== v.$ne; 376 + if (Object.hasOwn(v, "$in")) return v.$in.includes(doc[k]); 377 + } 378 + return doc[k] === v; 379 + }); 380 + } 381 + return { 382 + docs, 383 + find(query) { 384 + let sortSpec = null; 385 + let limitValue = null; 386 + const api = { 387 + sort(spec) { sortSpec = spec; return api; }, 388 + limit(v) { limitValue = v; return api; }, 389 + async toArray() { 390 + let out = docs.filter((d) => matches(d, query)); 391 + if (sortSpec) { 392 + const entries = Object.entries(sortSpec); 393 + out = out.sort((a, b) => { 394 + for (const [f, dir] of entries) { 395 + if (a[f] === b[f]) continue; 396 + return dir < 0 ? (b[f] ?? 0) - (a[f] ?? 0) : (a[f] ?? 0) - (b[f] ?? 0); 397 + } 398 + return 0; 399 + }); 400 + } 401 + if (limitValue !== null) out = out.slice(0, limitValue); 402 + return out; 403 + }, 404 + }; 405 + return api; 406 + }, 407 + async findOne(query) { 408 + return docs.find((d) => matches(d, query)) || null; 409 + }, 410 + aggregate() { 411 + return { async toArray() { return []; } }; 412 + }, 413 + }; 414 + } 415 + 416 + // --- Runner ---------------------------------------------------------------- 417 + 418 + async function run() { 419 + testStripUrls(); 420 + testParseAtUri(); 421 + testProjectPostExternalEmbed(); 422 + testProjectPostFacetLinkFallback(); 423 + testProjectPostPlainTextSkipped(); 424 + testConfiguredSourcesDefault(); 425 + testConfiguredSourcesOverride(); 426 + await testIngestInsertsOnlyExternalAndFacetPosts(); 427 + await testIngestDeduplicatesOnSecondRun(); 428 + await testExternalAttributionRendersOnFrontPage(); 429 + await testExternalAttributionRendersOnItemPage(); 430 + console.log("✅ news-bluesky tests passed"); 431 + } 432 + 433 + run();