diff --git a/.github/workflows/blueskyfeedbot.yml b/.github/workflows/blueskyfeedbot.yml deleted file mode 100644 index 4cfa8034b0..0000000000 --- a/.github/workflows/blueskyfeedbot.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Bluesky FeedBot -on: - schedule: - # This will run every hour - - cron: "0 * * * *" - workflow_dispatch: -jobs: - rss-to-bluesky: - runs-on: ubuntu-latest - if: github.repository == 'nf-core/website' - steps: - - name: Generate cache key - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - id: generate-key - with: - script: | - core.setOutput('cache-key', new Date().valueOf()) - - name: Retrieve cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ${{ github.workspace }}/blueskyfeedbot - key: feed-cache-${{ steps.generate-key.outputs.cache-key }} - restore-keys: feed-cache- - - name: GitHub - uses: "joschi/blueskyfeedbot@ded1962e3afb0d7283f49a037bf4d4fd9d7b86ad" # v1 - with: - # This is the RSS feed you want to publish - rss-feed: https://nf-co.re/blog/rss.xml - # Template of status posted to Bluesky (Handlebars) - template: | - New blog post! {{item.title}} - {{item.link}} - # This is your service URL (optional) - service-url: https://bsky.social - # This is the Bluesky username (example: username.bsky.social) - username: "nf-co.re" - # This is the app password you created earlier - password: ${{ secrets.FEED_TO_BLUESKY }} - # This is a path to the cache file, using the above cache path - cache-file: ${{ github.workspace }}/blueskyfeedbot/cache.json - initial-post-limit: 0 diff --git a/.github/workflows/social-post.yml b/.github/workflows/social-post.yml new file mode 100644 index 0000000000..f8825b77fc --- /dev/null +++ b/.github/workflows/social-post.yml @@ -0,0 +1,75 @@ +name: Social media auto-post +# Posts newly published blog posts & advisories, and upcoming events (the day +# before / day of), to Bluesky and Mastodon. The record of what has already been +# posted is kept in the Actions cache (S3-backed via RunsOn): each run restores +# the most recent log via the `social-posts-` prefix and saves an updated copy +# under a fresh, unique key (cache entries are immutable, so we never overwrite). +on: + schedule: + # Hourly: fast for blog/advisory, and catches the event day-before window + - cron: "0 * * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Print what would be posted without posting" + type: boolean + default: false + seed: + description: "Mark everything currently in the feed as already posted (run once at rollout)" + type: boolean + default: false + +permissions: {} + +jobs: + social-post: + runs-on: + - runs-on=${{ github.run_id }}-social-post + - runner=4cpu-linux-x64 + - extras=s3-cache + if: github.repository == 'nf-core/website' + permissions: + contents: read + steps: + - name: Configure RunsOn S3 cache + uses: runs-on/action@d141ef83eb66d096ce8afc767e09115a65c63b60 # v2.1.2 + + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Generate cache key + id: cache-key + run: echo "value=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Restore & persist post log + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: .social-cache + key: social-posts-${{ steps.cache-key.outputs.value }} + restore-keys: social-posts- + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --omit=dev --ignore-scripts + + - name: Post to social media + env: + BSKY_IDENTIFIER: nf-co.re + BSKY_PASSWORD: ${{ secrets.FEED_TO_BLUESKY }} + MASTODON_URL: https://mstdn.science + MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + SEED: ${{ inputs.seed }} + run: | + mkdir -p .social-cache + args=() + [ "$DRY_RUN" = "true" ] && args+=(--dry-run) + [ "$SEED" = "true" ] && args+=(--seed) + node bin/social-post.js --log .social-cache/social-posts.json "${args[@]}" diff --git a/bin/social-post.js b/bin/social-post.js new file mode 100644 index 0000000000..a0305fd94e --- /dev/null +++ b/bin/social-post.js @@ -0,0 +1,187 @@ +#! /usr/bin/env node +// Auto-post newly published blog posts and advisories, and upcoming events, +// to Bluesky and Mastodon. Driven by the /social-feed.json endpoint (built from +// Astro content collections) and a small JSON log that records what has already +// been posted to each network, so nothing is posted twice. +// +// Usage: +// node bin/social-post.js [--dry-run] [--seed] [--source ] [--log ] +// +// --dry-run Print what would be posted; no network calls, no log changes. +// --seed Mark everything currently in the feed as already posted and exit. +// Run once at rollout so the back-catalogue isn't posted. +// +// Env: BSKY_IDENTIFIER (default "nf-co.re"), BSKY_PASSWORD, +// MASTODON_URL (default "https://mstdn.science"), MASTODON_ACCESS_TOKEN. + +import { readFileSync, writeFileSync } from "fs"; +import { AtpAgent, RichText } from "@atproto/api"; +import { createRestAPIClient } from "masto"; + +const args = process.argv.slice(2); +const flag = (name) => args.includes(`--${name}`); +const opt = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; +}; + +const DRY_RUN = flag("dry-run"); +const SEED = flag("seed"); +const SOURCE = opt("source", "https://nf-co.re/social-feed.json"); +const LOG_PATH = opt("log", "social-posts.json"); +const HASHTAGS = "#nfcore #nextflow #bioinformatics #openscience"; +const BLUESKY_MAX = 290; // grapheme budget, leaving headroom under the 300 limit +const PRUNE_DAYS = 90; // forget log entries this old — far outside the feed window, so they can't re-post + +const ymd = (date) => date.toISOString().slice(0, 10); + +// --- decide what is due to post -------------------------------------------- + +const today = ymd(new Date()); +const tomorrow = ymd(new Date(Date.now() + 864e5)); + +// Returns the lead-in line for an item, or null if it is not due to post. +const lead = (item) => { + switch (item.type) { + case "blog": + return `New blog post: ${item.title}`; + case "advisory": + return `New advisory: ${item.title}`; + case "event": + if (item.startDate === tomorrow) return `Happening tomorrow: ${item.title}`; + if (item.startDate === today) return `Happening today: ${item.title}`; + return null; // events only post the day before / day of + default: + return null; + } +}; + +// --- message composition ---------------------------------------------------- + +const blueskyText = (item, leadLine) => { + // The link is carried by the embed card, so it is left out of the text. + let text = `${leadLine}\n\n${item.subtitle}`; + if ([...text].length > BLUESKY_MAX) { + const room = BLUESKY_MAX - [...leadLine].length - 3; // "\n\n" + ellipsis + text = `${leadLine}\n\n${[...item.subtitle].slice(0, Math.max(0, room)).join("")}…`; + } + return text; +}; + +const mastodonText = (item, leadLine) => `${leadLine}\n\n${item.subtitle}\n\n${item.url}\n\n${HASHTAGS}`; + +// --- network clients (lazy singletons) ------------------------------------- + +let bsky; +async function blueskyAgent() { + if (bsky) return bsky; + bsky = new AtpAgent({ service: "https://bsky.social" }); + await bsky.login({ identifier: process.env.BSKY_IDENTIFIER || "nf-co.re", password: process.env.BSKY_PASSWORD }); + return bsky; +} + +async function postBluesky(item, leadLine) { + const agent = await blueskyAgent(); + const external = { uri: item.url, title: item.title, description: item.subtitle }; + if (item.image) { + try { + const res = await fetch(item.image); + if (res.ok) { + const blob = new Uint8Array(await res.arrayBuffer()); + const encoding = res.headers.get("content-type") || "image/jpeg"; + const upload = await agent.uploadBlob(blob, { encoding }); + external.thumb = upload.data.blob; + } + } catch (e) { + console.warn(` ⚠ could not attach Bluesky thumbnail: ${e.message}`); + } + } + const rt = new RichText({ text: blueskyText(item, leadLine) }); + await rt.detectFacets(agent); + await agent.post({ + text: rt.text, + facets: rt.facets, + embed: { $type: "app.bsky.embed.external", external }, + createdAt: new Date().toISOString(), + }); +} + +let mastodon; +async function postMastodon(item, leadLine) { + mastodon ||= createRestAPIClient({ + url: process.env.MASTODON_URL || "https://mstdn.science", + accessToken: process.env.MASTODON_ACCESS_TOKEN, + }); + await mastodon.v1.statuses.create({ status: mastodonText(item, leadLine), visibility: "public" }); +} + +const NETWORKS = { + bluesky: { post: postBluesky, enabled: () => !!process.env.BSKY_PASSWORD }, + mastodon: { post: postMastodon, enabled: () => !!process.env.MASTODON_ACCESS_TOKEN }, +}; + +// --- main ------------------------------------------------------------------- + +async function main() { + let log = {}; + try { + log = JSON.parse(readFileSync(LOG_PATH, "utf8")); + } catch (e) { + if (e.code !== "ENOENT") throw e; + } + const save = () => { + const cutoff = Date.now() - PRUNE_DAYS * 864e5; + for (const id of Object.keys(log)) { + const newest = Math.max(...Object.values(log[id]).map((t) => new Date(t).getTime())); + if (newest < cutoff) delete log[id]; + } + writeFileSync(LOG_PATH, JSON.stringify(log, null, 2) + "\n"); + }; + + const res = await fetch(SOURCE); + if (!res.ok) throw new Error(`Failed to fetch ${SOURCE}: ${res.status}`); + const items = await res.json(); + + if (SEED) { + const now = new Date().toISOString(); + for (const item of items) { + log[item.id] ||= {}; + for (const name of Object.keys(NETWORKS)) log[item.id][name] ||= now; + } + save(); + console.log(`Seeded ${items.length} item(s) into ${LOG_PATH} — nothing posted.`); + return; + } + + let posted = 0; + for (const item of items) { + const leadLine = lead(item); + if (!leadLine) continue; + + log[item.id] ||= {}; + for (const [name, network] of Object.entries(NETWORKS)) { + if (log[item.id][name]) continue; // already posted here + + if (DRY_RUN) { + console.log(`[dry-run] ${name} ← ${leadLine}\n ${item.url}`); + continue; + } + if (!network.enabled()) continue; // network not configured + try { + await network.post(item, leadLine); + log[item.id][name] = new Date().toISOString(); + save(); + posted++; + console.log(`✓ ${name}: ${leadLine}`); + } catch (e) { + console.error(`✗ ${name} failed for ${item.id}: ${e.message}`); + } + } + } + console.log(DRY_RUN ? "Dry run complete." : `Done — ${posted} post(s) sent.`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 2c83a66350..5b5713dd73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "expressive-code": "^0.44.1", "github-slugger": "^2.0.0", "node-emoji": "^2.2.0", + "@atproto/api": "^0.20.18", + "masto": "^7.12.0", "remark": "^15.0.1", "satteri": "^0.9.5", "shiki": "^4.3.1", @@ -447,6 +449,145 @@ "integrity": "sha512-cxnGSw+sJigBLdX4TMSZKkzV6C3gMLJMucDk2W+n281Xhie68T2/9f1+1NMNDCZsc5i0FED7Qt5I10g2O9wtZg==", "license": "MIT" }, + "node_modules/@atproto/api": { + "version": "0.20.18", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.18.tgz", + "integrity": "sha512-JaU5uCvfusHD88uBFBtYpdKIhSo7AWx2NscRAPHdDJP6pLKhzPAJ4sVPOunrXahr0/f37QmPlFToajqV5XpPTw==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.1", + "@atproto/lexicon": "^0.7.2", + "@atproto/syntax": "^0.6.2", + "@atproto/xrpc": "^0.8.1", + "await-lock": "^3.0.0", + "multiformats": "^13.0.0", + "tlds": "^1.234.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/api/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/common-web": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.1.tgz", + "integrity": "sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.2", + "@atproto/lex-json": "^0.1.1", + "@atproto/syntax": "^0.6.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/common-web/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.1.2.tgz", + "integrity": "sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==", + "license": "MIT", + "dependencies": { + "multiformats": "^13.0.0", + "tslib": "^2.8.1", + "uint8arrays": "^5.0.0", + "unicode-segmenter": "^0.14.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.1.1.tgz", + "integrity": "sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.2.tgz", + "integrity": "sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.1", + "@atproto/syntax": "^0.6.2", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/lexicon/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.6.2.tgz", + "integrity": "sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/xrpc": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.1.tgz", + "integrity": "sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/xrpc/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@aws-sdk/checksums": { "version": "3.1000.22", "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.22.tgz", @@ -6492,6 +6633,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-3.0.0.tgz", + "integrity": "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==", + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -6904,6 +7051,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "license": "MIT" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -9130,6 +9283,12 @@ "node": ">=0.8.x" } }, + "node_modules/events-to-async": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.2.tgz", + "integrity": "sha512-WzI6m/SU+F38t2tejHh6IQuQkNZ0dMOmSEmODJb9czaeMYtQ5FVZ+b6DOHr3hC6hNKNnn9CwDUN8mJiAkWSI9Q==", + "license": "MIT" + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -11628,6 +11787,21 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "license": "MIT" + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -12474,6 +12648,19 @@ "node": ">= 20" } }, + "node_modules/masto": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/masto/-/masto-7.12.0.tgz", + "integrity": "sha512-hSqRfaqVhtaRB3+lSJ4YusqBZ/tj4/gtzS0PXZgrg2JLiDFLmsBdmuyVVF2c+QlKSMEKo7grVh3dN8TX1TECVw==", + "license": "MIT", + "dependencies": { + "change-case": "^5.4.4", + "events-to-async": "^2.0.2", + "isomorphic-ws": "^5.0.0", + "ts-custom-error": "^3.3.1", + "ws": "^8.18.3" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13814,6 +14001,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -17203,6 +17396,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -17309,6 +17511,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-dedent": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", @@ -17430,6 +17641,15 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, + "node_modules/uint8arrays": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.1.tgz", + "integrity": "sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, "node_modules/ulid": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/ulid/-/ulid-3.0.2.tgz", @@ -17493,6 +17713,12 @@ "node": ">=4" } }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "license": "MIT" + }, "node_modules/unicode-trie": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", @@ -18351,6 +18577,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-naming": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", diff --git a/package.json b/package.json index ddfcc42704..57807f5b90 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build-pipeline-json": "node bin/pipelines.json.js", "build-component-json": "node bin/components.json.js", "check-api-catalog": "node bin/check-api-catalog.js", + "social-post": "node bin/social-post.js", "update": "npx npm-check-updates --interactive --format group --workspaces", "test-all": "for dir in sites/*; do (cd $dir && npm run test); done" }, @@ -31,6 +32,8 @@ "expressive-code": "^0.44.1", "github-slugger": "^2.0.0", "node-emoji": "^2.2.0", + "@atproto/api": "^0.20.18", + "masto": "^7.12.0", "remark": "^15.0.1", "satteri": "^0.9.5", "shiki": "^4.3.1", diff --git a/sites/main-site/src/pages/social-feed.json.ts b/sites/main-site/src/pages/social-feed.json.ts new file mode 100644 index 0000000000..a959cc16c6 --- /dev/null +++ b/sites/main-site/src/pages/social-feed.json.ts @@ -0,0 +1,59 @@ +import { getCollection } from "astro:content"; +import type { APIContext } from "astro"; + +// Normalised feed of recently-published / upcoming content, consumed by +// bin/social-post.js to auto-post to Bluesky and Mastodon. Content collections +// are the single source of truth here so we never re-parse frontmatter or +// re-derive URLs elsewhere. + +const RECENT_WINDOW_DAYS = 30; // how far back blog posts / advisories stay in the feed +const EVENT_PAST_GRACE_DAYS = 2; // keep just-passed events briefly so same-day posts still fire + +type FeedType = "blog" | "advisory" | "event"; + +interface FeedItem { + type: FeedType; + id: string; + url: string; + title: string; + subtitle: string; + image: string; + startDate?: string; // events only — the date the auto-poster keys "tomorrow/today" off +} + +export async function GET(context: APIContext) { + const site = context.site!; + const now = Date.now(); + const recentCutoff = now - RECENT_WINDOW_DAYS * 864e5; + const eventCutoff = now - EVENT_PAST_GRACE_DAYS * 864e5; + + const slug = (id: string) => id.replace(/\.[^/.]+$/, ""); + const abs = (path: string) => new URL(path, site).href; + const image = (img?: string) => (img ? abs(img) : abs("/social_img.png")); + + // headerImage is absent on advisories and optional on events; the image() + // fallback covers both, so one builder serves all three content types. + const make = (type: FeedType, entry: { id: string; data: any }, extra: Partial = {}): FeedItem => ({ + type, + id: `${type}:${slug(entry.id)}`, + url: abs(`/${type === "advisory" ? "advisories" : type}/${slug(entry.id)}`), + title: entry.data.title, + subtitle: entry.data.subtitle, + image: image(entry.data.headerImage), + ...extra, + }); + + const blog = await getCollection("blog", ({ data }) => !data.draft && data.pubDate.getTime() <= now); + const advisories = await getCollection("advisories", ({ data }) => data.publishedDate.getTime() <= now); + const events = await getCollection("events", ({ data }) => data.start.getTime() >= eventCutoff); + + const items: FeedItem[] = [ + ...blog.filter((p) => p.data.pubDate.getTime() >= recentCutoff).map((p) => make("blog", p)), + ...advisories.filter((a) => a.data.publishedDate.getTime() >= recentCutoff).map((a) => make("advisory", a)), + ...events.map((e) => make("event", e, { startDate: e.data.startDate })), + ]; + + return new Response(JSON.stringify(items), { + headers: { "Content-Type": "application/json" }, + }); +}