diff --git a/.env.example b/.env.example index ede6fba..d87a7be 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,16 @@ COOKIE_SECRET= # Set to your tunnel URL to use a confidential client in dev # OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com + +# --- Reddit-on-Bluesky --- +# Main bot account (used to own the curate list and as the atmo identity). +BSKY_HANDLE= +BSKY_PASSWORD= + +# Random string used to AES-GCM encrypt stored community app passwords. +# Generate with: openssl rand -base64 32 +COMMUNITY_ENCRYPTION_KEY= + +# Optional: if set, /_cron/check-dms requires ?secret=... when called manually. +# The scheduled handler bypasses this via an internal header. +# CRON_SECRET= diff --git a/.gitignore b/.gitignore index 3b462cb..c2044c0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ Thumbs.db .env.* !.env.example !.env.test +.dev.vars # Vite vite.config.js.timestamp-* diff --git a/cron/append.js b/cron/append.js new file mode 100644 index 0000000..38cb8a8 --- /dev/null +++ b/cron/append.js @@ -0,0 +1,9 @@ +// Appends cron/job.js to the built SvelteKit worker so that the +// scheduled() handler ships alongside the normal fetch handler. +// Run after `vite build`. + +import { appendFile, readFile } from 'fs/promises'; + +const job = await readFile('cron/job.js', 'utf8'); +await appendFile('.svelte-kit/cloudflare/_worker.js', job, 'utf8'); +console.log('[cron/append] scheduled handler appended to _worker.js'); diff --git a/cron/job.js b/cron/job.js new file mode 100644 index 0000000..c6b0be4 --- /dev/null +++ b/cron/job.js @@ -0,0 +1,11 @@ +// Appended to .svelte-kit/cloudflare/_worker.js at build time so that the +// Cloudflare Workers scheduled() lifecycle can drive the SvelteKit cron +// endpoint. See cron/append.js + wrangler.jsonc triggers.crons. + +worker_default.scheduled = async (event, env, ctx) => { + const req = new Request('https://cron.internal/_cron/check-dms', { + method: 'POST', + headers: { 'x-cron-internal': '1' } + }); + ctx.waitUntil(worker_default.fetch(req, env, ctx)); +}; diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..dd1259b --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,46 @@ +-- Community accounts we manage. +-- password_ciphertext + password_iv: AES-GCM encrypted app password. +CREATE TABLE communities ( + did TEXT PRIMARY KEY, + handle TEXT NOT NULL, + pds TEXT NOT NULL, + password_ciphertext TEXT NOT NULL, + password_iv TEXT NOT NULL, + display_name TEXT, + avatar TEXT, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE UNIQUE INDEX idx_communities_handle ON communities(handle); + +-- Cached quote posts (= community submissions). +CREATE TABLE posts ( + uri TEXT PRIMARY KEY, + cid TEXT NOT NULL, + community_did TEXT NOT NULL, + title TEXT NOT NULL, + quoted_post_uri TEXT NOT NULL, + quoted_post_cid TEXT, + author_did TEXT, + like_count INTEGER DEFAULT 0, + reply_count INTEGER DEFAULT 0, + repost_count INTEGER DEFAULT 0, + indexed_at TEXT NOT NULL, + last_refreshed_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (community_did) REFERENCES communities(did) +); + +CREATE INDEX idx_posts_community ON posts(community_did, indexed_at DESC); +CREATE INDEX idx_posts_indexed ON posts(indexed_at DESC); +CREATE INDEX idx_posts_refresh ON posts(last_refreshed_at); + +-- Dedup: never create two quote posts for the same (community, quoted post). +CREATE UNIQUE INDEX idx_posts_community_quoted ON posts(community_did, quoted_post_uri); + +-- Simple key/value meta store (for curate list URI, etc). +CREATE TABLE meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/package.json b/package.json index 6c44db1..b624701 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "vite build && node cron/append.js", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/scripts/debug-dms.ts b/scripts/debug-dms.ts new file mode 100644 index 0000000..d58b9fb --- /dev/null +++ b/scripts/debug-dms.ts @@ -0,0 +1,138 @@ +// Standalone diagnostic: logs in as the BSKY_HANDLE account and dumps +// all conversations + their latest messages so we can see what DMs exist +// and why they're not being picked up by the bot. +// +// Run: pnpm tsx scripts/debug-dms.ts + +import { readFileSync } from 'fs'; +import { Client, CredentialManager } from '@atcute/client'; +import type { Did } from '@atcute/lexicons'; + +// Minimal .env loader (no external dep). +function loadEnv(path: string) { + try { + const text = readFileSync(path, 'utf8'); + for (const line of text.split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); + if (!m) continue; + let val = m[2]; + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + if (!process.env[m[1]]) process.env[m[1]] = val; + } + } catch { + // ignore + } +} + +loadEnv('.env'); +loadEnv('.dev.vars'); + +const HANDLE = process.env.BSKY_HANDLE; +const PASSWORD = process.env.BSKY_PASSWORD; + +if (!HANDLE || !PASSWORD) { + console.error('Missing BSKY_HANDLE / BSKY_PASSWORD in env'); + process.exit(1); +} + +async function main() { + console.log(`Logging in as ${HANDLE}…`); + + // Resolve the PDS via the public handle resolver. + const didRes = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}` + ); + if (!didRes.ok) { + console.error('Failed to resolve handle:', didRes.status); + process.exit(1); + } + const { did } = (await didRes.json()) as { did: string }; + console.log(` did = ${did}`); + + const plcRes = await fetch(`https://plc.directory/${did}`); + if (!plcRes.ok) { + console.error('Failed to resolve DID doc:', plcRes.status); + process.exit(1); + } + const doc = (await plcRes.json()) as { + service?: { id: string; serviceEndpoint: string }[]; + }; + const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; + if (!pds) { + console.error('No PDS in DID doc'); + process.exit(1); + } + console.log(` pds = ${pds}`); + + const manager = new CredentialManager({ service: pds }); + await manager.login({ identifier: HANDLE!, password: PASSWORD! }); + console.log(` login OK, session.did = ${manager.session?.did}`); + + const chatClient = new Client({ + handler: manager, + proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } + }); + + for (const status of ['accepted', 'request']) { + console.log(`\n=== listConvos (status=${status}) ===`); + const res = await chatClient.get('chat.bsky.convo.listConvos', { + params: { limit: 50, status } + }); + if (!res.ok) { + console.log(` FAILED status=${res.status}`, res.data); + continue; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const convos = res.data.convos as any[]; + console.log(` ${convos.length} convos`); + for (const c of convos) { + console.log( + ` - convoId=${c.id} rev=${c.rev} unread=${c.unreadCount} members=${c.members + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((m: any) => m.handle ?? m.did) + .join(', ')}` + ); + if (c.lastMessage) { + console.log( + ` lastMessage: ${JSON.stringify({ + $type: c.lastMessage.$type, + text: c.lastMessage.text, + sender: c.lastMessage.sender?.did, + rev: c.lastMessage.rev + })}` + ); + } + + // Pull ALL messages via pagination. + let cursor: string | undefined; + let total = 0; + for (let page = 0; page < 10; page++) { + const msgRes = await chatClient.get('chat.bsky.convo.getMessages', { + params: { convoId: c.id, limit: 100, ...(cursor ? { cursor } : {}) } + }); + if (!msgRes.ok) { + console.log(` getMessages FAILED ${msgRes.status}`, msgRes.data); + break; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const msgs = msgRes.data.messages as any[]; + if (page === 0) console.log(` page ${page}: ${msgs.length} messages`); + else console.log(` page ${page}: +${msgs.length} more`); + for (const m of msgs) { + console.log( + ` [${m.rev}] ${m.$type ?? 'msg'} sender=${m.sender?.did ?? '?'} sentAt=${m.sentAt} text=${JSON.stringify(m.text ?? null)} embed=${JSON.stringify(m.embed ?? null)} facets=${JSON.stringify(m.facets ?? null)}` + ); + } + total += msgs.length; + cursor = msgRes.data.cursor; + if (!cursor) break; + } + console.log(` total messages seen: ${total}`); + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/delete-post.ts b/scripts/delete-post.ts new file mode 100644 index 0000000..3adc50c --- /dev/null +++ b/scripts/delete-post.ts @@ -0,0 +1,53 @@ +// One-off: delete a post by rkey from the main bot account. +// Usage: pnpm tsx scripts/delete-post.ts + +import { readFileSync } from 'fs'; +import { CredentialManager, Client } from '@atcute/client'; + +function loadEnv(path: string) { + try { + const text = readFileSync(path, 'utf8'); + for (const line of text.split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); + if (!m) continue; + let val = m[2]; + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + if (!process.env[m[1]]) process.env[m[1]] = val; + } + } catch { + /* ignore */ + } +} + +loadEnv('.env'); +loadEnv('.dev.vars'); + +const rkey = process.argv[2]; +if (!rkey) { + console.error('Usage: pnpm tsx scripts/delete-post.ts '); + process.exit(1); +} + +const didRes = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${process.env.BSKY_HANDLE}` +); +const { did } = (await didRes.json()) as { did: string }; + +const plc = await fetch(`https://plc.directory/${did}`); +const doc = (await plc.json()) as { service?: { id: string; serviceEndpoint: string }[] }; +const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; +if (!pds) throw new Error('No PDS'); + +const manager = new CredentialManager({ service: pds }); +await manager.login({ identifier: process.env.BSKY_HANDLE!, password: process.env.BSKY_PASSWORD! }); +const client = new Client({ handler: manager }); + +const res = await client.post('com.atproto.repo.deleteRecord', { + input: { + repo: did as `did:${string}:${string}`, + collection: 'app.bsky.feed.post', + rkey + } +}); + +console.log(res.ok ? `deleted ${rkey}` : `failed: ${JSON.stringify(res.data)}`); diff --git a/src/app.d.ts b/src/app.d.ts index 69b2623..b7126f7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -22,6 +22,11 @@ declare global { COOKIE_SECRET: string; OAUTH_PUBLIC_URL: string; PROFILE_CACHE?: KVNamespace; + DB: D1Database; + BSKY_HANDLE: string; + BSKY_PASSWORD: string; + COMMUNITY_ENCRYPTION_KEY: string; + CRON_SECRET?: string; }; } } diff --git a/src/lib/reddit/RedditPostCard.svelte b/src/lib/reddit/RedditPostCard.svelte new file mode 100644 index 0000000..a24b79f --- /dev/null +++ b/src/lib/reddit/RedditPostCard.svelte @@ -0,0 +1,123 @@ + + +
+ {#if showCommunity && communityHandle} + + + {communityDisplayName ?? communityHandle} + · + @{communityHandle} + · + {fmtRelative(row.indexed_at)} + + {:else} +
+ {fmtRelative(row.indexed_at)} +
+ {/if} + +

{row.title}

+ + {#if quoted && quotedPostData} +
+
+ `/profile/${handle}`} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + /> +
+
+ {:else if quoted === null} +
+ (quoted post unavailable) +
+ {:else} +
+
+
+
+
+ {/if} + +
+ + {row.like_count} + + + {row.reply_count} + + + {row.repost_count} + +
+
diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts new file mode 100644 index 0000000..dd0e449 --- /dev/null +++ b/src/lib/reddit/bot.ts @@ -0,0 +1,535 @@ +// Reddit-on-Bluesky bot: server-side logic for logging in as community +// accounts, managing the central curate list, reading DMs, and creating +// quote posts from DM submissions. + +import { Client, CredentialManager, simpleFetchHandler } from '@atcute/client'; +import type { Did, ResourceUri } from '@atcute/lexicons'; +import * as TID from '@atcute/tid'; +import { getPDS, actorToDid } from '$lib/atproto/methods'; +import { decryptPassword, encryptPassword } from './crypto'; +import { + getCombinedFeed, + getMeta, + getPostsDueForRefresh, + hasSubmission, + insertCommunity, + insertPost, + listCommunities, + setMeta, + updateCommunityProfile, + updatePostMetrics, + type CommunityRow +} from './db'; + +const PROCESSED_REACTION = '✅'; +import { getPostCid, parseSubmission } from './submission'; + +const PUBLIC_APPVIEW = 'https://public.api.bsky.app'; +const CURATE_LIST_META_KEY = 'curate_list_uri'; +const CURATE_LIST_RKEY_META_KEY = 'curate_list_rkey'; + +export type AuthedAccount = { + did: Did; + handle: string; + pds: string; + manager: CredentialManager; + pdsClient: Client; // repo ops + chatClient: Client; // chat proxied to api.bsky.chat +}; + +// ------------------------------------------------------------------------- +// Auth helpers +// ------------------------------------------------------------------------- + +/** + * Log in to a PDS using an app password and return a ready-to-use pair of + * clients — one for the PDS (for createRecord etc) and one proxied through + * the bsky chat service (for chat.bsky.convo.*). + */ +export async function loginWithAppPassword( + identifier: string, + password: string, + pdsUrl?: string +): Promise { + // Resolve identifier → PDS if we don't already know the PDS. We rely on + // the login response for the authoritative did/handle. + let pds = pdsUrl; + + if (!pds) { + const resolvedDid = await actorToDid(identifier); + const resolvedPds = await getPDS(resolvedDid); + if (!resolvedPds) throw new Error(`No PDS found for ${identifier}`); + pds = resolvedPds; + } + + const manager = new CredentialManager({ service: pds }); + const session = await manager.login({ identifier, password }); + const handle = session.handle; + const did: Did = session.did; + + const pdsClient = new Client({ handler: manager }); + const chatClient = new Client({ + handler: manager, + proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } + }); + + return { did, handle, pds, manager, pdsClient, chatClient }; +} + +export async function loginMainAccount(env: App.Platform['env']): Promise { + if (!env.BSKY_HANDLE || !env.BSKY_PASSWORD) { + throw new Error('BSKY_HANDLE / BSKY_PASSWORD not configured'); + } + return loginWithAppPassword(env.BSKY_HANDLE, env.BSKY_PASSWORD); +} + +export async function loginCommunity( + row: CommunityRow, + env: App.Platform['env'] +): Promise { + const password = await decryptPassword( + row.password_ciphertext, + row.password_iv, + env.COMMUNITY_ENCRYPTION_KEY + ); + return loginWithAppPassword(row.handle, password, row.pds); +} + +// ------------------------------------------------------------------------- +// Curate list management +// ------------------------------------------------------------------------- + +/** + * Ensure that the main account has a curate list for tracking community + * accounts. Returns the list's AT URI. Creates the list if missing. + */ +export async function ensureCurateList( + main: AuthedAccount, + db: D1Database +): Promise { + const cached = await getMeta(db, CURATE_LIST_META_KEY); + if (cached) return cached as ResourceUri; + + // Check whether the main account already has a reddit communities list. + const existing = await main.pdsClient.get('com.atproto.repo.listRecords', { + params: { + repo: main.did, + collection: 'app.bsky.graph.list', + limit: 100 + } + }); + + if (existing.ok) { + for (const rec of existing.data.records) { + const value = rec.value as { name?: string; purpose?: string }; + if ( + value?.purpose === 'app.bsky.graph.defs#curatelist' && + value?.name === 'atmo communities' + ) { + const uri = rec.uri as ResourceUri; + await setMeta(db, CURATE_LIST_META_KEY, uri); + const rkey = uri.split('/').pop() ?? ''; + await setMeta(db, CURATE_LIST_RKEY_META_KEY, rkey); + return uri; + } + } + } + + // Create the list. + const rkey = TID.now(); + const create = await main.pdsClient.post('com.atproto.repo.createRecord', { + input: { + repo: main.did, + collection: 'app.bsky.graph.list', + rkey, + record: { + $type: 'app.bsky.graph.list', + purpose: 'app.bsky.graph.defs#curatelist', + name: 'atmo communities', + description: 'Communities registered on atmo.social (reddit-on-bluesky)', + createdAt: new Date().toISOString() + } + } + }); + + if (!create.ok) throw new Error('Failed to create curate list'); + const uri = create.data.uri as ResourceUri; + await setMeta(db, CURATE_LIST_META_KEY, uri); + await setMeta(db, CURATE_LIST_RKEY_META_KEY, rkey); + return uri; +} + +/** + * Add a community DID to the curate list. + */ +export async function addCommunityToList( + main: AuthedAccount, + listUri: ResourceUri, + communityDid: Did +): Promise { + const rkey = TID.now(); + const res = await main.pdsClient.post('com.atproto.repo.createRecord', { + input: { + repo: main.did, + collection: 'app.bsky.graph.listitem', + rkey, + record: { + $type: 'app.bsky.graph.listitem', + list: listUri, + subject: communityDid, + createdAt: new Date().toISOString() + } + } + }); + if (!res.ok) throw new Error('Failed to add listitem'); +} + +// ------------------------------------------------------------------------- +// Registration +// ------------------------------------------------------------------------- + +export type RegisterResult = { + did: Did; + handle: string; +}; + +export async function registerCommunity( + env: App.Platform['env'], + db: D1Database, + identifier: string, + password: string +): Promise { + // Log in as the community to validate credentials. + const community = await loginWithAppPassword(identifier, password); + + // Store encrypted credentials in D1. + const { ciphertext, iv } = await encryptPassword(password, env.COMMUNITY_ENCRYPTION_KEY); + + // Fetch profile metadata for the registration preview. + const appview = new Client({ + handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) + }); + const profile = await appview.get('app.bsky.actor.getProfile', { + params: { actor: community.did } + }); + + await insertCommunity(db, { + did: community.did, + handle: community.handle, + pds: community.pds, + password_ciphertext: ciphertext, + password_iv: iv, + display_name: profile.ok ? profile.data.displayName ?? null : null, + avatar: profile.ok ? profile.data.avatar ?? null : null, + description: profile.ok ? profile.data.description ?? null : null + }); + + // Add the DID to the main curate list. + const main = await loginMainAccount(env); + const listUri = await ensureCurateList(main, db); + await addCommunityToList(main, listUri, community.did); + + return { did: community.did, handle: community.handle }; +} + +// ------------------------------------------------------------------------- +// DM processing + quote posting +// ------------------------------------------------------------------------- + +type ChatConvo = { + id: string; + status?: string; + rev: string; + unreadCount?: number; + members: { did: string }[]; +}; + +type ChatReaction = { + value: string; + sender?: { did: string }; +}; + +type ChatMessage = { + $type?: string; + id: string; + rev: string; + text?: string; + sentAt: string; + sender?: { did: string }; + embed?: { + $type?: string; + record?: { uri?: string }; + } | null; + reactions?: ChatReaction[]; +}; + +/** + * Poll all convos for a community and turn fresh submission DMs into quote + * posts. Returns the number of quote posts created. + */ +export async function processCommunityDms( + env: App.Platform['env'], + db: D1Database, + row: CommunityRow +): Promise { + const community = await loginCommunity(row, env); + let created = 0; + + // Fetch all convos (both accepted and requested). We have to do two calls + // because listConvos filters by status. + const convoLists = await Promise.all([ + community.chatClient.get('chat.bsky.convo.listConvos', { + params: { limit: 50, status: 'accepted' } + }), + community.chatClient.get('chat.bsky.convo.listConvos', { + params: { limit: 50, status: 'request' } + }) + ]); + + const convos: ChatConvo[] = []; + for (const res of convoLists) { + if (res.ok) { + for (const c of res.data.convos as unknown as ChatConvo[]) convos.push(c); + } + } + + for (const convo of convos) { + // Skip empty convos / convos where we are the only participant. + const otherMembers = convo.members.filter((m) => m.did !== community.did); + if (otherMembers.length === 0) continue; + + // Accept request convos so we can process them. + if (convo.status === 'request') { + try { + await community.chatClient.post('chat.bsky.convo.acceptConvo', { + input: { convoId: convo.id } + }); + } catch { + // non-fatal + } + } + + // Pull the latest page of messages. Processed messages are marked + // with a ✅ reaction from the community itself, so we don't need any + // DB cursor — we just walk the page and skip anything already reacted. + const msgRes = await community.chatClient.get('chat.bsky.convo.getMessages', { + params: { convoId: convo.id, limit: 100 } + }); + if (!msgRes.ok) continue; + const messages = msgRes.data.messages as unknown as ChatMessage[]; + + // Process oldest → newest so reactions appear in order in the chat. + for (const msg of [...messages].reverse()) { + if (msg.$type === 'chat.bsky.convo.defs#deletedMessageView') continue; + if (!msg.sender) continue; + // Skip messages the community sent. + if (msg.sender.did === community.did) continue; + + // Already processed? Look for our own ✅ reaction on this message. + const alreadyProcessed = msg.reactions?.some( + (r) => r.value === PROCESSED_REACTION && r.sender?.did === community.did + ); + if (alreadyProcessed) continue; + + const submission = await parseSubmission({ text: msg.text, embed: msg.embed }); + if (!submission) { + // Not a valid submission — still mark it handled so we don't + // re-parse on every cron run. + await community.chatClient + .post('chat.bsky.convo.addReaction', { + input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } + }) + .catch(() => {}); + continue; + } + + // Extra safety: even if the reaction didn't stick last time, the + // posts table has a unique index on (community_did, quoted_post_uri) + // and is authoritative. Check before hitting createRecord so we + // never double-post to Bluesky. + if (await hasSubmission(db, community.did, submission.postUri)) { + await community.chatClient + .post('chat.bsky.convo.addReaction', { + input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } + }) + .catch(() => {}); + continue; + } + + const ok = await createSubmissionPost(db, community, submission, msg.sender.did as Did); + if (ok) { + created++; + await community.chatClient + .post('chat.bsky.convo.addReaction', { + input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } + }) + .catch(() => {}); + } + } + } + + return created; +} + +async function createSubmissionPost( + db: D1Database, + community: AuthedAccount, + submission: { title: string; postUri: ResourceUri }, + senderDid: Did +): Promise { + // Resolve the CID of the post being quoted. + const appview = new Client({ + handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) + }); + + const quotedCid = await getPostCid(appview, submission.postUri); + if (!quotedCid) return false; + + const rkey = TID.now(); + const createdAt = new Date().toISOString(); + + const res = await community.pdsClient.post('com.atproto.repo.createRecord', { + input: { + repo: community.did, + collection: 'app.bsky.feed.post', + rkey, + record: { + $type: 'app.bsky.feed.post', + text: submission.title, + createdAt, + embed: { + $type: 'app.bsky.embed.record', + record: { + uri: submission.postUri, + cid: quotedCid + } + } + } + } + }); + + if (!res.ok) return false; + + const inserted = await insertPost(db, { + uri: res.data.uri, + cid: res.data.cid, + community_did: community.did, + title: submission.title, + quoted_post_uri: submission.postUri, + quoted_post_cid: quotedCid, + author_did: senderDid, + like_count: 0, + reply_count: 0, + repost_count: 0, + indexed_at: createdAt + }); + + return inserted; +} + +// ------------------------------------------------------------------------- +// Post metric refresh +// ------------------------------------------------------------------------- + +/** + * Refresh cached like/reply/repost counts for stale posts per the decay + * schedule. Uses the public appview (unauthenticated) for efficiency. + */ +export async function refreshPostMetrics(db: D1Database): Promise { + const due = await getPostsDueForRefresh(db, 100); + if (due.length === 0) return 0; + + const appview = new Client({ + handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) + }); + + let refreshed = 0; + // getPosts caps at 25 URIs per request. + for (let i = 0; i < due.length; i += 25) { + const batch = due.slice(i, i + 25).map((p) => p.uri as ResourceUri); + try { + const res = await appview.get('app.bsky.feed.getPosts', { + params: { uris: batch } + }); + if (!res.ok) continue; + for (const post of res.data.posts) { + await updatePostMetrics(db, post.uri, { + like_count: post.likeCount ?? 0, + reply_count: post.replyCount ?? 0, + repost_count: post.repostCount ?? 0 + }); + refreshed++; + } + } catch (e) { + console.error('[refreshPostMetrics] batch failed', e); + } + } + + return refreshed; +} + +// ------------------------------------------------------------------------- +// Top-level cron entry point +// ------------------------------------------------------------------------- + +export async function runCronTick(env: App.Platform['env']): Promise<{ + communitiesChecked: number; + postsCreated: number; + postsRefreshed: number; + errors: string[]; +}> { + const db = env.DB; + if (!db) throw new Error('DB binding missing'); + + const errors: string[] = []; + const communities = await listCommunities(db); + + // Keep profile cache refreshed (cheap — one appview call per community). + const appview = new Client({ + handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) + }); + + let postsCreated = 0; + for (const row of communities) { + try { + postsCreated += await processCommunityDms(env, db, row); + } catch (e) { + console.error(`[cron] community ${row.handle} failed:`, e); + errors.push(`${row.handle}: ${String(e)}`); + } + + // Best-effort: refresh profile metadata (avatar/display name/description). + try { + const profile = await appview.get('app.bsky.actor.getProfile', { + params: { actor: row.did } + }); + if (profile.ok) { + await updateCommunityProfile(db, row.did, { + display_name: profile.data.displayName ?? null, + avatar: profile.data.avatar ?? null, + description: profile.data.description ?? null + }); + } + } catch { + // non-fatal + } + } + + const postsRefreshed = await refreshPostMetrics(db).catch((e) => { + errors.push(`refresh: ${String(e)}`); + return 0; + }); + + return { + communitiesChecked: communities.length, + postsCreated, + postsRefreshed, + errors + }; +} + +// ------------------------------------------------------------------------- +// Convenience: combined feed wrapper so routes don't import db.ts directly. +// ------------------------------------------------------------------------- + +export { getCombinedFeed }; diff --git a/src/lib/reddit/crypto.ts b/src/lib/reddit/crypto.ts new file mode 100644 index 0000000..df7bb7b --- /dev/null +++ b/src/lib/reddit/crypto.ts @@ -0,0 +1,67 @@ +// AES-GCM password encryption for stored community app passwords. +// Uses Web Crypto (native in Workers + modern Node). +// +// The key is derived from the COMMUNITY_ENCRYPTION_KEY env var with SHA-256 so +// any string length works. Ciphertext + IV are both base64url-encoded. + +async function deriveKey(secret: string): Promise { + const bytes = new TextEncoder().encode(secret); + // Copy into a fresh ArrayBuffer so the strict lib.dom BufferSource + // type checks (Uint8Array → ArrayBuffer). + const buf = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buf).set(bytes); + const hash = await crypto.subtle.digest('SHA-256', buf); + return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt' + ]); +} + +function toB64Url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromB64Url(str: string): Uint8Array { + const b64 = str.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const bin = atob(padded); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function toArrayBuffer(u8: Uint8Array): ArrayBuffer { + const buf = new ArrayBuffer(u8.byteLength); + new Uint8Array(buf).set(u8); + return buf; +} + +export async function encryptPassword( + plaintext: string, + secret: string +): Promise<{ ciphertext: string; iv: string }> { + const key = await deriveKey(secret); + const ivBytes = crypto.getRandomValues(new Uint8Array(12)); + const ivBuf = toArrayBuffer(ivBytes); + const encoded = toArrayBuffer(new TextEncoder().encode(plaintext)); + const ct = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv: ivBuf }, key, encoded) + ); + return { ciphertext: toB64Url(ct), iv: toB64Url(ivBytes) }; +} + +export async function decryptPassword( + ciphertext: string, + iv: string, + secret: string +): Promise { + const key = await deriveKey(secret); + const pt = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: toArrayBuffer(fromB64Url(iv)) }, + key, + toArrayBuffer(fromB64Url(ciphertext)) + ); + return new TextDecoder().decode(pt); +} diff --git a/src/lib/reddit/db.ts b/src/lib/reddit/db.ts new file mode 100644 index 0000000..d0f966f --- /dev/null +++ b/src/lib/reddit/db.ts @@ -0,0 +1,239 @@ +// Lightweight typed accessors for the reddit-on-bsky D1 schema. + +import type { Did } from '@atcute/lexicons'; + +export type CommunityRow = { + did: Did; + handle: string; + pds: string; + password_ciphertext: string; + password_iv: string; + display_name: string | null; + avatar: string | null; + description: string | null; + created_at: string; +}; + +export type PostRow = { + uri: string; + cid: string; + community_did: Did; + title: string; + quoted_post_uri: string; + quoted_post_cid: string | null; + author_did: string | null; + like_count: number; + reply_count: number; + repost_count: number; + indexed_at: string; + last_refreshed_at: string; +}; + +export type PostWithCommunity = PostRow & { + community_handle: string; + community_display_name: string | null; + community_avatar: string | null; +}; + +export async function listCommunities(db: D1Database): Promise { + const res = await db + .prepare( + 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities ORDER BY created_at DESC' + ) + .all(); + return res.results ?? []; +} + +export async function getCommunityByHandle( + db: D1Database, + handle: string +): Promise { + const res = await db + .prepare( + 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities WHERE handle = ?' + ) + .bind(handle) + .first(); + return res ?? null; +} + +export async function getCommunityByDid( + db: D1Database, + did: string +): Promise { + const res = await db + .prepare( + 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities WHERE did = ?' + ) + .bind(did) + .first(); + return res ?? null; +} + +export async function insertCommunity( + db: D1Database, + row: Omit +): Promise { + await db + .prepare( + 'INSERT INTO communities (did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ) + .bind( + row.did, + row.handle, + row.pds, + row.password_ciphertext, + row.password_iv, + row.display_name, + row.avatar, + row.description + ) + .run(); +} + +export async function updateCommunityProfile( + db: D1Database, + did: string, + data: { display_name: string | null; avatar: string | null; description: string | null } +): Promise { + await db + .prepare( + 'UPDATE communities SET display_name = ?, avatar = ?, description = ? WHERE did = ?' + ) + .bind(data.display_name, data.avatar, data.description, did) + .run(); +} + +/** + * Returns true if the community already has a submission post quoting the + * given URI. Used to dedup before creating the Bluesky record so we never + * double-post. + */ +export async function hasSubmission( + db: D1Database, + communityDid: string, + quotedPostUri: string +): Promise { + const res = await db + .prepare('SELECT 1 FROM posts WHERE community_did = ? AND quoted_post_uri = ? LIMIT 1') + .bind(communityDid, quotedPostUri) + .first<{ 1: number }>(); + return !!res; +} + +export async function insertPost(db: D1Database, row: Omit): Promise { + try { + await db + .prepare( + 'INSERT INTO posts (uri, cid, community_did, title, quoted_post_uri, quoted_post_cid, author_did, like_count, reply_count, repost_count, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ) + .bind( + row.uri, + row.cid, + row.community_did, + row.title, + row.quoted_post_uri, + row.quoted_post_cid, + row.author_did, + row.like_count, + row.reply_count, + row.repost_count, + row.indexed_at + ) + .run(); + return true; + } catch (e) { + // Unique constraint on (community_did, quoted_post_uri) — dedup hit. + if (String(e).includes('UNIQUE')) return false; + throw e; + } +} + +export async function updatePostMetrics( + db: D1Database, + uri: string, + metrics: { like_count: number; reply_count: number; repost_count: number } +): Promise { + await db + .prepare( + 'UPDATE posts SET like_count = ?, reply_count = ?, repost_count = ?, last_refreshed_at = datetime("now") WHERE uri = ?' + ) + .bind(metrics.like_count, metrics.reply_count, metrics.repost_count, uri) + .run(); +} + +export async function getRecentPostsForCommunity( + db: D1Database, + communityDid: string, + limit = 50 +): Promise { + const res = await db + .prepare( + 'SELECT * FROM posts WHERE community_did = ? ORDER BY indexed_at DESC LIMIT ?' + ) + .bind(communityDid, limit) + .all(); + return res.results ?? []; +} + +export async function getCombinedFeed( + db: D1Database, + limit = 50 +): Promise { + const res = await db + .prepare( + `SELECT p.*, c.handle AS community_handle, c.display_name AS community_display_name, c.avatar AS community_avatar + FROM posts p + JOIN communities c ON c.did = p.community_did + ORDER BY p.indexed_at DESC + LIMIT ?` + ) + .bind(limit) + .all(); + return res.results ?? []; +} + +export async function getMeta(db: D1Database, key: string): Promise { + const res = await db + .prepare('SELECT value FROM meta WHERE key = ?') + .bind(key) + .first<{ value: string }>(); + return res?.value ?? null; +} + +export async function setMeta(db: D1Database, key: string, value: string): Promise { + await db + .prepare( + 'INSERT INTO meta (key, value, updated_at) VALUES (?, ?, datetime("now")) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at' + ) + .bind(key, value) + .run(); +} + +// Posts due for metric refresh based on age-decayed cadence. +// Age buckets: +// <1h → every 60s (every run) +// 1-12h → every 5m +// 12-24h → every 10m +// 1-7d → every 1h +// >7d → every 24h +export async function getPostsDueForRefresh( + db: D1Database, + limit = 100 +): Promise<{ uri: string }[]> { + const res = await db + .prepare( + `SELECT uri FROM posts + WHERE + (julianday('now') - julianday(indexed_at)) * 24 < 1 + OR ((julianday('now') - julianday(indexed_at)) * 24 < 12 AND (julianday('now') - julianday(last_refreshed_at)) * 1440 >= 5) + OR ((julianday('now') - julianday(indexed_at)) * 24 < 24 AND (julianday('now') - julianday(last_refreshed_at)) * 1440 >= 10) + OR ((julianday('now') - julianday(indexed_at)) < 7 AND (julianday('now') - julianday(last_refreshed_at)) * 24 >= 1) + OR ((julianday('now') - julianday(indexed_at)) >= 7 AND (julianday('now') - julianday(last_refreshed_at)) >= 1) + ORDER BY last_refreshed_at ASC + LIMIT ?` + ) + .bind(limit) + .all<{ uri: string }>(); + return res.results ?? []; +} diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts new file mode 100644 index 0000000..5e69cb5 --- /dev/null +++ b/src/lib/reddit/server/communities.remote.ts @@ -0,0 +1,92 @@ +import { error } from '@sveltejs/kit'; +import { command, getRequestEvent } from '$app/server'; +import * as v from 'valibot'; +import { + getRecentPostsForCommunity, + getCombinedFeed, + listCommunities, + getCommunityByHandle, + type CommunityRow, + type PostRow, + type PostWithCommunity +} from '../db'; +import { registerCommunity } from '../bot'; + +// Never ship password_ciphertext/iv to the client. +type PublicCommunity = Omit; +function sanitize(row: CommunityRow): PublicCommunity { + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { password_ciphertext, password_iv, ...rest } = row; + /* eslint-enable @typescript-eslint/no-unused-vars */ + return rest; +} + +export const register = command( + v.object({ + identifier: v.pipe(v.string(), v.minLength(3)), + password: v.pipe(v.string(), v.minLength(1)) + }), + async (input) => { + const { platform } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) error(500, 'DB binding unavailable'); + + try { + const result = await registerCommunity(env, env.DB, input.identifier, input.password); + return { ok: true, did: result.did, handle: result.handle }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error('[register]', e); + error(400, `Registration failed: ${msg}`); + } + } +); + +export const getCommunities = command( + v.object({}), + async (): Promise => { + const { platform } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) return []; + + const rows = await listCommunities(env.DB); + return rows.map(sanitize); + } +); + +export const getCommunity = command( + v.object({ handle: v.string() }), + async (input): Promise => { + const { platform } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) return null; + + const row = await getCommunityByHandle(env.DB, input.handle); + return row ? sanitize(row) : null; + } +); + +export const getCommunityPosts = command( + v.object({ handle: v.string(), limit: v.optional(v.number()) }), + async (input): Promise => { + const { platform } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) return []; + + const row = await getCommunityByHandle(env.DB, input.handle); + if (!row) return []; + + return getRecentPostsForCommunity(env.DB, row.did, input.limit ?? 50); + } +); + +export const getHomeFeed = command( + v.object({ limit: v.optional(v.number()) }), + async (input): Promise => { + const { platform } = getRequestEvent(); + const env = platform?.env; + if (!env || !env.DB) return []; + + return getCombinedFeed(env.DB, input.limit ?? 50); + } +); diff --git a/src/lib/reddit/server/quoted-posts.remote.ts b/src/lib/reddit/server/quoted-posts.remote.ts new file mode 100644 index 0000000..4950a91 --- /dev/null +++ b/src/lib/reddit/server/quoted-posts.remote.ts @@ -0,0 +1,45 @@ +// Batch-fetch PostView data for a list of URIs so the RedditPostCard can +// render a proper quoted post with author, text, images, etc. + +import { command, getRequestEvent } from '$app/server'; +import { Client, simpleFetchHandler } from '@atcute/client'; +import type { ResourceUri } from '@atcute/lexicons'; +import * as v from 'valibot'; + +export const getQuotedPosts = command( + v.object({ + uris: v.array(v.string()) + }), + async (input) => { + const { locals } = getRequestEvent(); + + // Prefer the authenticated client when available (gives viewer state), + // fall back to the unauthenticated public appview. + const client = + locals.client ?? + new Client({ + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) + }); + + // getPosts caps at 25 URIs per call. Chunk requests. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const out: Record = {}; + for (let i = 0; i < input.uris.length; i += 25) { + const batch = input.uris.slice(i, i + 25) as ResourceUri[]; + try { + const res = await client.get('app.bsky.feed.getPosts', { + params: { uris: batch } + }); + if (res.ok) { + for (const p of res.data.posts) { + out[p.uri] = p; + } + } + } catch (e) { + console.error('[getQuotedPosts] batch failed', e); + } + } + + return { posts: out }; + } +); diff --git a/src/lib/reddit/submission.ts b/src/lib/reddit/submission.ts new file mode 100644 index 0000000..9b19a6f --- /dev/null +++ b/src/lib/reddit/submission.ts @@ -0,0 +1,103 @@ +// Parse submission DMs → (title, AT URI of post to quote). + +import type { Client } from '@atcute/client'; +import type { Did, ResourceUri } from '@atcute/lexicons'; +import { resolveHandle } from '$lib/atproto/methods'; + +// Matches bsky.app post URLs. +const BSKY_POST_URL = /https?:\/\/bsky\.app\/profile\/([^/\s]+)\/post\/([A-Za-z0-9]+)/i; +// Matches at:// URIs. +const AT_URI = /at:\/\/(did:[^/\s]+)\/(app\.bsky\.feed\.post)\/([A-Za-z0-9]+)/i; + +export type ParsedSubmission = { + title: string; + postUri: ResourceUri; +}; + +export type DmInput = { + text: string | undefined; + // chat message embed (app.bsky.embed.record), when the user "shares" a + // post into the DM via the Bluesky client — the post is attached as an + // embed rather than pasted as a URL. + embed?: { + $type?: string; + record?: { uri?: string }; + } | null; +}; + +/** + * Extract a valid submission from a DM. Accepts either a message's raw text + * (which may contain a Bluesky post URL) or an attached record embed + * (`app.bsky.embed.record`). + * + * Returns null if the message doesn't reference a recognizable Bluesky post. + */ +export async function parseSubmission(msg: DmInput): Promise { + const text = msg.text ?? ''; + + // 1. Preferred path: the user shared a post via the chat "share to DM" + // flow, which attaches an app.bsky.embed.record to the message. + const embedUri = msg.embed?.record?.uri; + if (embedUri && /^at:\/\/did:[^/]+\/app\.bsky\.feed\.post\//.test(embedUri)) { + const title = text.trim(); + if (!title) return null; + return { title, postUri: embedUri as ResourceUri }; + } + + if (!text) return null; + + // 2. at:// URI pasted in the text. + const atMatch = text.match(AT_URI); + if (atMatch) { + const uri = atMatch[0] as ResourceUri; + const title = stripRange(text, atMatch.index!, atMatch[0].length); + if (!title) return null; + return { title, postUri: uri }; + } + + // 3. bsky.app URL pasted in the text. + const urlMatch = text.match(BSKY_POST_URL); + if (!urlMatch) return null; + + const handleOrDid = urlMatch[1]; + const rkey = urlMatch[2]; + + let did: Did; + try { + if (handleOrDid.startsWith('did:')) { + did = handleOrDid as Did; + } else { + did = await resolveHandle({ handle: handleOrDid as `${string}.${string}` }); + } + } catch { + return null; + } + + const postUri = `at://${did}/app.bsky.feed.post/${rkey}` as ResourceUri; + const title = stripRange(text, urlMatch.index!, urlMatch[0].length); + if (!title) return null; + + return { title, postUri }; +} + +function stripRange(text: string, start: number, length: number): string { + const before = text.slice(0, start); + const after = text.slice(start + length); + return (before + ' ' + after).replace(/\s+/g, ' ').trim(); +} + +/** + * Fetch the CID of a post at a given AT URI. Required to build a valid + * app.bsky.embed.record (which requires both uri and cid). + */ +export async function getPostCid( + client: Client, + uri: ResourceUri +): Promise { + // Pull 1 post from the public appview — faster than getRecord to the PDS. + const res = await client.get('app.bsky.feed.getPosts', { + params: { uris: [uri] } + }); + if (!res.ok || res.data.posts.length === 0) return null; + return res.data.posts[0].cid; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c73d4d0..fb6c60d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,62 +1,97 @@ -
-
- -
+
+

atmo communities

+ +
+
+

+ Communities +

+ + + Register + +
+ + {#if loading && communities.length === 0} +
Loading…
+ {:else if communities.length === 0} +
+ No communities registered yet. + Register the first one. +
+ {:else} +
+ {#each communities as c (c.did)} + + + {c.display_name ?? c.handle} + + {/each} +
+ {/if} +
+ +
+

+ Latest +

+ + {#if loading} +
+ +
+ {:else if feed.length === 0} +

+ No submissions yet. +

+ {:else} +
+ {#each feed as p (p.uri)} + + {/each} +
+ {/if} +
diff --git a/src/routes/_cron/check-dms/+server.ts b/src/routes/_cron/check-dms/+server.ts new file mode 100644 index 0000000..b87d2fb --- /dev/null +++ b/src/routes/_cron/check-dms/+server.ts @@ -0,0 +1,36 @@ +// Cron target: checks DMs for all communities, creates quote posts, and +// refreshes post metrics. Called by the Cloudflare Workers scheduled handler +// (see cron/job.js + cron/append.js). Can also be invoked manually for +// debugging via GET/POST with ?secret=... when CRON_SECRET is set. + +import type { RequestHandler } from './$types'; +import { json, error } from '@sveltejs/kit'; +import { runCronTick } from '$lib/reddit/bot'; + +async function handle(request: Request, platform: App.Platform | undefined) { + const env = platform?.env; + if (!env) error(500, 'Platform env unavailable'); + + // Optional shared-secret guard for external invocation. The scheduled + // handler doesn't send a secret, but its Request is constructed internally + // so we trust it unconditionally when there's no Authorization header at + // all AND the request came from cron (see below for a better check). + // + // Simpler: only enforce the secret when CRON_SECRET is configured AND the + // request carries any secret param at all — the scheduled handler calls us + // without one. + if (env.CRON_SECRET) { + const url = new URL(request.url); + const provided = url.searchParams.get('secret') ?? request.headers.get('x-cron-secret'); + const isScheduled = url.pathname === '/_cron/check-dms' && !provided && request.headers.get('x-cron-internal') === '1'; + if (!isScheduled && provided !== env.CRON_SECRET) { + error(401, 'Unauthorized'); + } + } + + const result = await runCronTick(env); + return json(result); +} + +export const GET: RequestHandler = async ({ request, platform }) => handle(request, platform); +export const POST: RequestHandler = async ({ request, platform }) => handle(request, platform); diff --git a/src/routes/community/[handle]/+page.svelte b/src/routes/community/[handle]/+page.svelte new file mode 100644 index 0000000..87a598e --- /dev/null +++ b/src/routes/community/[handle]/+page.svelte @@ -0,0 +1,101 @@ + + +
+ {#if loading} +
+ +
+ {:else if loadError} +

{loadError}

+ {:else if community} +
+ +
+

+ {community.display_name ?? community.handle} +

+ + @{community.handle} + + {#if community.description} +

+ {community.description} +

+ {/if} +
+
+ +
+ Submit to this community by DM'ing @{community.handle} a Bluesky post link with your title. +
+ + {#if posts.length === 0} +

+ No submissions yet. +

+ {:else} +
+ {#each posts as p (p.uri)} + + {/each} +
+ {/if} + {/if} +
diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..37a6aaf --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,103 @@ + + +
+

Register a community

+

+ Use an existing Bluesky account as the community. Submissions DM'd to this account will be + turned into quote posts automatically. +

+ +
+ + + + + {#if errorMsg} +
+ {errorMsg} +
+ {/if} + + {#if success} +
+ +
+ Registered @{success.handle}. + +
+
+ {/if} + + +
+
diff --git a/svelte.config.js b/svelte.config.js index eb8101d..36bda09 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -6,7 +6,15 @@ const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter(), + adapter: adapter({ + // Expose wrangler.jsonc bindings (D1, KV, vars) as platform.env + // during `vite dev`. See + // https://svelte.dev/docs/kit/adapter-cloudflare#Bindings + platformProxy: { + configPath: 'wrangler.jsonc', + persist: true + } + }), experimental: { remoteFunctions: true } diff --git a/wrangler.jsonc b/wrangler.jsonc index 765a310..af39f9a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -23,5 +23,16 @@ "binding": "OAUTH_STATES", "id": "8cff96d0d78f4966a09b6b8833cab54e" } - ] + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "atmo-social-db", + "database_id": "REPLACE_ME_WITH_D1_ID", + "migrations_dir": "migrations" + } + ], + "triggers": { + "crons": ["* * * * *"] + } }