/** * Weekly SkateSpots newsletter generator. * * - Picks the next issue number (latest published + 1). * - Builds the full draft: title/slug/summary/cover/body/teaser. * - Generates a fresh cover from the Drop #006 foundation and uploads it to Blossom. * - Publishes a NIP-78 draft (kind 30078, d=skatespots:newsletter:draft) for the * admin composer. Set NEWSLETTER_AUTOPUBLISH=1 to publish the NIP-23 issue and * kind-1 teaser in the same run. * * Run: npx tsx scripts/newsletter-weekly.ts * Signer: SIGN_WITH in ~/.config/skatespots/zsp.env (nsec; never printed). */ import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import WebSocket from 'ws'; import { NRelay1 } from '@nostrify/nostrify'; import { BlossomUploader } from '@nostrify/nostrify/uploaders'; import { finalizeEvent, nip19 } from 'nostr-tools'; import { getPublicKey } from 'nostr-tools/pure'; import { ADMIN_PUBKEYS } from '../src/lib/admins'; import { NEWSLETTER_ISSUES } from '../src/lib/newsletterIssues'; import { buildWeeklyDraft, pickJokes, WEEKLY_HIGHLIGHTS, type WeeklyHeadline, type WeeklyHighlight, } from '../src/lib/newsletterContent'; import { generateNewsletterCover } from './newsletter-cover'; (globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket; const RELAY = 'wss://relay.skate-spots.org'; const COVER_TITLES = [ 'THE PRIVATE CHAT GROWS UP', 'SHIP HAPPENS', 'THE MAP IS THE POINT', 'BUILT FOR THE PARK', 'LESS SLOP, MORE SKATE', 'DO THE BORING PART', ]; function readSecret(): { sk: Uint8Array; pubkey: string } { const file = join(process.env.HOME ?? '', '.config/skatespots/zsp.env'); const raw = process.env.SIGN_WITH || readFileSync(file, 'utf8').match(/^SIGN_WITH=(.*)$/m)?.[1]?.trim() || ''; if (!raw.startsWith('nsec1')) throw new Error('newsletter signer must be a local nsec (SIGN_WITH)'); const sk = nip19.decode(raw).data as Uint8Array; const pubkey = getPublicKey(sk); if (!ADMIN_PUBKEYS.includes(pubkey)) throw new Error('newsletter signer is not an app admin'); return { sk, pubkey }; } function parseReleaseHighlights(): WeeklyHighlight[] { try { const notes = readFileSync(join(process.cwd(), 'release-notes.txt'), 'utf8'); const top = notes.split(/\n---\n/)[0] ?? ''; const lines = top.split('\n'); let heading = 'Shipped'; let tag = 'App'; const groups: Array<{ heading: string; bullets: string[] }> = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('- ')) { if (trimmed.startsWith('- ')) { if (groups.length === 0) groups.push({ heading, bullets: [] }); groups[groups.length - 1].bullets.push(trimmed.slice(2)); } continue; } const clean = trimmed.replace(/^[^\p{L}\p{N}]+/u, '').trim(); if (clean) { heading = clean; tag = clean.split(':')[0].slice(0, 24); groups.push({ heading, bullets: [] }); } } const out: WeeklyHighlight[] = []; for (const group of groups) { for (const bullet of group.bullets) { out.push({ tag, title: bullet.split('—')[0].split('.')[0].slice(0, 90), body: bullet, }); if (out.length >= 4) return out; } } return out; } catch { return []; } } function decodeXml(value: string): string { return value .replace(//g, '$1') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'"); } async function fetchHeadlines(): Promise { const feeds = [ { url: 'https://news.ycombinator.com/rss', source: 'Hacker News' }, { url: 'https://techcrunch.com/feed/', source: 'TechCrunch' }, { url: 'https://www.coindesk.com/arc/outboundfeeds/rss/', source: 'CoinDesk' }, ]; const out: WeeklyHeadline[] = []; for (const feed of feeds) { try { const res = await fetch(feed.url, { headers: { 'user-agent': 'SkateSpotsNewsletter/1.0' }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) continue; const xml = await res.text(); const item = xml.match(/([\s\S]*?)<\/item>/i)?.[1]; if (!item) continue; const title = decodeXml(item.match(/([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? ''); const link = decodeXml(item.match(/<link>([\s\S]*?)<\/link>/i)?.[1]?.trim() ?? ''); if (title && /^https?:\/\//.test(link)) out.push({ title, url: link, source: feed.source }); } catch { // Feed unavailable — keep the newsletter self-contained. } } return out.slice(0, 3); } async function latestIssueNumber(relay: NRelay1): Promise<number> { const staticMax = Math.max(0, ...NEWSLETTER_ISSUES.map((issue) => issue.number)); try { const events = await relay.query( [{ kinds: [30023], authors: ADMIN_PUBKEYS, '#t': ['skatespotsnews'], limit: 50 }], { signal: AbortSignal.timeout(10_000) }, ); let max = staticMax; for (const event of events) { const title = event.tags.find(([name]) => name === 'title')?.[1] ?? ''; const match = title.match(/#0*(\d+)/); if (match) max = Math.max(max, Number(match[1])); } return max; } catch { return staticMax; } } const { sk, pubkey } = readSecret(); const relay = new NRelay1(RELAY); const number = (await latestIssueNumber(relay)) + 1; const dateIso = new Date().toISOString().slice(0, 10); const dateLabel = new Date(`${dateIso}T12:00:00Z`) .toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }) .toUpperCase(); const parsed = number === 7 ? [] : parseReleaseHighlights(); const highlights = parsed.length >= 3 ? parsed : WEEKLY_HIGHLIGHTS; const jokes = pickJokes(number, 3); const headlines = await fetchHeadlines(); const coverTitle = COVER_TITLES[(number - 1) % COVER_TITLES.length]; const tempDir = mkdtempSync(join(tmpdir(), 'skatespots-newsletter-')); const coverPath = join(tempDir, `drop-${String(number).padStart(3, '0')}-cover.png`); await generateNewsletterCover({ number, dateLabel, coverTitle, outputPath: coverPath }); const signer = { getPublicKey: async () => pubkey, signEvent: async (event: Parameters<typeof finalizeEvent>[0]) => finalizeEvent(event, sk), }; let coverUrl = 'https://skate-spots.org/images/drop-006-cover.png'; try { const uploader = new BlossomUploader({ servers: ['https://blossom.skate-spots.org/'], signer }); const tags = await uploader.upload( new File([readFileSync(coverPath)], `drop-${String(number).padStart(3, '0')}-cover.png`, { type: 'image/png', }), ); coverUrl = tags.find(([name]) => name === 'url')?.[1] ?? coverUrl; } catch (error) { console.warn('[newsletter] cover upload failed, using the foundation cover:', error instanceof Error ? error.message : error); } const draft = buildWeeklyDraft({ number, dateIso, coverImage: coverUrl, coverTitle, highlights, headlines, jokes, }); const draftEvent = finalizeEvent( { kind: 30078, content: JSON.stringify(draft), created_at: Math.floor(Date.now() / 1000), tags: [ ['d', 'skatespots:newsletter:draft'], ['t', 'skatespotsnewsdraft'], ['alt', `SkateSpots weekly newsletter draft #${String(number).padStart(3, '0')}`], ], }, sk, ); await relay.event(draftEvent, { signal: AbortSignal.timeout(15_000) }); console.log( JSON.stringify( { issue: number, title: draft.title, date: dateIso, cover: coverUrl, highlights: highlights.length, jokes: jokes.map((joke) => joke.id), headlines: headlines.map((headline) => headline.source), draftEvent: draftEvent.id, autoPublished: process.env.NEWSLETTER_AUTOPUBLISH === '1', }, null, 2, ), ); if (process.env.NEWSLETTER_AUTOPUBLISH === '1') { const now = Math.floor(Date.now() / 1000); const issueEvent = finalizeEvent( { kind: 30023, content: draft.body, created_at: now, tags: [ ['d', draft.slug], ['title', draft.title], ['summary', draft.summary], ['image', draft.image], ['published_at', String(now)], ['t', 'skatespotsnews'], ['t', 'skatespots'], ['t', 'newsletter'], ['t', 'skateboarding'], ['alt', `SkateSpots newsletter issue: ${draft.title}`], ], }, sk, ); await relay.event(issueEvent, { signal: AbortSignal.timeout(15_000) }); const teaser = finalizeEvent( { kind: 1, content: draft.nostrText, created_at: now, tags: [ ['t', 'skatespotsnews'], ['t', 'skateboarding'], ['t', 'nostr'], ], }, sk, ); await relay.event(teaser, { signal: AbortSignal.timeout(15_000) }); console.log(JSON.stringify({ publishedIssue: issueEvent.id, teaser: teaser.id })); } try { await relay.close?.(); } catch { // ignore } rmSync(tempDir, { recursive: true, force: true });