This repository has no description
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 “${esc(handle)}”. 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="/">← 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="/">← 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> ·
1175 Extract: <strong>${esc(rule.mode)}</strong> ·
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 · running: <strong><span id="bf-running">${summary.running}</span></strong>
1233 · queued: <strong><span id="bf-pending">${summary.pending}</span></strong>
1234 · 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 · 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>% ·
1334 messages processed: <strong><span id="bf-scanned">${latest.scanned}</span></strong> ·
1335 records written: <strong><span id="bf-written">${latest.records_written ?? 0}</span></strong> ·
1336 failed writes: <strong><span id="bf-errors">${latest.error_count ?? 0}</span></strong>
1337 <span id="bf-cursor">${latest.cursor_ts ? `· 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="/">← 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> ·
1386 Extract: <strong>${esc(rule.mode)}</strong> ·
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 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
2024 }[c]));
2025}