This repository has no description
0

Configure Feed

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

disjecta / src / web.js
85 kB 2025 lines
1import { randomBytes } from 'node:crypto'; 2import { Hono } from 'hono'; 3import { oauth, isLoopback, billingDisabled } from './oauth.js'; 4import * as discord from './discord-oauth.js'; 5import { searchGuildMembers } from './discord-oauth.js'; 6import { setSession, getSession, clearSession, SESSION_MAX_AGE_LONG } from './cookies.js'; 7import { 8 upsertUserByDiscord, 9 getUserByDiscord, 10 getUserByCustomer, 11 upsertJob, 12 listJobsForUser, 13 getJobByUserAndDid, 14 getJobById, 15 setJobProfile, 16 deleteJob, 17 listRulesForJob, 18 getRuleForJob, 19 insertRule, 20 updateRuleForJob, 21 deleteRuleForJob, 22 toggleRuleForJob, 23 getCounter, 24 listCounters, 25 setUserSubscription, 26 setActiveByCustomer, 27 setActiveByDiscord, 28 deactivateUserAndDisableRules, 29 clearStripeCustomer, 30 tryRecordWebhookEvent, 31 oauthStores, 32 graceUntilEpoch, 33 isWithinGrace, 34 insertBackfillJob, 35 getActiveBackfillJobForRule, 36 getLatestBackfillJobForRule, 37 listBackfillJobsForRule, 38 getActiveBackfillJobForRuleChannel, 39 cancelBackfillJob, 40} from './db.js'; 41import { resolveDidProfile } from './atproto-identity.js'; 42import { client as botClient } from './bot.js'; 43import { stripe, priceInfo } from './stripe.js'; 44import { KNOWN_DESTINATIONS, CATALOG, metaFor } from './destinations/index.js'; 45import { listSembleCollections } from './destinations/bookmark.js'; 46import { refreshActiveGuilds } from './active-guilds.js'; 47import { validateMatchPattern } from './match.js'; 48 49const PUBLIC_URL = process.env.PUBLIC_URL; 50const SESSION_SECRET = process.env.SESSION_SECRET; 51const DISCORD_REDIRECT = `${PUBLIC_URL}/discord/callback`; 52 53if (!PUBLIC_URL) throw new Error('PUBLIC_URL is required'); 54if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required'); 55 56const errorPage = (c, message, status = 400) => c.html(` 57 <!doctype html> 58 <meta charset="utf-8"> 59 <title>something went wrong</title> 60 <h1>something went wrong</h1> 61 <p>${message}</p> 62 <p><a href="/">Start over</a></p> 63`, status); 64 65export const app = new Hono(); 66 67// Resolves the request to its (account, job, session) triple. Returns null 68// if the user is not signed in OR has no job for the DID in their cookie. 69// In loopback dev mode the atproto OAuth flow is disabled, so there is never 70// a job — callers should treat ctx.job as nullable and degrade the UI for 71// loopback (no rules editing). 72function requireActiveJob(c) { 73 const s = getSession(c, SESSION_SECRET); 74 if (!s?.discord?.userId) return null; 75 if (!isLoopback && !s?.did) return null; 76 let user = getUserByDiscord.get(s.discord.userId); 77 if (!user) return null; 78 if (billingDisabled && !user.active) { 79 setActiveByDiscord(user.discord_id, 1); 80 refreshActiveGuilds(); 81 user = getUserByDiscord.get(s.discord.userId); 82 } 83 if (isLoopback) return { user, job: null, session: s }; 84 const job = getJobByUserAndDid(user.id, s.did); 85 if (!job) return null; 86 return { user, job, session: s }; 87} 88 89function botGuildsForUser(userGuildIds) { 90 if (!botClient.isReady?.()) return []; 91 const cache = botClient.guilds.cache; 92 return (userGuildIds ?? []) 93 .map(id => cache.get(id)) 94 .filter(Boolean) 95 .map(g => ({ id: g.id, name: g.name })); 96} 97 98// IDs of guilds the bot AND the user are both in, computed against the bot's 99// live cache. Cookie-stored list is the user's full guild membership. 100function sharedGuildIdsFor(userGuildIds) { 101 if (!botClient.isReady?.() || !userGuildIds?.length) return []; 102 const cache = botClient.guilds.cache; 103 return userGuildIds.filter(id => cache.has(id)); 104} 105 106app.get('/', async (c) => { 107 const s = getSession(c, SESSION_SECRET); 108 const discordLinked = !!s?.discord; 109 const sharedGuildIds = sharedGuildIdsFor(s?.discord?.guildIds); 110 const sharedGuildCount = sharedGuildIds.length; 111 const discordReady = discordLinked && sharedGuildCount > 0; 112 const account = discordLinked ? getUserByDiscord.get(s.discord.userId) : null; 113 const jobs = account ? listJobsForUser.all(account.id) : []; 114 const activeJob = (account && s?.did) ? jobs.find(j => j.did === s.did) ?? null : null; 115 const didLinked = !!activeJob; 116 117 const discordWho = discordLinked ? renderDiscordWho(s.discord) : ''; 118 const discordCard = !discordLinked ? ` 119 <p><a href="/discord/start"><button type="button">Link Discord account</button></a></p> 120 <p><small>So we know which Discord ID's messages are yours.</small></p> 121 ` : !discordReady ? ` 122 ${discordWho} 123 <p class="warning"> 124 The bot isn't in any of your servers yet — without that, no rules can fire. 125 </p> 126 <p> 127 <a href="${esc(botInstallUrl())}" target="_blank" rel="noopener"> 128 <button type="button">Add bot to a server</button> 129 </a> 130 <a href="/discord/start"><button type="button">Refresh after invite</button></a> 131 </p> 132 <p><small>Click "Add bot to a server", then "Refresh after invite" once it's in.</small></p> 133 <form action="/cancel" method="post"><button type="submit">Disconnect</button></form> 134 ` : ` 135 ${discordWho} 136 <p><small>${sharedGuildCount} shared server${sharedGuildCount === 1 ? '' : 's'} with the bot</small></p> 137 <p> 138 <a href="${esc(botInstallUrl())}" target="_blank" rel="noopener"><button type="button">Add to another server</button></a> 139 </p> 140 <form action="/cancel" method="post"><button type="submit">Disconnect</button></form> 141 `; 142 143 const atmoCard = isLoopback ? ` 144 <p><small>atproto OAuth is disabled in loopback dev mode. Switch <code>PUBLIC_URL</code> to a tunneled HTTPS URL to enable this step.</small></p> 145 ` : !discordReady ? ` 146 <form action="/login" method="get"> 147 <input name="handle" placeholder="you.bsky.social" disabled> 148 <button type="submit" disabled>Sign in</button> 149 </form> 150 <p><small>Complete the Discord step first.</small></p> 151 ` : await renderAtmoCard({ jobs, activeJob }); 152 153 // Identity-stage gating. In loopback dev mode, atproto link is optional. 154 const identityComplete = discordReady && (didLinked || isLoopback); 155 const isActive = !!account?.active; 156 157 // Third card states. 158 // - billingDisabled (test period / loopback): auto-activated, no payment. 159 // - grace window (BILLING_DISABLED=0 + within GRACE_UNTIL): active without a 160 // stripe customer; show the grace deadline so users know it's transitional. 161 // - paid mode: Subscribe / Manage based on account.active. 162 const subscribeButtonLabel = priceInfo?.label ? `Subscribe — ${priceInfo.label}` : 'Subscribe'; 163 const inGraceWindow = !billingDisabled && isWithinGrace() && !account?.stripe_customer_id; 164 const graceUntilDate = graceUntilEpoch != null 165 ? new Date(graceUntilEpoch * 1000).toISOString().slice(0, 10) 166 : null; 167 168 let paymentCard; 169 if (!identityComplete) { 170 paymentCard = `<p><small>Complete the previous steps to enable subscription.</small></p>`; 171 } else if (billingDisabled) { 172 paymentCard = ` 173 <p><strong>Active — test period.</strong></p> 174 <p><small>Payment is not required while disjecta is in early testing. Rules and dispatch are fully functional.</small></p> 175 `; 176 } else if (isActive && account?.stripe_customer_id) { 177 paymentCard = ` 178 <p><strong>Active — subscribed.</strong></p> 179 <form action="/portal" method="post"><button type="submit">Manage subscription</button></form> 180 `; 181 } else if (inGraceWindow) { 182 paymentCard = ` 183 <p><strong>Active — free until ${esc(graceUntilDate)}.</strong></p> 184 <p><small>You've been carried over from the test period. After ${esc(graceUntilDate)}, rules will stop firing until you subscribe.</small></p> 185 <form action="/subscribe" method="post"><button type="submit">${esc(subscribeButtonLabel)}</button></form> 186 `; 187 } else { 188 paymentCard = ` 189 <p>Final step — subscribe to enable rule processing.</p> 190 <form action="/subscribe" method="post"><button type="submit">${esc(subscribeButtonLabel)}</button></form> 191 `; 192 } 193 194 const cardClass = (enabled) => enabled ? 'card' : 'card card-disabled'; 195 const arrow = (active) => `<div class="arrow ${active ? '' : 'arrow-dim'}" aria-hidden="true">→</div>`; 196 197 const ctx = identityComplete ? requireActiveJob(c) : null; 198 const rulesSection = (ctx && ctx.job) ? renderRulesSection(ctx) : ''; 199 200 return c.html(layout('disjecta', ` 201 ${helpButton()} 202 <h1>disjecta</h1> 203 <p>Route Discord messages — full content or just URLs — into atproto-shaped destinations.</p> 204 <div class="row identity-row"> 205 <div class="${cardClass(true)}"><h2>1. Discord</h2>${discordCard}</div> 206 ${arrow(discordReady)} 207 <div class="${cardClass(discordReady)}"><h2>2. Atmosphere</h2>${atmoCard}</div> 208 ${arrow(identityComplete)} 209 <div class="${cardClass(identityComplete)}"><h2>3. ${billingDisabled ? 'Account' : 'Subscription'}</h2>${paymentCard}</div> 210 </div> 211 ${rulesSection} 212 `)); 213}); 214 215function discordAvatarUrl(userId, hash) { 216 if (!userId || !hash) return null; 217 const ext = hash.startsWith('a_') ? 'gif' : 'png'; 218 return `https://cdn.discordapp.com/avatars/${userId}/${hash}.${ext}?size=64`; 219} 220 221function renderDiscordWho(d) { 222 const avatarUrl = discordAvatarUrl(d.userId, d.avatar); 223 const label = d.globalName ?? d.username ?? d.userId; 224 return ` 225 <div class="who"> 226 ${avatarUrl 227 ? `<img class="avatar" src="${esc(avatarUrl)}" alt="" referrerpolicy="no-referrer">` 228 : `<div class="avatar avatar-placeholder" aria-hidden="true"></div>`} 229 <code>${esc(label)}</code> 230 </div> 231 `; 232} 233 234function renderJobWho(job) { 235 return ` 236 <div class="who"> 237 ${job.did_avatar 238 ? `<img class="avatar" src="${esc(job.did_avatar)}" alt="" referrerpolicy="no-referrer">` 239 : `<div class="avatar avatar-placeholder" aria-hidden="true"></div>`} 240 <code>${esc(job.did_handle ?? job.did)}</code> 241 </div> 242 `; 243} 244 245// Probe whether the atproto OAuth session for a given DID is still live. We 246// check by reading the session-store row (cheap, local) — a missing row means 247// the user revoked OR we never had one yet. Note: this does NOT prove the 248// refresh token is still accepted by the PDS; if it's been revoked upstream 249// we'll only discover that on the next dispatch. Good enough as a UI hint. 250async function jobOauthLive(did) { 251 try { 252 const v = await oauthStores.session.get(did); 253 return v != null; 254 } catch { 255 return false; 256 } 257} 258 259async function renderAtmoCard({ jobs, activeJob }) { 260 if (jobs.length === 0) { 261 return ` 262 <form action="/login" method="get"> 263 <input name="handle" placeholder="you.bsky.social" required> 264 <button type="submit">Sign in</button> 265 </form> 266 `; 267 } 268 269 const liveness = new Map(); 270 await Promise.all(jobs.map(async (j) => liveness.set(j.id, await jobOauthLive(j.did)))); 271 272 const activePill = activeJob ? ` 273 <div class="atmo-active"> 274 ${renderJobWho(activeJob)} 275 <div class="atmo-actions"> 276 <form action="/atmo/sign-out-current" method="post"> 277 <button type="submit" title="Clear OAuth session for this DID; rules stay configured.">Sign out</button> 278 </form> 279 <form action="/jobs/${activeJob.id}/delete" method="post" 280 onsubmit="return confirm('Delete this atproto identity and all its rules? This cannot be undone.')"> 281 <button type="submit" class="danger" title="Delete this identity and all its rules.">Delete</button> 282 </form> 283 </div> 284 </div> 285 ` : ''; 286 287 const others = jobs.filter(j => activeJob ? j.id !== activeJob.id : true); 288 const otherRows = others.map(j => { 289 const live = liveness.get(j.id); 290 const switchAction = live 291 ? `<form action="/jobs/${j.id}/switch" method="post"><button type="submit">Switch to</button></form>` 292 : `<form action="/login" method="get"> 293 <input type="hidden" name="handle" value="${esc(j.did)}"> 294 <button type="submit" class="warning" title="OAuth session expired — re-authorize to switch to this identity.">Re-auth</button> 295 </form>`; 296 const badge = live ? '' : `<span class="warning small">re-auth needed</span>`; 297 return ` 298 <div class="atmo-other"> 299 ${renderJobWho(j)} 300 <div class="atmo-actions"> 301 ${badge} 302 ${switchAction} 303 <form action="/jobs/${j.id}/delete" method="post" 304 onsubmit="return confirm('Delete this atproto identity and all its rules? This cannot be undone.')"> 305 <button type="submit" class="danger">Delete</button> 306 </form> 307 </div> 308 </div> 309 `; 310 }).join(''); 311 312 const addAnother = ` 313 <form action="/login" method="get" class="atmo-add"> 314 <input name="handle" placeholder="another.bsky.social" required> 315 <button type="submit">+ Link another</button> 316 </form> 317 `; 318 319 // When the cookie's did doesn't match any job (e.g. you just deleted the 320 // active one), prompt to pick one from the list above or sign in fresh. 321 const noActive = !activeJob ? ` 322 <p><small>No identity selected. Switch to one above or link a new identity.</small></p> 323 ` : ''; 324 325 return `${activePill}${noActive}${otherRows}${addAnother}`; 326} 327 328// Construct the bot install URL from the Discord client id. Permissions 329// bitmask: View Channels + Read Message History + Send Messages + Use 330// Application (Slash) Commands. Matches what we configured in the developer 331// portal's Installation tab. 332function botInstallUrl() { 333 const clientId = process.env.DISCORD_CLIENT_ID ?? ''; 334 const PERMS = 2147552256; // 0x400 + 0x800 + 0x10000 + 0x80000000 335 const params = new URLSearchParams({ 336 client_id: clientId, 337 permissions: String(PERMS), 338 scope: 'bot applications.commands', 339 }); 340 return `https://discord.com/api/oauth2/authorize?${params}`; 341} 342 343function helpButton() { 344 return ` 345 <details class="help"> 346 <summary aria-label="How this works">?</summary> 347 <div class="help-panel"> 348 <h3>How this works</h3> 349 <ol> 350 <li><strong>Link Discord.</strong> We learn your Discord user ID and which servers you're in.</li> 351 <li><strong>Sign in with an Atmosphere account.</strong> Fine-grained OAuth.</li> 352 <li><strong>Configure rules.</strong> Pick which server/channel/author to listen to, what to extract (URLs or full message content), and where to route the result.</li> 353 <li><strong>Subscribe.</strong> $2/month flat. The bot only acts on rules belonging to active subscribers.</li> 354 </ol> 355 <p><strong>Pass-through.</strong> Messages flow through disjecta and out to your destinations; we don't store them.</p> 356 </div> 357 </details> 358 `; 359} 360 361// Defaults object consumed by renderRuleForm. Used for both the empty add 362// form and the pre-filled edit form so the two paths share rendering. 363const EMPTY_RULE_DEFAULTS = { 364 guild_id: '', 365 channel_id_display: '', 366 author_mode: 'self', 367 author_id_display: '', 368 mode: 'urls', 369 match_kind: '', 370 match_value: '', 371 destination_kind: '', 372 bookmark_lexicons: [], 373 semble_collection_uri: '', 374 destination_config_raw: '', 375}; 376 377function ruleToFormDefaults(rule) { 378 if (!rule) return { ...EMPTY_RULE_DEFAULTS }; 379 const d = { 380 guild_id: rule.guild_id ?? '', 381 channel_id_display: rule.channel_id ?? '', 382 author_mode: rule.author_id === 'self' ? 'self' : rule.author_id == null ? 'any' : 'id', 383 author_id_display: (rule.author_id && rule.author_id !== 'self') ? rule.author_id : '', 384 mode: rule.mode ?? 'urls', 385 match_kind: rule.match_kind ?? '', 386 match_value: rule.match_value ?? '', 387 destination_kind: rule.destination_kind ?? '', 388 bookmark_lexicons: [], 389 semble_collection_uri: '', 390 destination_config_raw: '', 391 }; 392 if (rule.destination_kind === 'bookmark' && rule.destination_config) { 393 try { 394 const cfg = JSON.parse(rule.destination_config); 395 if (Array.isArray(cfg?.lexicons)) d.bookmark_lexicons = cfg.lexicons; 396 d.semble_collection_uri = cfg?.lexiconConfig?.['semble-card']?.collectionUri ?? ''; 397 } catch {} 398 } else if (rule.destination_kind === 'atproto-record' && rule.destination_config) { 399 d.destination_config_raw = rule.destination_config; 400 } 401 return d; 402} 403 404function renderRuleForm({ action, submitLabel, defaults, guilds, cancelHref }) { 405 const d = defaults; 406 const sel = (a, b) => a === b ? 'selected' : ''; 407 const chk = (a, b) => a === b ? 'checked' : ''; 408 const chkSet = (arr, v) => arr.includes(v) ? 'checked' : ''; 409 const effectiveDest = d.destination_kind || CATALOG[0].id; 410 411 const guildOptions = guilds.length === 0 412 ? `<option value="">(none — invite the bot to a server you're in first)</option>` 413 : guilds.map(g => 414 `<option value="${esc(g.id)}" ${sel(d.guild_id, g.id)}>${esc(g.name)} (${esc(g.id)})</option>` 415 ).join(''); 416 417 const destOptions = CATALOG.map(meta => 418 `<option value="${esc(meta.id)}" ${sel(effectiveDest, meta.id)}>${esc(meta.name)}</option>` 419 ).join(''); 420 421 return ` 422 <form action="${esc(action)}" method="post" class="add-rule"> 423 <p> 424 <label>Server<br> 425 <select name="guild_id">${guildOptions}</select> 426 </label> 427 </p> 428 <p> 429 <label>Channel (optional)<br> 430 <input name="channel_id" value="${esc(d.channel_id_display)}" placeholder="#channel-name or numeric ID; blank = any"> 431 </label> 432 <small>Type a channel name like <code>#general</code> or <code>general</code>; we'll resolve it inside the selected server. Numeric IDs are kept as-is.</small> 433 </p> 434 <fieldset> 435 <legend>Author</legend> 436 <label><input type="radio" name="author_mode" value="self" ${chk(d.author_mode, 'self')}> Just me</label><br> 437 <label><input type="radio" name="author_mode" value="any" ${chk(d.author_mode, 'any')}> Anyone</label><br> 438 <label><input type="radio" name="author_mode" value="id" ${chk(d.author_mode, 'id')}> Specific user:</label> 439 <input name="author_id" value="${esc(d.author_id_display)}" placeholder="@username or numeric ID"> 440 <small>Username works without Discord developer mode — right-click a user → Copy Username. We resolve it to an ID inside the selected server.</small> 441 </fieldset> 442 <fieldset> 443 <legend>Extract</legend> 444 <label><input type="radio" name="mode" value="urls" ${chk(d.mode, 'urls')}> URLs only</label> 445 <small>One record per URL found. Messages with no URLs are skipped.</small><br> 446 <label><input type="radio" name="mode" value="content" ${chk(d.mode, 'content')}> Full message content</label> 447 <small>One record per message, with the entire message text. Empty messages are skipped.</small> 448 </fieldset> 449 <fieldset> 450 <legend>Message must match (optional)</legend> 451 <label><input type="radio" name="match_kind" value="" ${chk(d.match_kind, '')}> No content filter</label><br> 452 <label><input type="radio" name="match_kind" value="substring" ${chk(d.match_kind, 'substring')}> Contains text (case-insensitive)</label><br> 453 <label><input type="radio" name="match_kind" value="regex" ${chk(d.match_kind, 'regex')}> Matches regex (case-insensitive; JS syntax)</label><br> 454 <input name="match_value" value="${esc(d.match_value)}" placeholder="leave blank for no filter"> 455 <small>Applied to the whole message before extraction. Useful for narrowing to messages containing a keyword like "announcement" or matching a pattern.</small> 456 </fieldset> 457 <p> 458 <label>Destination<br> 459 <select name="destination_kind" id="destination_kind"> 460 ${destOptions} 461 </select> 462 </label> 463 <small>${CATALOG.map(meta => `<strong>${esc(meta.name)}</strong>: ${esc(meta.description)}`).join('<br>')}</small> 464 </p> 465 <fieldset data-dest-config="bookmark"> 466 <legend>Bookmark: which lexicons to write</legend> 467 ${(metaFor('bookmark')?.lexicons ?? []).map(l => ` 468 <label><input type="checkbox" name="bookmark_lexicon" value="${esc(l.id)}" data-lex="${esc(l.id)}" ${chkSet(d.bookmark_lexicons, l.id)}> 469 <strong>${esc(l.name)}</strong> <code>${esc(l.nsid)}</code></label> 470 <small>${esc(l.description)}</small><br> 471 `).join('')} 472 <small>Pick at least one. Each selected lexicon gets its own record per URL — you can write only the community standard, only an app-specific collection, or fan out to several.</small> 473 <div data-lex-config="semble-card" style="margin-top:0.75rem;display:none"> 474 <label>Semble collection (optional)<br> 475 <select name="semble_collection_uri" data-semble-select data-initial="${esc(d.semble_collection_uri)}"> 476 <option value="">Library only (don't add to a collection)</option> 477 ${d.semble_collection_uri ? `<option value="${esc(d.semble_collection_uri)}" selected>${esc(d.semble_collection_uri.split('/').pop())} (loading…)</option>` : ''} 478 </select> 479 </label> 480 <small data-semble-status>Loading your Semble collections…</small> 481 </div> 482 </fieldset> 483 <fieldset data-dest-config="atproto-record" style="display:none"> 484 <legend>Raw atproto record config</legend> 485 <label>Destination config (JSON)<br> 486 <textarea name="destination_config" rows="3" placeholder='{ "collection": "com.example.bookmark" }'>${esc(d.destination_config_raw)}</textarea> 487 </label> 488 <small>Provide an NSID under the "collection" key.</small> 489 </fieldset> 490 <script> 491 (function () { 492 const sel = document.getElementById('destination_kind'); 493 const blocks = document.querySelectorAll('[data-dest-config]'); 494 function sync() { 495 const v = sel.value; 496 blocks.forEach(b => { b.style.display = b.dataset.destConfig === v ? '' : 'none'; }); 497 } 498 sel.addEventListener('change', sync); 499 sync(); 500 501 const lexBoxes = document.querySelectorAll('input[name="bookmark_lexicon"]'); 502 const lexBlocks = document.querySelectorAll('[data-lex-config]'); 503 function syncLex() { 504 const checked = new Set(Array.from(lexBoxes).filter(b => b.checked).map(b => b.dataset.lex)); 505 lexBlocks.forEach(b => { b.style.display = checked.has(b.dataset.lexConfig) ? '' : 'none'; }); 506 if (checked.has('semble-card')) loadSembleCollections(); 507 } 508 lexBoxes.forEach(b => b.addEventListener('change', syncLex)); 509 syncLex(); 510 511 let sembleLoaded = false; 512 async function loadSembleCollections() { 513 if (sembleLoaded) return; 514 sembleLoaded = true; 515 const select = document.querySelector('[data-semble-select]'); 516 const status = document.querySelector('[data-semble-status]'); 517 const initial = select?.getAttribute('data-initial') || ''; 518 try { 519 const res = await fetch('/api/semble/collections'); 520 if (!res.ok) throw new Error('HTTP ' + res.status); 521 const { collections } = await res.json(); 522 for (const c of collections) { 523 let opt = select.querySelector('option[value="' + c.uri + '"]'); 524 if (opt) { 525 opt.textContent = c.name + (c.accessType ? ' (' + c.accessType.toLowerCase() + ')' : ''); 526 } else { 527 opt = document.createElement('option'); 528 opt.value = c.uri; 529 opt.textContent = c.name + (c.accessType ? ' (' + c.accessType.toLowerCase() + ')' : ''); 530 select.appendChild(opt); 531 } 532 } 533 if (initial) select.value = initial; 534 status.textContent = collections.length 535 ? 'Pick a collection, or leave as "Library only".' 536 : 'No Semble collections found in your repo. Create one in Semble first, then refresh.'; 537 } catch (err) { 538 status.textContent = "Couldn't load Semble collections: " + err.message; 539 sembleLoaded = false; 540 } 541 } 542 })(); 543 </script> 544 <p> 545 <button type="submit">${esc(submitLabel)}</button> 546 ${cancelHref ? `<a href="${esc(cancelHref)}" style="margin-left:0.75rem">Cancel</a>` : ''} 547 </p> 548 </form> 549 `; 550} 551 552function renderRulesSection({ user, job, session }) { 553 const rules = listRulesForJob.all(job.id); 554 const userGuildIds = session.discord.guildIds ?? []; 555 const guilds = botGuildsForUser(sharedGuildIdsFor(userGuildIds)); 556 const thisMonth = getCounter(job.id); 557 const recent = listCounters(job.id, 6); 558 const backfillEnabled = canUseBackfill(user); 559 560 const usageBlock = ` 561 <section class="usage"> 562 <strong>Writes this month:</strong> ${thisMonth} 563 ${recent.length > 1 ? `<small style="margin-left:1em">recent: ${ 564 recent.map(r => `${esc(r.period)}=${r.count}`).join(', ') 565 }</small>` : ''} 566 </section> 567 `; 568 569 const ruleRows = rules.length === 0 ? ` 570 <p><em>No rules yet — nothing is being captured. Add one below to start.</em></p> 571 ` : ` 572 <table class="rules"> 573 <thead><tr> 574 <th>Server</th><th>Channel</th><th>Author</th><th>Match</th><th>Extract</th><th>Destination</th><th>State</th><th></th> 575 </tr></thead> 576 <tbody> 577 ${rules.map(r => ruleRow(r, guilds, backfillEnabled)).join('')} 578 </tbody> 579 </table> 580 `; 581 582 return ` 583 <h2>Rules for <code>${esc(job.did_handle ?? job.did)}</code></h2> 584 <p style="color:#444"> 585 Each rule tells the bot to act on messages matching its filters and route the result to a destination. 586 <strong>The bot does nothing in a server until you have at least one rule pointing at it.</strong> 587 </p> 588 589 ${usageBlock} 590 591 ${ruleRows} 592 593 <h3>Add rule</h3> 594 ${renderRuleForm({ 595 action: '/rules', 596 submitLabel: 'Add rule', 597 defaults: { ...EMPTY_RULE_DEFAULTS }, 598 guilds, 599 })} 600 601 ${backfillEnabled ? ` 602 <section class="backfill-preview" style="margin-top:1.5rem;border:1px dashed #bbb;border-radius:6px;padding:0.75rem 1rem;opacity:0.6"> 603 <h3 style="margin-top:0">Backfill <small style="font-weight:normal;color:#666">— available once the rule exists</small></h3> 604 <p style="font-size:0.85rem;color:#555;margin-top:0"> 605 After you add the rule, you can replay a channel's history through it from here. We keep it disabled until then so you can add the rule, confirm its filters and destination look right, and only then run the backfill. A <strong>Backfill</strong> button appears on the rule's row once it's saved. 606 </p> 607 <p> 608 <label>How far back<br> 609 <select disabled> 610 <option value="1">Last 24 hours</option> 611 <option value="7" selected>Last 7 days</option> 612 <option value="30">Last 30 days</option> 613 <option value="90">Last 90 days</option> 614 <option value="365">Last year</option> 615 <option value="3650">Everything I can reach</option> 616 </select> 617 </label> 618 </p> 619 <p><button type="button" disabled>Start backfill</button></p> 620 </section> 621 ` : ''} 622 623 <p style="margin-top:2rem;color:#666;font-size:0.85rem"> 624 The "Server" dropdown shows servers where the bot is present and you are a member. If a server is missing, 625 invite the bot and re-link your Discord account here. 626 </p> 627 628 <h3>Examples</h3> 629 <table class="rules examples"> 630 <thead><tr><th>Goal</th><th>Server</th><th>Channel</th><th>Author</th><th>Extract</th></tr></thead> 631 <tbody> 632 <tr> 633 <td>Save URLs I post in #links to my PDS</td> 634 <td><em>my server</em></td><td>#links</td><td>just me</td><td>URLs only</td> 635 </tr> 636 <tr> 637 <td>Mirror every message in #announcements</td> 638 <td><em>my server</em></td><td>#announcements</td><td>anyone</td><td>full content</td> 639 </tr> 640 <tr> 641 <td>Capture Boris's posts anywhere in a server</td> 642 <td><em>my server</em></td><td><em>(any)</em></td><td>Boris's Discord ID</td><td>full content</td> 643 </tr> 644 <tr> 645 <td>Bookmark every URL anyone shares in a server</td> 646 <td><em>my server</em></td><td><em>(any)</em></td><td>anyone</td><td>URLs only</td> 647 </tr> 648 <tr> 649 <td>Log just my messages from one channel</td> 650 <td><em>my server</em></td><td>#my-channel</td><td>just me</td><td>full content</td> 651 </tr> 652 </tbody> 653 </table> 654 `; 655} 656 657app.post('/cancel', (c) => { 658 clearSession(c); 659 return c.redirect('/'); 660}); 661 662// Clear the OAuth session for the currently-active DID without deleting the 663// job. Rules stay configured; they'll go dormant until the user re-auths via 664// the "Re-auth" button on the switcher. 665app.post('/atmo/sign-out-current', async (c) => { 666 const ctx = requireActiveJob(c); 667 if (!ctx || !ctx.job) return c.redirect('/'); 668 try { await oauth?.revoke?.(ctx.job.did); } catch {} 669 try { await oauthStores.session.del(ctx.job.did); } catch {} 670 setSession(c, { discord: ctx.session.discord }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 671 return c.redirect('/'); 672}); 673 674// Switch the cookie's "active job" to a different one the same account owns. 675// Doesn't touch OAuth sessions; if the target's session has lapsed the UI 676// will surface the re-auth prompt on the next render. 677app.post('/jobs/:id/switch', (c) => { 678 const s = getSession(c, SESSION_SECRET); 679 if (!s?.discord?.userId) return c.redirect('/'); 680 const account = getUserByDiscord.get(s.discord.userId); 681 if (!account) return c.redirect('/'); 682 const id = Number(c.req.param('id')); 683 if (!Number.isInteger(id)) return errorPage(c, 'Bad job id.'); 684 const job = getJobById.get(id); 685 if (!job || job.user_id !== account.id) return errorPage(c, 'Job not found.', 404); 686 setSession(c, { discord: s.discord, did: job.did }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 687 return c.redirect('/'); 688}); 689 690// Destructive: drops the job and its rules + counters (FK cascade), revokes 691// the OAuth session for its DID, and clears session.did if we just deleted 692// the active one. 693app.post('/jobs/:id/delete', async (c) => { 694 const s = getSession(c, SESSION_SECRET); 695 if (!s?.discord?.userId) return c.redirect('/'); 696 const account = getUserByDiscord.get(s.discord.userId); 697 if (!account) return c.redirect('/'); 698 const id = Number(c.req.param('id')); 699 if (!Number.isInteger(id)) return errorPage(c, 'Bad job id.'); 700 const job = getJobById.get(id); 701 if (!job || job.user_id !== account.id) return errorPage(c, 'Job not found.', 404); 702 try { await oauth?.revoke?.(job.did); } catch {} 703 try { await oauthStores.session.del(job.did); } catch {} 704 deleteJob(job.id, account.id); 705 refreshActiveGuilds(); 706 const next = (s.did === job.did) 707 ? { discord: s.discord } 708 : { discord: s.discord, did: s.did }; 709 setSession(c, next, SESSION_SECRET, SESSION_MAX_AGE_LONG); 710 return c.redirect('/'); 711}); 712 713app.get('/api/semble/collections', async (c) => { 714 const ctx = requireActiveJob(c); 715 if (!ctx || !ctx.job) return c.json({ error: 'not linked' }, 401); 716 try { 717 const collections = await listSembleCollections(ctx.job.did); 718 return c.json({ collections }); 719 } catch (err) { 720 console.error('semble collections fetch failed', err); 721 return c.json({ error: err.message }, 502); 722 } 723}); 724 725app.get('/client-metadata.json', (c) => 726 oauth ? c.json(oauth.clientMetadata) : c.notFound() 727); 728app.get('/jwks.json', (c) => 729 oauth ? c.json(oauth.jwks) : c.notFound() 730); 731 732app.get('/discord/start', (c) => { 733 const discordState = randomBytes(16).toString('hex'); 734 setSession(c, { discordState }, SESSION_SECRET); 735 return c.redirect(discord.authUrl({ state: discordState, redirectUri: DISCORD_REDIRECT })); 736}); 737 738app.get('/discord/callback', async (c) => { 739 const s = getSession(c, SESSION_SECRET); 740 const code = c.req.query('code'); 741 const state = c.req.query('state'); 742 if (!code) return errorPage(c, 'Missing Discord authorization code.'); 743 if (!s?.discordState || state !== s.discordState) { 744 return errorPage(c, 'Discord state mismatch — possible CSRF.'); 745 } 746 747 let user, guildsRaw; 748 try { 749 const token = await discord.exchangeCode({ code, redirectUri: DISCORD_REDIRECT }); 750 user = await discord.getUser(token.access_token); 751 guildsRaw = await discord.getGuilds(token.access_token); 752 } catch (err) { 753 console.error('discord oauth failed', err); 754 return errorPage(c, 'Could not verify Discord account.'); 755 } 756 757 // Persist the user's full guild ID list; we intersect with the bot's live 758 // cache on each page render so newly-shared servers (bot was added later) 759 // surface without requiring another OAuth round-trip. Capped to avoid 760 // overflowing the cookie size limit on power users. 761 const guildIds = guildsRaw.map(g => g.id).slice(0, 250); 762 763 upsertUserByDiscord({ discordId: user.id }); 764 765 setSession(c, { 766 discord: { 767 userId: user.id, 768 username: user.username, 769 globalName: user.global_name ?? null, 770 avatar: user.avatar ?? null, 771 guildIds, 772 }, 773 }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 774 return c.redirect('/'); 775}); 776 777const normalizeHandle = (raw) => { 778 let h = raw.trim(); 779 if (h.startsWith('@')) h = h.slice(1); 780 if (h.includes('/')) { 781 try { 782 const u = new URL(h.includes('://') ? h : `https://${h}`); 783 if (u.pathname.startsWith('/profile/')) { 784 h = decodeURIComponent(u.pathname.slice('/profile/'.length).split('/')[0]); 785 if (h.startsWith('@')) h = h.slice(1); 786 } 787 } catch {} 788 } 789 if (h.endsWith('.')) h = h.slice(0, -1); 790 return h; 791}; 792 793app.get('/login', async (c) => { 794 if (!oauth) return errorPage(c, 'atproto OAuth is disabled in loopback dev mode. Set PUBLIC_URL to a tunneled HTTPS URL or deploy.', 503); 795 const s = getSession(c, SESSION_SECRET); 796 if (!s?.discord) return c.redirect('/'); 797 const raw = c.req.query('handle'); 798 if (!raw) return errorPage(c, 'Missing handle.'); 799 const handle = normalizeHandle(raw); 800 const state = randomBytes(16).toString('hex'); 801 try { 802 const url = await oauth.authorize(handle, { state }); 803 return c.redirect(url.toString()); 804 } catch (err) { 805 console.error('oauth authorize failed', err); 806 return errorPage(c, `Could not resolve &ldquo;${esc(handle)}&rdquo;. Enter a handle like <code>you.bsky.social</code> or a DID.`); 807 } 808}); 809 810app.get('/oauth/callback', async (c) => { 811 if (!oauth) return errorPage(c, 'atproto OAuth is disabled in loopback dev mode.', 503); 812 const s = getSession(c, SESSION_SECRET); 813 if (!s?.discord) { 814 clearSession(c); 815 return errorPage(c, 'Missing flow state. Start over.'); 816 } 817 818 let session; 819 try { 820 const params = new URLSearchParams(c.req.url.split('?')[1]); 821 ({ session } = await oauth.callback(params)); 822 } catch (err) { 823 console.error('atproto oauth callback failed', err); 824 clearSession(c); 825 return errorPage(c, 'Sign-in with Atmosphere account failed. Try again.'); 826 } 827 828 const account = upsertUserByDiscord({ discordId: s.discord.userId }); 829 const job = upsertJob({ userId: account.id, did: session.did }); 830 const profile = await resolveDidProfile(session.did).catch(() => null); 831 if (profile) setJobProfile(job.id, profile.handle, profile.avatar); 832 833 setSession(c, { 834 discord: s.discord, 835 did: session.did, 836 }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 837 return c.redirect('/'); 838}); 839 840// ───── Rules mutations (rendered inline on /) ───────────────────────────── 841 842app.get('/rules', (c) => c.redirect('/')); 843 844// Form parsing + validation shared by /rules (insert) and /rules/:id/edit 845// (update). Returns either { ok: true, fields } where `fields` is the row 846// payload for insertRule/updateRuleForUser, or { ok: false, status, error } 847// describing why we rejected the submission. 848async function parseRuleForm(form, session) { 849 const guildIdRaw = (form.get('guild_id') ?? '').toString().trim(); 850 const channelIdRaw = (form.get('channel_id') ?? '').toString().trim(); 851 const authorMode = (form.get('author_mode') ?? 'self').toString(); 852 const authorIdRaw = (form.get('author_id') ?? '').toString().trim(); 853 const mode = (form.get('mode') ?? 'urls').toString(); 854 const matchKindRaw = (form.get('match_kind') ?? '').toString(); 855 const matchValueRaw = (form.get('match_value') ?? '').toString(); 856 const destKind = (form.get('destination_kind') ?? 'stdout').toString(); 857 const destConfigRaw = (form.get('destination_config') ?? '').toString().trim(); 858 859 if (!['urls', 'content'].includes(mode)) { 860 return { ok: false, status: 400, error: `Invalid extract mode: ${esc(mode)}.` }; 861 } 862 863 let match_kind = null, match_value = null; 864 if (matchKindRaw) { 865 if (!matchValueRaw.trim()) { 866 return { ok: false, status: 400, error: 'Match pattern is required when a content filter is selected.' }; 867 } 868 const v = validateMatchPattern(matchKindRaw, matchValueRaw); 869 if (!v.ok) return { ok: false, status: 400, error: esc(v.reason) }; 870 match_kind = matchKindRaw; 871 match_value = matchValueRaw; 872 } 873 874 let author_id; 875 if (authorMode === 'self') author_id = 'self'; 876 else if (authorMode === 'any') author_id = null; 877 else if (authorMode === 'id') { 878 const trimmed = authorIdRaw.replace(/^@/, '').trim(); 879 if (!trimmed) return { ok: false, status: 400, error: 'Specify a user (numeric ID or @username).' }; 880 if (/^\d+$/.test(trimmed)) { 881 author_id = trimmed; 882 } else { 883 const guildForLookup = guildIdRaw; 884 if (!guildForLookup) { 885 return { ok: false, status: 400, error: 'Pick a server before entering a username (we resolve usernames within the selected server).' }; 886 } 887 let matches; 888 try { 889 matches = await searchGuildMembers(guildForLookup, trimmed); 890 } catch (err) { 891 console.error('member search failed', err); 892 return { ok: false, status: 502, error: `Couldn't reach Discord to resolve username "${esc(trimmed)}".` }; 893 } 894 if (!Array.isArray(matches) || matches.length === 0) { 895 return { ok: false, status: 400, error: `No member matching "${esc(trimmed)}" in that server. Try the exact username (Discord username, not display name).` }; 896 } 897 const exact = matches.find(m => m?.user?.username?.toLowerCase() === trimmed.toLowerCase()); 898 author_id = (exact ?? matches[0]).user.id; 899 } 900 } else { 901 return { ok: false, status: 400, error: 'Invalid author mode.' }; 902 } 903 904 const guild_id = guildIdRaw || null; 905 if (!guild_id) { 906 return { ok: false, status: 400, error: 'Every rule must specify a server.' }; 907 } 908 const sharedSet = new Set(sharedGuildIdsFor(session.discord.guildIds)); 909 if (!sharedSet.has(guild_id)) { 910 return { ok: false, status: 403, error: 'You must be a member of that server and the bot must be in it. Re-link Discord if your guild list is stale.' }; 911 } 912 913 let channel_id = null; 914 if (channelIdRaw) { 915 if (/^\d+$/.test(channelIdRaw)) { 916 channel_id = channelIdRaw; 917 } else { 918 const name = channelIdRaw.replace(/^#/, '').trim().toLowerCase(); 919 if (name) { 920 const guild = botClient.isReady?.() ? botClient.guilds.cache.get(guild_id) : null; 921 if (!guild) { 922 return { ok: false, status: 502, error: 'Could not access that server (the bot may not be in it yet).' }; 923 } 924 const channel = guild.channels.cache.find(ch => 925 ch?.isTextBased?.() && ch.name?.toLowerCase() === name 926 ); 927 if (!channel) { 928 return { ok: false, status: 400, error: `No text channel named "#${esc(name)}" in that server.` }; 929 } 930 channel_id = channel.id; 931 } 932 } 933 } 934 935 if (!KNOWN_DESTINATIONS.includes(destKind)) { 936 return { ok: false, status: 400, error: `Unknown destination kind: ${esc(destKind)}` }; 937 } 938 let destination_config = null; 939 if (destKind === 'bookmark') { 940 if (mode !== 'urls') { 941 return { ok: false, status: 400, error: 'The Bookmark destination only supports URL extraction mode (it writes one bookmark per URL).' }; 942 } 943 const knownLexicons = new Set((metaFor('bookmark')?.lexicons ?? []).map(l => l.id)); 944 const picked = form.getAll('bookmark_lexicon').map(v => v.toString()).filter(id => knownLexicons.has(id)); 945 if (picked.length === 0) { 946 return { ok: false, status: 400, error: 'Pick at least one bookmark lexicon to write to.' }; 947 } 948 const lexiconConfig = {}; 949 if (picked.includes('semble-card')) { 950 const collectionUri = (form.get('semble_collection_uri') ?? '').toString().trim(); 951 if (collectionUri) { 952 if (!/^at:\/\/[^/]+\/network\.cosmik\.collection\/[^/]+$/.test(collectionUri)) { 953 return { ok: false, status: 400, error: 'Semble collection URI looks malformed.' }; 954 } 955 lexiconConfig['semble-card'] = { collectionUri }; 956 } 957 } 958 destination_config = JSON.stringify({ lexicons: picked, lexiconConfig }); 959 } else if (destKind === 'atproto-record') { 960 if (!destConfigRaw) { 961 return { ok: false, status: 400, error: 'atproto-record requires a config (e.g. { "collection": "com.example.bookmark" }).' }; 962 } 963 let parsed; 964 try { parsed = JSON.parse(destConfigRaw); } catch { return { ok: false, status: 400, error: 'Destination config must be valid JSON.' }; } 965 if (!parsed?.collection || typeof parsed.collection !== 'string') { 966 return { ok: false, status: 400, error: 'atproto-record requires a "collection" string (an NSID) in the config.' }; 967 } 968 if (!/^[a-z][a-z0-9]*(\.[a-z0-9-]+)+$/i.test(parsed.collection)) { 969 return { ok: false, status: 400, error: 'collection must look like an NSID (e.g. "com.example.bookmark").' }; 970 } 971 destination_config = destConfigRaw; 972 } 973 974 return { 975 ok: true, 976 fields: { 977 guild_id, 978 channel_id, 979 author_id, 980 mode, 981 match_kind, 982 match_value, 983 destination_kind: destKind, 984 destination_config, 985 }, 986 }; 987} 988 989app.post('/rules', async (c) => { 990 const ctx = requireActiveJob(c); 991 if (!ctx || !ctx.job) return c.redirect('/'); 992 const { job, session } = ctx; 993 994 const form = await c.req.formData(); 995 const parsed = await parseRuleForm(form, session); 996 if (!parsed.ok) return errorPage(c, parsed.error, parsed.status); 997 998 insertRule({ job_id: job.id, ...parsed.fields, enabled: 1 }); 999 refreshActiveGuilds(); 1000 return c.redirect('/'); 1001}); 1002 1003app.post('/rules/:id/delete', (c) => { 1004 const ctx = requireActiveJob(c); 1005 if (!ctx || !ctx.job) return c.redirect('/'); 1006 const id = Number(c.req.param('id')); 1007 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1008 deleteRuleForJob.run(id, ctx.job.id); 1009 refreshActiveGuilds(); 1010 return c.redirect('/'); 1011}); 1012 1013app.post('/rules/:id/toggle', (c) => { 1014 const ctx = requireActiveJob(c); 1015 if (!ctx || !ctx.job) return c.redirect('/'); 1016 const id = Number(c.req.param('id')); 1017 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1018 toggleRuleForJob.run(id, ctx.job.id); 1019 refreshActiveGuilds(); 1020 return c.redirect('/'); 1021}); 1022 1023app.get('/rules/:id/edit', (c) => { 1024 const ctx = requireActiveJob(c); 1025 if (!ctx || !ctx.job) return c.redirect('/'); 1026 const id = Number(c.req.param('id')); 1027 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1028 const rule = getRuleForJob.get(id, ctx.job.id); 1029 if (!rule) return errorPage(c, 'Rule not found.', 404); 1030 const userGuildIds = ctx.session.discord.guildIds ?? []; 1031 const guilds = botGuildsForUser(sharedGuildIdsFor(userGuildIds)); 1032 return c.html(layout(`Edit rule #${rule.id}`, ` 1033 <p><a href="/">&larr; Back to rules</a></p> 1034 <h1>Edit rule #${rule.id}</h1> 1035 ${renderRuleForm({ 1036 action: `/rules/${rule.id}/edit`, 1037 submitLabel: 'Save changes', 1038 defaults: ruleToFormDefaults(rule), 1039 guilds, 1040 cancelHref: '/', 1041 })} 1042 ${canUseBackfill(ctx.user) ? ` 1043 <section style="margin-top:1.5rem;border-top:1px solid #ddd;padding-top:1rem"> 1044 <h3 style="margin-top:0">Backfill</h3> 1045 <p style="font-size:0.85rem;color:#555;margin-top:0"> 1046 Replay this rule over a channel's history. Save any changes above first so the backfill uses the rule's current filters and destination. 1047 </p> 1048 <p><a href="/rules/${rule.id}/backfill"><button type="button">Backfill this rule</button></a></p> 1049 </section> 1050 ` : ''} 1051 `)); 1052}); 1053 1054app.post('/rules/:id/edit', async (c) => { 1055 const ctx = requireActiveJob(c); 1056 if (!ctx || !ctx.job) return c.redirect('/'); 1057 const id = Number(c.req.param('id')); 1058 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1059 const existing = getRuleForJob.get(id, ctx.job.id); 1060 if (!existing) return errorPage(c, 'Rule not found.', 404); 1061 1062 const form = await c.req.formData(); 1063 const parsed = await parseRuleForm(form, ctx.session); 1064 if (!parsed.ok) return errorPage(c, parsed.error, parsed.status); 1065 1066 const ok = updateRuleForJob({ id: existing.id, job_id: ctx.job.id, ...parsed.fields }); 1067 if (!ok) return errorPage(c, 'Update failed — rule may have been deleted in another tab.', 409); 1068 refreshActiveGuilds(); 1069 return c.redirect('/'); 1070}); 1071 1072// ───── Backfill ──────────────────────────────────────────────────────────── 1073// Backfill runs each historical Discord message through the same pipeline as 1074// live MessageCreate. PDS records use deterministic rkeys (see destinations/ 1075// bookmark.js) so a replay overwrites instead of duplicating. 1076// 1077// Gated to a single allowlisted Discord account via env until we've shaken 1078// out the worst-case behaviors (huge channels, rate limits, billing-meter 1079// surprises). Unset → backfill is disabled for everyone. 1080const BACKFILL_ALLOWED_DISCORD_ID = process.env.BACKFILL_ALLOWED_DISCORD_ID ?? null; 1081function canUseBackfill(user) { 1082 return !!BACKFILL_ALLOWED_DISCORD_ID && user?.discord_id === BACKFILL_ALLOWED_DISCORD_ID; 1083} 1084 1085function listTextChannelsForGuild(guildId) { 1086 if (!botClient.isReady?.()) return []; 1087 const guild = botClient.guilds.cache.get(guildId); 1088 if (!guild) return []; 1089 return [...guild.channels.cache.values()] 1090 .filter(ch => ch?.isTextBased?.() && typeof ch.name === 'string') 1091 .map(ch => ({ id: ch.id, name: ch.name })) 1092 .sort((a, b) => a.name.localeCompare(b.name)); 1093} 1094 1095// Discord snowflakes embed their creation time in the high 42 bits. A channel 1096// can't contain messages older than itself, so this is the true floor of a 1097// backfill's content window — derivable with no API call. 1098const DISCORD_EPOCH_MS = 1420070400000; 1099function channelCreatedSec(channelId) { 1100 try { 1101 const ms = Number(BigInt(channelId) >> 22n) + DISCORD_EPOCH_MS; 1102 return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; 1103 } catch { 1104 return null; 1105 } 1106} 1107 1108function jobPercent(job) { 1109 if (!job) return 0; 1110 if (job.status === 'done') return 100; 1111 if (!job.cursor_ts) return 0; 1112 // Measure progress against the span that can actually hold messages, not the 1113 // raw since_ts. With an "Everything" window (since_ts ~10y ago) but a channel 1114 // created last year, the cursor blows through all real content while covering 1115 // only a few percent of the calendar window — pinning the bar near 0 for the 1116 // whole run. Clamping the floor to the channel's creation time fixes that. 1117 const created = channelCreatedSec(job.channel_id); 1118 const since = created != null ? Math.max(job.since_ts, created) : job.since_ts; 1119 const span = job.until_ts - since; 1120 if (span <= 0) return 100; 1121 const walked = job.until_ts - job.cursor_ts; 1122 return Math.max(0, Math.min(100, Math.round((walked / span) * 100))); 1123} 1124 1125// Latest backfill job per channel for a rule. listBackfillJobsForRule is 1126// ordered newest-first, so the first row seen for a channel_id is its current 1127// state — older re-runs of the same channel are ignored. 1128function latestBackfillJobByChannel(ruleId) { 1129 const map = new Map(); 1130 for (const j of listBackfillJobsForRule.all(ruleId)) { 1131 if (!map.has(j.channel_id)) map.set(j.channel_id, j); 1132 } 1133 return map; 1134} 1135 1136// Roll a set of per-channel jobs up into the numbers the batch view shows. 1137function summarizeBackfill(jobs) { 1138 const s = { 1139 total: jobs.length, done: 0, running: 0, pending: 0, error: 0, cancelled: 0, 1140 scanned: 0, records_written: 0, error_count: 0, active: false, 1141 }; 1142 for (const j of jobs) { 1143 if (j.status in s) s[j.status] += 1; 1144 s.scanned += j.scanned ?? 0; 1145 s.records_written += j.records_written ?? 0; 1146 s.error_count += j.error_count ?? 0; 1147 if (j.status === 'pending' || j.status === 'running') s.active = true; 1148 } 1149 return s; 1150} 1151 1152app.get('/rules/:id/backfill', (c) => { 1153 const ctx = requireActiveJob(c); 1154 if (!ctx || !ctx.job) return c.redirect('/'); 1155 if (!canUseBackfill(ctx.user)) { 1156 return errorPage(c, 'Backfill is currently restricted to a single allowlisted account while the feature is being shaken out.', 403); 1157 } 1158 const id = Number(c.req.param('id')); 1159 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1160 const rule = getRuleForJob.get(id, ctx.job.id); 1161 if (!rule) return errorPage(c, 'Rule not found.', 404); 1162 if (!rule.guild_id) { 1163 return errorPage(c, 'Backfill needs a rule scoped to a specific server. Edit the rule or create a new one with a server selected.'); 1164 } 1165 1166 const channels = listTextChannelsForGuild(rule.guild_id); 1167 const guild = botClient.isReady?.() ? botClient.guilds.cache.get(rule.guild_id) : null; 1168 const guildLabel = guild?.name ?? rule.guild_id; 1169 1170 const header = ` 1171 <p><a href="/">&larr; Back to rules</a></p> 1172 <h1>Backfill rule #${rule.id}</h1> 1173 <p style="color:var(--muted-fg)"> 1174 Server: <strong>${esc(guildLabel)}</strong> &middot; 1175 Extract: <strong>${esc(rule.mode)}</strong> &middot; 1176 Destination: <strong>${esc(rule.destination_kind)}</strong> 1177 </p>`; 1178 const daysSelect = (disabled) => ` 1179 <label>How far back<br> 1180 <select name="days"${disabled ? ' disabled' : ''}> 1181 <option value="1">Last 24 hours</option> 1182 <option value="7" selected>Last 7 days</option> 1183 <option value="30">Last 30 days</option> 1184 <option value="90">Last 90 days</option> 1185 <option value="365">Last year</option> 1186 <option value="3650">Everything I can reach</option> 1187 </select> 1188 </label>`; 1189 const replaySafetyNote = `<p><small>Replays are safe: bookmark and atproto-record destinations use deterministic record keys, so re-running a backfill over the same range overwrites in place rather than creating duplicates.</small></p>`; 1190 1191 // ── Wildcard (server-wide) rule: channel-centric batch view ────────────── 1192 // The rule matches every channel, so backfill can fan out one job per 1193 // channel. The worker drains them FIFO; write-pacing is process-global so 1194 // the queue stays under the PDS rate limit across jobs. 1195 if (!rule.channel_id) { 1196 const byChannel = latestBackfillJobByChannel(rule.id); 1197 const jobs = [...byChannel.values()]; 1198 const summary = summarizeBackfill(jobs); 1199 1200 // One row per visible channel, plus any channel that has a job but is no 1201 // longer visible to the bot (left server / lost read access). 1202 const rowChannels = [ 1203 ...channels.map(ch => ({ id: ch.id, name: ch.name })), 1204 ...[...byChannel.keys()] 1205 .filter(chId => !channels.some(ch => ch.id === chId)) 1206 .map(chId => ({ id: chId, name: null })), 1207 ]; 1208 const channelRows = rowChannels.map(ch => { 1209 const j = byChannel.get(ch.id); 1210 const label = ch.name ? `#${esc(ch.name)}` : `${esc(ch.id)} <small>(not visible)</small>`; 1211 const statusCell = j 1212 ? `${esc(j.status)}${j.error ? ` — <code>${esc(j.error)}</code>` : ''}` 1213 : '<em>never</em>'; 1214 const pct = j ? jobPercent(j) : 0; 1215 return `<tr> 1216 <td>${label}</td> 1217 <td>${statusCell}</td> 1218 <td><progress max="100" value="${pct}" style="width:80px"></progress> ${pct}%</td> 1219 <td>${j?.records_written ?? 0}</td> 1220 <td>${j?.error_count ?? 0}</td> 1221 </tr>`; 1222 }).join(''); 1223 1224 const overallPct = summary.total ? Math.round((summary.done / summary.total) * 100) : 0; 1225 const noChannels = channels.length === 0; 1226 return c.html(layout('Backfill rule', ` 1227 ${header} 1228 <section class="usage" id="backfill-status" data-rule-id="${rule.id}" data-batch="1"> 1229 <p> 1230 <strong>Channels:</strong> 1231 <span id="bf-done">${summary.done}</span>/<span id="bf-total">${summary.total}</span> complete 1232 &middot; running: <strong><span id="bf-running">${summary.running}</span></strong> 1233 &middot; queued: <strong><span id="bf-pending">${summary.pending}</span></strong> 1234 &middot; failed: <strong><span id="bf-failed">${summary.error}</span></strong> 1235 </p> 1236 <p> 1237 <progress max="100" value="${overallPct}" style="width:100%"></progress> 1238 <small> 1239 records written: <strong><span id="bf-written">${summary.records_written}</span></strong> 1240 &middot; failed writes: <strong><span id="bf-errors">${summary.error_count}</span></strong> 1241 </small> 1242 </p> 1243 ${summary.active ? ` 1244 <form action="/rules/${rule.id}/backfill/cancel" method="post"> 1245 <button type="submit">Cancel all</button> 1246 </form>` : ''} 1247 </section> 1248 ${summary.total === 0 ? '<p><em>No channels backfilled yet.</em></p>' : ` 1249 <table class="rules"> 1250 <thead><tr><th>Channel</th><th>Status</th><th>Progress</th><th>Written</th><th>Failed</th></tr></thead> 1251 <tbody>${channelRows}</tbody> 1252 </table>`} 1253 ${summary.active ? ` 1254 <script> 1255 (async function poll() { 1256 const el = document.getElementById('backfill-status'); 1257 if (!el) return; 1258 const rid = el.getAttribute('data-rule-id'); 1259 try { 1260 const res = await fetch('/api/rules/' + rid + '/backfill', { cache: 'no-store' }); 1261 if (res.ok) { 1262 const j = await res.json(); 1263 if (j && j.batch) { 1264 document.getElementById('bf-done').textContent = j.done; 1265 document.getElementById('bf-total').textContent = j.total; 1266 document.getElementById('bf-running').textContent = j.running; 1267 document.getElementById('bf-pending').textContent = j.pending; 1268 document.getElementById('bf-failed').textContent = j.error; 1269 document.getElementById('bf-written').textContent = j.records_written; 1270 document.getElementById('bf-errors').textContent = j.error_count; 1271 const prog = el.querySelector('progress'); 1272 if (prog) prog.value = j.total ? Math.round((j.done / j.total) * 100) : 0; 1273 if (j.active) setTimeout(poll, 3000); 1274 else setTimeout(() => location.reload(), 1000); 1275 } 1276 } else { setTimeout(poll, 5000); } 1277 } catch { setTimeout(poll, 5000); } 1278 })(); 1279 </script>` : ''} 1280 1281 <h2>Backfill every channel</h2> 1282 <p style="color:var(--muted-fg)">Queues a backfill for all ${channels.length} channel${channels.length === 1 ? '' : 's'} the bot can read in this server, processed one at a time. Channels already queued or running are skipped, so it's safe to re-run.</p> 1283 ${noChannels 1284 ? '<p class="warning">No channels visible to the bot in this server. Make sure the bot has read access.</p>' 1285 : `<form action="/rules/${rule.id}/backfill/all" method="post" class="add-rule"> 1286 <p>${daysSelect(false)}</p> 1287 <p><button type="submit">Backfill all ${channels.length} channels</button></p> 1288 </form>`} 1289 1290 <h2>Backfill a single channel</h2> 1291 ${noChannels ? '' : ` 1292 <form action="/rules/${rule.id}/backfill" method="post" class="add-rule"> 1293 <p><label>Channel<br> 1294 <select name="channel_id" required> 1295 ${channels.map(ch => `<option value="${esc(ch.id)}">#${esc(ch.name)}</option>`).join('')} 1296 </select> 1297 </label></p> 1298 <p>${daysSelect(false)}</p> 1299 <p><button type="submit">Backfill this channel</button></p> 1300 </form>`} 1301 ${replaySafetyNote} 1302 `)); 1303 } 1304 1305 // ── Channel-scoped rule: single-channel view ───────────────────────────── 1306 const ruleChannel = channels.find(ch => ch.id === rule.channel_id); 1307 const active = getActiveBackfillJobForRule.get(rule.id); 1308 const latest = active ?? getLatestBackfillJobForRule.get(rule.id); 1309 1310 const channelPicker = `<input type="hidden" name="channel_id" value="${esc(rule.channel_id)}"> 1311 <p><strong>Channel:</strong> #${esc(ruleChannel?.name ?? rule.channel_id)} <small>(from the rule)</small></p>`; 1312 1313 let latestSamples = null; 1314 if (latest?.error_samples) { 1315 try { latestSamples = JSON.parse(latest.error_samples); } catch { latestSamples = null; } 1316 } 1317 const samplesBlock = latestSamples && latestSamples.length > 0 1318 ? `<details style="margin-top:0.5rem"> 1319 <summary><strong>${latestSamples.length}</strong> distinct write-error sample${latestSamples.length === 1 ? '' : 's'}</summary> 1320 <pre style="white-space:pre-wrap;font-size:0.85em">${esc(JSON.stringify(latestSamples, null, 2))}</pre> 1321 </details>` 1322 : ''; 1323 1324 const statusBlock = latest ? ` 1325 <section class="usage" id="backfill-status" data-job-id="${latest.id}" data-rule-id="${rule.id}"> 1326 <p> 1327 <strong>Latest job:</strong> <span id="bf-status">${esc(latest.status)}</span> 1328 ${latest.error ? ` — <code>${esc(latest.error)}</code>` : ''} 1329 </p> 1330 <p> 1331 <progress max="100" value="${jobPercent(latest)}" style="width:100%"></progress> 1332 <small> 1333 <span id="bf-percent">${jobPercent(latest)}</span>% &middot; 1334 messages processed: <strong><span id="bf-scanned">${latest.scanned}</span></strong> &middot; 1335 records written: <strong><span id="bf-written">${latest.records_written ?? 0}</span></strong> &middot; 1336 failed writes: <strong><span id="bf-errors">${latest.error_count ?? 0}</span></strong> 1337 <span id="bf-cursor">${latest.cursor_ts ? `&middot; at ${esc(new Date(latest.cursor_ts * 1000).toISOString().slice(0,16).replace('T',' '))} UTC` : ''}</span> 1338 </small> 1339 </p> 1340 ${samplesBlock} 1341 ${active ? ` 1342 <form action="/rules/${rule.id}/backfill/cancel" method="post"> 1343 <button type="submit">Cancel backfill</button> 1344 </form> 1345 ` : ''} 1346 </section> 1347 ${active ? ` 1348 <script> 1349 (async function poll() { 1350 const el = document.getElementById('backfill-status'); 1351 if (!el) return; 1352 const id = el.getAttribute('data-rule-id'); 1353 try { 1354 const res = await fetch('/api/rules/' + id + '/backfill', { cache: 'no-store' }); 1355 if (res.ok) { 1356 const j = await res.json(); 1357 if (j) { 1358 document.getElementById('bf-percent').textContent = j.percent; 1359 document.getElementById('bf-scanned').textContent = j.scanned; 1360 document.getElementById('bf-written').textContent = j.records_written; 1361 document.getElementById('bf-errors').textContent = j.error_count; 1362 document.getElementById('bf-status').textContent = j.status; 1363 const prog = el.querySelector('progress'); 1364 if (prog) prog.value = j.percent; 1365 if (j.status === 'running' || j.status === 'pending') { 1366 setTimeout(poll, 2500); 1367 } else { 1368 setTimeout(() => location.reload(), 1000); 1369 } 1370 } 1371 } else { 1372 setTimeout(poll, 5000); 1373 } 1374 } catch { setTimeout(poll, 5000); } 1375 })(); 1376 </script> 1377 ` : ''} 1378 ` : '<p><em>No backfill has been run for this rule yet.</em></p>'; 1379 1380 const formDisabled = active ? 'disabled' : ''; 1381 return c.html(layout('Backfill rule', ` 1382 <p><a href="/">&larr; Back to rules</a></p> 1383 <h1>Backfill rule #${rule.id}</h1> 1384 <p style="color:var(--muted-fg)"> 1385 Server: <strong>${esc(guildLabel)}</strong> &middot; 1386 Extract: <strong>${esc(rule.mode)}</strong> &middot; 1387 Destination: <strong>${esc(rule.destination_kind)}</strong> 1388 </p> 1389 1390 ${statusBlock} 1391 1392 <h2>Start a new backfill</h2> 1393 ${active ? '<p><small>This form re-enables once the current backfill finishes (or you cancel it above).</small></p>' : ''} 1394 <form action="/rules/${rule.id}/backfill" method="post" class="add-rule"> 1395 ${channelPicker} 1396 <p> 1397 <label>How far back<br> 1398 <select name="days" ${formDisabled}> 1399 <option value="1">Last 24 hours</option> 1400 <option value="7" selected>Last 7 days</option> 1401 <option value="30">Last 30 days</option> 1402 <option value="90">Last 90 days</option> 1403 <option value="365">Last year</option> 1404 <option value="3650">Everything I can reach</option> 1405 </select> 1406 </label> 1407 <small>The bot reads pages of 100 messages with a 1s pause between pages, so a year of an active channel can take a while. Progress updates live above.</small> 1408 </p> 1409 <p><button type="submit" ${formDisabled}>Start backfill</button></p> 1410 </form> 1411 <p><small>Replays are safe: bookmark and atproto-record destinations use deterministic record keys, so re-running a backfill over the same range overwrites in place rather than creating duplicates.</small></p> 1412 `)); 1413}); 1414 1415app.post('/rules/:id/backfill', async (c) => { 1416 const ctx = requireActiveJob(c); 1417 if (!ctx || !ctx.job) return c.redirect('/'); 1418 if (!canUseBackfill(ctx.user)) { 1419 return errorPage(c, 'Backfill is currently restricted to a single allowlisted account.', 403); 1420 } 1421 const id = Number(c.req.param('id')); 1422 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1423 const rule = getRuleForJob.get(id, ctx.job.id); 1424 if (!rule) return errorPage(c, 'Rule not found.', 404); 1425 if (!rule.guild_id) return errorPage(c, 'Backfill requires a server-scoped rule.'); 1426 1427 const form = await c.req.formData(); 1428 const days = Math.max(1, Math.min(3650, Number(form.get('days') ?? 7))); 1429 const channelIdRaw = (form.get('channel_id') ?? '').toString().trim(); 1430 const channelId = rule.channel_id ?? channelIdRaw; 1431 if (!channelId) return errorPage(c, 'Pick a channel to backfill.'); 1432 1433 // The bot must actually be able to read the channel — fail fast rather than 1434 // letting the worker fail at messages.fetch time. 1435 const channels = listTextChannelsForGuild(rule.guild_id); 1436 if (!channels.some(ch => ch.id === channelId)) { 1437 return errorPage(c, 'Channel not visible to the bot. Make sure the bot has read access to it.'); 1438 } 1439 1440 const existing = getActiveBackfillJobForRuleChannel.get(rule.id, channelId); 1441 if (existing) { 1442 return errorPage(c, 'A backfill job is already pending or running for this channel.'); 1443 } 1444 1445 const now = Math.floor(Date.now() / 1000); 1446 const since = now - days * 24 * 60 * 60; 1447 insertBackfillJob({ 1448 rule_id: rule.id, 1449 channel_id: channelId, 1450 since_ts: since, 1451 until_ts: now, 1452 }); 1453 return c.redirect(`/rules/${rule.id}/backfill`); 1454}); 1455 1456// Fan-out: enqueue one backfill job per readable channel in the rule's server. 1457// Only meaningful for a wildcard (server-wide) rule — a channel-scoped rule 1458// already targets exactly one channel. The worker drains the queue FIFO. 1459app.post('/rules/:id/backfill/all', async (c) => { 1460 const ctx = requireActiveJob(c); 1461 if (!ctx || !ctx.job) return c.redirect('/'); 1462 if (!canUseBackfill(ctx.user)) { 1463 return errorPage(c, 'Backfill is currently restricted to a single allowlisted account.', 403); 1464 } 1465 const id = Number(c.req.param('id')); 1466 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1467 const rule = getRuleForJob.get(id, ctx.job.id); 1468 if (!rule) return errorPage(c, 'Rule not found.', 404); 1469 if (!rule.guild_id) return errorPage(c, 'Backfill requires a server-scoped rule.'); 1470 if (rule.channel_id) { 1471 return errorPage(c, 'This rule targets a single channel — use the single-channel backfill.'); 1472 } 1473 1474 const form = await c.req.formData(); 1475 const days = Math.max(1, Math.min(3650, Number(form.get('days') ?? 7))); 1476 const channels = listTextChannelsForGuild(rule.guild_id); 1477 if (channels.length === 0) { 1478 return errorPage(c, 'No channels visible to the bot in this server. Make sure the bot has read access.'); 1479 } 1480 1481 const now = Math.floor(Date.now() / 1000); 1482 const since = now - days * 24 * 60 * 60; 1483 for (const ch of channels) { 1484 // Skip channels that already have a pending/running job so re-running the 1485 // fan-out tops up newly-added channels without double-queuing. 1486 if (getActiveBackfillJobForRuleChannel.get(rule.id, ch.id)) continue; 1487 insertBackfillJob({ rule_id: rule.id, channel_id: ch.id, since_ts: since, until_ts: now }); 1488 } 1489 return c.redirect(`/rules/${rule.id}/backfill`); 1490}); 1491 1492app.get('/api/rules/:id/backfill', (c) => { 1493 const ctx = requireActiveJob(c); 1494 if (!ctx || !ctx.job) return c.json({ error: 'unauthorized' }, 401); 1495 if (!canUseBackfill(ctx.user)) return c.json({ error: 'forbidden' }, 403); 1496 const id = Number(c.req.param('id')); 1497 if (!Number.isInteger(id)) return c.json({ error: 'bad id' }, 400); 1498 const rule = getRuleForJob.get(id, ctx.job.id); 1499 if (!rule) return c.json({ error: 'not found' }, 404); 1500 1501 // Wildcard rule → aggregate the per-channel fan-out for the batch view. 1502 if (!rule.channel_id) { 1503 const jobs = [...latestBackfillJobByChannel(rule.id).values()]; 1504 const s = summarizeBackfill(jobs); 1505 return c.json({ batch: true, ...s }); 1506 } 1507 1508 const job = getActiveBackfillJobForRule.get(rule.id) ?? getLatestBackfillJobForRule.get(rule.id); 1509 if (!job) return c.json(null); 1510 let error_samples = null; 1511 if (job.error_samples) { 1512 try { error_samples = JSON.parse(job.error_samples); } catch { error_samples = null; } 1513 } 1514 return c.json({ 1515 id: job.id, 1516 status: job.status, 1517 scanned: job.scanned, 1518 dispatched: job.dispatched, 1519 records_written: job.records_written ?? 0, 1520 error_count: job.error_count ?? 0, 1521 error_samples, 1522 cursor_ts: job.cursor_ts, 1523 since_ts: job.since_ts, 1524 until_ts: job.until_ts, 1525 error: job.error, 1526 percent: jobPercent(job), 1527 }); 1528}); 1529 1530app.post('/rules/:id/backfill/cancel', (c) => { 1531 const ctx = requireActiveJob(c); 1532 if (!ctx || !ctx.job) return c.redirect('/'); 1533 if (!canUseBackfill(ctx.user)) { 1534 return errorPage(c, 'Backfill is currently restricted to a single allowlisted account.', 403); 1535 } 1536 const id = Number(c.req.param('id')); 1537 if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 1538 const rule = getRuleForJob.get(id, ctx.job.id); 1539 if (!rule) return errorPage(c, 'Rule not found.', 404); 1540 // Cancel every non-terminal job for the rule — covers both a single job and 1541 // a fan-out batch. The running one stops at its next page boundary; pending 1542 // ones are flipped to cancelled before the worker ever claims them. 1543 const cancellable = listBackfillJobsForRule.all(rule.id) 1544 .filter(j => j.status === 'pending' || j.status === 'running'); 1545 if (cancellable.length === 0) { 1546 return errorPage(c, 'No active backfill job to cancel for this rule.'); 1547 } 1548 for (const j of cancellable) cancelBackfillJob(j.id); 1549 return c.redirect(`/rules/${rule.id}/backfill`); 1550}); 1551 1552 1553// ───── Stripe ────────────────────────────────────────────────────────────── 1554 1555app.post('/subscribe', async (c) => { 1556 const ctx = requireActiveJob(c); 1557 if (!ctx) return c.redirect('/'); 1558 if (!stripe) return errorPage(c, 'Subscriptions are not configured on this instance.', 503); 1559 const priceId = process.env.STRIPE_PRICE_ID; 1560 if (!priceId) return errorPage(c, 'Subscriptions are not configured on this instance.', 503); 1561 1562 // Already-active users with a Stripe customer get routed to the portal; 1563 // otherwise clicking Subscribe twice creates a second subscription billed 1564 // independently. 1565 if (ctx.user.active && ctx.user.stripe_customer_id) { 1566 return c.redirect('/portal-redirect'); 1567 } 1568 1569 try { 1570 const session = await stripe.checkout.sessions.create({ 1571 mode: 'subscription', 1572 line_items: [{ price: priceId, quantity: 1 }], 1573 client_reference_id: String(ctx.user.id), 1574 customer: ctx.user.stripe_customer_id || undefined, 1575 success_url: `${PUBLIC_URL}/?subscribed=1`, 1576 cancel_url: `${PUBLIC_URL}/`, 1577 allow_promotion_codes: true, 1578 }); 1579 return c.redirect(session.url); 1580 } catch (err) { 1581 console.error('checkout session create failed', err); 1582 return errorPage(c, 'Could not start subscription.', 500); 1583 } 1584}); 1585 1586// GET shim so the /subscribe POST handler can redirect already-active users to 1587// the portal (which is itself a POST endpoint). 1588app.get('/portal-redirect', (c) => c.html( 1589 `<!doctype html><meta charset="utf-8"><title>Opening portal…</title> 1590 <form id="f" action="/portal" method="post"></form> 1591 <script>document.getElementById('f').submit()</script> 1592 <noscript><p>Click <button form="f" type="submit">here</button> to open the billing portal.</p></noscript>` 1593)); 1594 1595app.post('/portal', async (c) => { 1596 const ctx = requireActiveJob(c); 1597 if (!ctx) return c.redirect('/'); 1598 if (!ctx.user.stripe_customer_id) return c.redirect('/'); 1599 if (!stripe) return errorPage(c, 'Subscriptions are not configured on this instance.', 503); 1600 1601 try { 1602 const session = await stripe.billingPortal.sessions.create({ 1603 customer: ctx.user.stripe_customer_id, 1604 return_url: `${PUBLIC_URL}/`, 1605 }); 1606 return c.redirect(session.url); 1607 } catch (err) { 1608 console.error('portal session create failed', err); 1609 return errorPage(c, 'Could not open the customer portal.', 500); 1610 } 1611}); 1612 1613app.post('/webhooks/stripe', async (c) => { 1614 if (!stripe) return c.text('not configured', 503); 1615 const secret = process.env.STRIPE_WEBHOOK_SECRET; 1616 if (!secret) return c.text('webhook secret not configured', 503); 1617 1618 const sig = c.req.header('stripe-signature'); 1619 const body = await c.req.text(); 1620 1621 let event; 1622 try { 1623 event = stripe.webhooks.constructEvent(body, sig, secret); 1624 } catch (err) { 1625 console.error('stripe webhook signature verification failed', err.message); 1626 return c.text('bad signature', 400); 1627 } 1628 1629 // Idempotency: if we've already processed this event.id, ack and skip. 1630 // Stripe retries on any non-2xx and occasionally on success; without this, 1631 // any side effect added later would double-fire. 1632 if (!tryRecordWebhookEvent(event.id)) { 1633 return c.text('ok'); 1634 } 1635 1636 try { 1637 switch (event.type) { 1638 case 'checkout.session.completed': { 1639 const s = event.data.object; 1640 const userId = Number(s.client_reference_id); 1641 const customerId = typeof s.customer === 'string' ? s.customer : s.customer?.id; 1642 if (Number.isInteger(userId) && customerId) { 1643 setUserSubscription(userId, customerId, 1); 1644 refreshActiveGuilds(); 1645 } 1646 break; 1647 } 1648 case 'customer.subscription.deleted': { 1649 // Subscription canceled. Mark inactive AND disable every rule the user 1650 // owns — re-subscribing flips active=1 but rules stay off so the user 1651 // re-enables them deliberately. 1652 const sub = event.data.object; 1653 const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; 1654 if (customerId) { 1655 const user = getUserByCustomer(customerId); 1656 if (user) deactivateUserAndDisableRules(user.id); 1657 refreshActiveGuilds(); 1658 } 1659 break; 1660 } 1661 case 'customer.subscription.updated': 1662 case 'customer.subscription.created': { 1663 const sub = event.data.object; 1664 const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; 1665 const active = ['active', 'trialing'].includes(sub.status); 1666 if (customerId) { 1667 const user = getUserByCustomer(customerId); 1668 if (user) { 1669 if (active) { 1670 // Re-activation. Don't touch rules — user re-enables manually. 1671 setActiveByCustomer(customerId, 1); 1672 } else if (user.active) { 1673 // Past-due / canceled / unpaid — same teardown as a delete. 1674 deactivateUserAndDisableRules(user.id); 1675 } 1676 refreshActiveGuilds(); 1677 } 1678 } 1679 break; 1680 } 1681 case 'customer.deleted': { 1682 // Stripe customer record itself was removed. Drop our reference, 1683 // deactivate, and disable rules. 1684 const cust = event.data.object; 1685 const customerId = cust.id; 1686 if (customerId) { 1687 const user = getUserByCustomer(customerId); 1688 if (user) { 1689 deactivateUserAndDisableRules(user.id); 1690 clearStripeCustomer(user.id); 1691 } 1692 refreshActiveGuilds(); 1693 } 1694 break; 1695 } 1696 default: 1697 break; 1698 } 1699 } catch (err) { 1700 console.error('stripe webhook handler failed', { type: event.type, err: err.message }); 1701 return c.text('handler error', 500); 1702 } 1703 1704 return c.text('ok'); 1705}); 1706 1707// ───── helpers ───────────────────────────────────────────────────────────── 1708 1709function ruleRow(r, guilds, backfillEnabled = false) { 1710 const guildLabel = r.guild_id 1711 ? (guilds.find(g => g.id === r.guild_id)?.name ?? r.guild_id) 1712 : '(any)'; 1713 let channelLabel = '(any)'; 1714 if (r.channel_id) { 1715 const guild = botClient.isReady?.() ? botClient.guilds.cache.get(r.guild_id) : null; 1716 const ch = guild?.channels?.cache?.get(r.channel_id); 1717 channelLabel = ch?.name ? `#${ch.name}` : r.channel_id; 1718 } 1719 const authorLabel = r.author_id === 'self' ? 'just me' 1720 : r.author_id === null ? '(anyone)' 1721 : r.author_id; 1722 const matchLabel = r.match_kind 1723 ? `${r.match_kind}: <code>${esc(r.match_value ?? '')}</code>` 1724 : '(any)'; 1725 const modeLabel = r.mode === 'content' ? 'full content' : 'URLs only'; 1726 const destMeta = metaFor(r.destination_kind); 1727 const destName = destMeta?.name ?? r.destination_kind; 1728 let destDetail = ''; 1729 if (r.destination_config) { 1730 if (r.destination_kind === 'bookmark') { 1731 try { 1732 const cfg = JSON.parse(r.destination_config); 1733 const ids = cfg?.lexicons ?? []; 1734 const byId = new Map((destMeta?.lexicons ?? []).map(l => [l.id, l])); 1735 const labels = ids.map(id => { 1736 const nsid = byId.get(id)?.nsid ?? id; 1737 if (id === 'semble-card' && cfg?.lexiconConfig?.['semble-card']?.collectionUri) { 1738 const rkey = cfg.lexiconConfig['semble-card'].collectionUri.split('/').pop(); 1739 return `${esc(nsid)} <em>→ collection ${esc(rkey)}</em>`; 1740 } 1741 return esc(nsid); 1742 }); 1743 if (labels.length) destDetail = ` <small>→ ${labels.join(', ')}</small>`; 1744 } catch {} 1745 } else { 1746 destDetail = ` <small>${esc(r.destination_config)}</small>`; 1747 } 1748 } 1749 const destLabel = `${esc(destName)}${destDetail}`; 1750 return ` 1751 <tr> 1752 <td>${esc(guildLabel)}</td> 1753 <td>${esc(channelLabel)}</td> 1754 <td>${esc(authorLabel)}</td> 1755 <td>${matchLabel}</td> 1756 <td>${esc(modeLabel)}</td> 1757 <td>${destLabel}</td> 1758 <td>${r.enabled ? 'on' : 'off'}</td> 1759 <td> 1760 <div class="row-actions"> 1761 <a href="/rules/${r.id}/edit"><button type="button">Edit</button></a> 1762 <form action="/rules/${r.id}/toggle" method="post"><button type="submit">${r.enabled ? 'Disable' : 'Enable'}</button></form> 1763 ${backfillEnabled ? `<a href="/rules/${r.id}/backfill"><button type="button">Backfill</button></a>` : ''} 1764 <form action="/rules/${r.id}/delete" method="post"><button type="submit">Delete</button></form> 1765 </div> 1766 </td> 1767 </tr>`; 1768} 1769 1770function layout(title, body) { 1771 return `<!doctype html> 1772<meta charset="utf-8"> 1773<meta name="viewport" content="width=device-width,initial-scale=1"> 1774<title>${esc(title)}</title> 1775<style> 1776 :root { 1777 color-scheme: light dark; 1778 --bg: #ffffff; 1779 --fg: #1a1a1a; 1780 --muted-fg: #666666; 1781 --border: #d6d6d8; 1782 --border-strong: #b8b8bc; 1783 --card-bg: #ffffff; 1784 --soft-bg: #f5f7fa; 1785 --soft-border: #dde3ea; 1786 --code-bg: #f3f3f4; 1787 --shadow: 0 4px 16px rgba(0,0,0,0.06); 1788 --radius: 8px; 1789 --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 1790 --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; 1791 } 1792 @media (prefers-color-scheme: dark) { 1793 :root { 1794 --bg: #16161a; 1795 --fg: #e8e8ea; 1796 --muted-fg: #9a9aa1; 1797 --border: #2f2f34; 1798 --border-strong: #4a4a52; 1799 --card-bg: #1c1c20; 1800 --soft-bg: #1f2126; 1801 --soft-border: #2a2d34; 1802 --code-bg: #232328; 1803 --shadow: 0 4px 16px rgba(0,0,0,0.35); 1804 } 1805 } 1806 1807 * { box-sizing: border-box; } 1808 html, body { background: var(--bg); color: var(--fg); } 1809 body { 1810 font-family: var(--font-sans); 1811 font-size: 15px; 1812 line-height: 1.5; 1813 max-width: 1080px; 1814 margin: 0 auto; 1815 padding: 2rem 1.25rem 4rem; 1816 position: relative; 1817 } 1818 h1 { font-size: 1.6rem; margin: 0 0 0.5rem; letter-spacing: -0.01em; } 1819 h2 { font-size: 1.05rem; margin: 2rem 0 0.5rem; } 1820 h3 { font-size: 0.95rem; margin: 1.25rem 0 0.5rem; } 1821 p { margin: 0.5rem 0; } 1822 hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; } 1823 code { 1824 font-family: var(--font-mono); font-size: 0.85em; 1825 background: var(--code-bg); padding: 0.05em 0.35em; border-radius: 4px; 1826 word-break: break-all; 1827 } 1828 a { color: inherit; } 1829 small { color: var(--muted-fg); font-size: 0.85em; } 1830 1831 button { 1832 font: inherit; font-size: 0.9rem; 1833 color: var(--fg); background: var(--card-bg); 1834 border: 1px solid var(--border-strong); border-radius: 6px; 1835 padding: 0.35rem 0.8rem; cursor: pointer; 1836 } 1837 button:hover { background: var(--soft-bg); } 1838 input, select, textarea { 1839 font: inherit; font-size: 0.9rem; 1840 color: var(--fg); background: var(--card-bg); 1841 border: 1px solid var(--border); border-radius: 6px; 1842 padding: 0.35rem 0.5rem; 1843 } 1844 textarea { font-family: var(--font-mono); } 1845 1846 details.help { position: absolute; top: 1.5rem; right: 1.25rem; } 1847 details.help > summary { 1848 cursor: pointer; list-style: none; 1849 width: 1.9rem; height: 1.9rem; 1850 border: 1px solid var(--border-strong); border-radius: 50%; 1851 display: inline-flex; align-items: center; justify-content: center; 1852 font-weight: 600; color: var(--muted-fg); background: var(--card-bg); 1853 } 1854 details.help > summary::-webkit-details-marker { display: none; } 1855 details.help[open] > summary { background: var(--soft-bg); color: var(--fg); } 1856 details.help .help-panel { 1857 position: absolute; top: 2.5rem; right: 0; 1858 width: min(560px, 92vw); 1859 background: var(--card-bg); color: var(--fg); 1860 border: 1px solid var(--border); border-radius: var(--radius); 1861 padding: 1rem 1.25rem; font-size: 0.9rem; 1862 box-shadow: var(--shadow); 1863 z-index: 10; 1864 } 1865 details.help .help-panel h3 { margin: 0 0 0.5rem; font-size: 1rem; } 1866 details.help .help-panel ol { padding-left: 1.2rem; margin: 0.5rem 0; } 1867 details.help .help-panel li { margin-bottom: 0.25rem; } 1868 1869 .row { 1870 display: flex; flex-wrap: wrap; 1871 gap: 0.5rem; 1872 align-items: stretch; 1873 margin: 1.25rem 0 1.5rem; 1874 } 1875 .card { 1876 flex: 1 1 240px; min-width: 220px; 1877 background: var(--card-bg); 1878 border: 1px solid var(--border); border-radius: var(--radius); 1879 padding: 1rem; font-size: 0.9rem; 1880 transition: opacity 0.15s ease; 1881 } 1882 .card-disabled { 1883 opacity: 0.5; 1884 pointer-events: none; 1885 user-select: none; 1886 } 1887 .card h2 { margin: 0 0 0.5rem; font-size: 0.95rem; } 1888 .card ul { padding-left: 1.1rem; margin: 0.25rem 0; } 1889 .card p { margin: 0.5rem 0; } 1890 .card form { display: inline-block; margin-right: 0.4rem; margin-bottom: 0.25rem; } 1891 1892 .warning { 1893 background: rgba(255, 196, 0, 0.08); 1894 border: 1px solid rgba(255, 196, 0, 0.4); 1895 border-radius: 6px; 1896 padding: 0.5rem 0.65rem; margin: 0.5rem 0; 1897 font-size: 0.85rem; 1898 } 1899 /* Inline pill + button variants of .warning, used by the atmo switcher to 1900 surface "re-auth needed" without claiming a full row. */ 1901 .warning.small { 1902 display: inline-block; 1903 padding: 0.05em 0.4em; margin: 0; 1904 font-size: 0.75rem; 1905 border-radius: 4px; 1906 } 1907 button.warning { 1908 background: rgba(255, 196, 0, 0.12); 1909 border-color: rgba(255, 196, 0, 0.55); 1910 color: var(--fg); 1911 margin: 0; 1912 } 1913 button.warning:hover { background: rgba(255, 196, 0, 0.22); } 1914 button.danger { 1915 background: transparent; 1916 border-color: rgba(220, 60, 60, 0.55); 1917 color: #c43a3a; 1918 } 1919 button.danger:hover { background: rgba(220, 60, 60, 0.10); } 1920 @media (prefers-color-scheme: dark) { 1921 button.danger { color: #e8746e; border-color: rgba(232, 116, 110, 0.45); } 1922 button.danger:hover { background: rgba(232, 116, 110, 0.12); } 1923 } 1924 1925 /* Atmosphere identity switcher inside the third card. Each identity stacks 1926 vertically — handle on top, action buttons on a flex row beneath — so a 1927 long handle and 2–3 buttons never have to share one ~340px-wide line. 1928 Active job sits in a soft-bg pill; secondary jobs are plain rows. */ 1929 .atmo-active, .atmo-other { margin: 0.35rem 0; } 1930 .atmo-active { 1931 background: var(--soft-bg); 1932 border: 1px solid var(--soft-border); 1933 border-radius: var(--radius); 1934 padding: 0.5rem 0.6rem; 1935 } 1936 .atmo-active .who, .atmo-other .who { margin: 0 0 0.4rem; } 1937 .atmo-actions { 1938 display: flex; flex-wrap: wrap; 1939 gap: 0.35rem; align-items: center; 1940 } 1941 .atmo-actions form { margin: 0; } 1942 .atmo-add { 1943 display: flex; gap: 0.35rem; align-items: stretch; 1944 margin-top: 0.75rem; 1945 } 1946 .atmo-add input { flex: 1 1 auto; min-width: 0; } 1947 1948 .who { display: flex; align-items: center; gap: 0.6rem; margin: 0.25rem 0 0.6rem; } 1949 .avatar { 1950 width: 36px; height: 36px; 1951 border-radius: 50%; 1952 object-fit: cover; 1953 background: var(--soft-bg); 1954 border: 1px solid var(--border); 1955 flex: 0 0 36px; 1956 } 1957 .avatar-placeholder { background: var(--soft-bg); } 1958 1959 .arrow { 1960 display: flex; align-items: center; justify-content: center; 1961 flex: 0 0 1.5rem; 1962 color: var(--border-strong); 1963 font-size: 1.4rem; line-height: 1; 1964 user-select: none; 1965 } 1966 .arrow-dim { color: var(--border); opacity: 0.6; } 1967 @media (max-width: 720px) { 1968 /* On stacked layout, rotate arrows down between cards. */ 1969 .arrow { flex: 0 0 auto; transform: rotate(90deg); margin: 0.25rem auto; } 1970 } 1971 1972 .usage { 1973 background: var(--soft-bg); border: 1px solid var(--soft-border); 1974 border-radius: var(--radius); padding: 0.6rem 0.85rem; 1975 margin: 1rem 0; font-size: 0.9rem; 1976 } 1977 1978 table.rules { 1979 width: 100%; border-collapse: collapse; 1980 margin: 1rem 0; font-size: 0.9rem; 1981 background: var(--card-bg); 1982 border: 1px solid var(--border); border-radius: var(--radius); 1983 overflow: hidden; 1984 } 1985 table.rules th, table.rules td { 1986 border-bottom: 1px solid var(--border); 1987 padding: 0.5rem 0.65rem; 1988 text-align: left; vertical-align: top; 1989 } 1990 table.rules tr:last-child td { border-bottom: 0; } 1991 table.rules th { 1992 background: var(--soft-bg); color: var(--muted-fg); 1993 font-weight: 600; font-size: 0.8rem; 1994 text-transform: uppercase; letter-spacing: 0.04em; 1995 } 1996 table.rules td button { padding: 0.2rem 0.55rem; font-size: 0.8rem; } 1997 table.rules td .row-actions { display: flex; gap: 0.35rem; flex-wrap: nowrap; } 1998 table.rules.examples td em { color: var(--muted-fg); font-style: italic; } 1999 table.rules.examples td:first-child { font-weight: 500; } 2000 2001 form.add-rule { max-width: 640px; } 2002 form.add-rule fieldset { 2003 border: 1px solid var(--border); border-radius: var(--radius); 2004 padding: 0.6rem 0.9rem; margin: 0.75rem 0; 2005 } 2006 form.add-rule legend { padding: 0 0.35rem; color: var(--muted-fg); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; } 2007 form.add-rule input:not([type=radio]):not([type=checkbox]), 2008 form.add-rule select, 2009 form.add-rule textarea { 2010 width: 100%; max-width: 100%; 2011 margin-top: 0.15rem; 2012 } 2013 form.add-rule label { display: block; } 2014 form.add-rule label small { display: block; color: var(--muted-fg); font-weight: normal; margin-top: 0.25rem; } 2015 form.add-rule fieldset label { display: inline; } 2016 form.add-rule fieldset > input { width: auto; max-width: 320px; margin-left: 0.4rem; } 2017</style> 2018${body}`; 2019} 2020 2021function esc(s) { 2022 return String(s ?? '').replace(/[&<>"']/g, c => ({ 2023 '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', 2024 }[c])); 2025}