@rackbops/plugin-tracker 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +130 -12
  2. package/dist/plugin.js +1700 -230
  3. package/package.json +2 -2
package/dist/plugin.js CHANGED
@@ -191,6 +191,14 @@ async function optOut(store, task, recipientId, now, occurrenceId = null) {
191
191
  at
192
192
  });
193
193
  }
194
+ async function liftBlock(store, actor, blockId, now) {
195
+ if (!actor.admin)
196
+ throw new ConsentError("only an admin lifts a block");
197
+ const block = await store.getBlock(blockId);
198
+ if (!block)
199
+ throw new ConsentError(`no block ${blockId}`);
200
+ return store.liftBlock(blockId, actor.userId, now.toISOString());
201
+ }
194
202
 
195
203
  // node_modules/@rackbops/docket-core/dist/lanes.js
196
204
  var LANES = ["notify", "execute"];
@@ -1916,6 +1924,10 @@ class ClaimStore {
1916
1924
  // plugins/tracker/src/delivery-health.ts
1917
1925
  var PAUSE_AFTER = 3;
1918
1926
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
1927
+ function recipientPauseDetail(userId) {
1928
+ return `delivery to {${userId}} paused: ${PAUSE_AFTER} DMs in a row could not be delivered`;
1929
+ }
1930
+ var PERSON_REF = /\{(u[1-9][0-9]*)\}/g;
1919
1931
  function decidePause(failures, alreadyPaused) {
1920
1932
  return !alreadyPaused && failures >= PAUSE_AFTER;
1921
1933
  }
@@ -1941,6 +1953,9 @@ class DeliveryHealth {
1941
1953
  this.chain = next.catch(() => {});
1942
1954
  return next;
1943
1955
  }
1956
+ exclusive(fn) {
1957
+ return this.locked(fn);
1958
+ }
1944
1959
  get(userId) {
1945
1960
  const r = this.db.query("SELECT * FROM delivery_health WHERE user_id = ?").get(userId);
1946
1961
  if (!r)
@@ -1959,6 +1974,8 @@ class DeliveryHealth {
1959
1974
  async recordFailure(userId, error, at) {
1960
1975
  const notices = [];
1961
1976
  const result = await this.locked(async () => {
1977
+ if (!await this.store.getUser(userId))
1978
+ return { failures: 0, paused: false };
1962
1979
  this.db.query(`INSERT INTO delivery_health (user_id, failures, last_error, last_failed_at) VALUES (?, 1, ?, ?)
1963
1980
  ON CONFLICT (user_id) DO UPDATE SET failures = delivery_health.failures + 1,
1964
1981
  last_error = excluded.last_error, last_failed_at = excluded.last_failed_at`).run(userId, error, at);
@@ -1995,7 +2012,7 @@ class DeliveryHealth {
1995
2012
  if (changes === 0 || task.status !== "active")
1996
2013
  continue;
1997
2014
  await this.store.updateTask(task.id, { status: "paused", at });
1998
- const detail = asRecipient ? `delivery to ${name2(recipient, userId)} paused: ${PAUSE_AFTER} DMs in a row could not be delivered` : PAUSE_DETAIL;
2015
+ const detail = asRecipient ? recipientPauseDetail(userId) : PAUSE_DETAIL;
1999
2016
  await this.store.addTaskEvent({ taskId: task.id, actorId: null, kind: "paused", detail, at });
2000
2017
  if (asRecipient) {
2001
2018
  const owner = await this.store.getUser(task.ownerId);
@@ -2381,6 +2398,19 @@ function serial() {
2381
2398
  };
2382
2399
  }
2383
2400
 
2401
+ // plugins/tracker/src/limits.ts
2402
+ var MAX_TITLE = 100;
2403
+ var MAX_REMINDER_TEXT = 1500;
2404
+ var MAX_WHEN = 100;
2405
+ var MAX_NOTE = 300;
2406
+ var MAX_URL = 1000;
2407
+ var MAX_NEAR = 100;
2408
+ var CURRENCY_LENGTH = 3;
2409
+ var DATE_LENGTH = 10;
2410
+ var MAX_ZONE = 64;
2411
+ var MAX_TASK_ID = 24;
2412
+ var MAX_LIVE_TASKS = 200;
2413
+
2384
2414
  // plugins/tracker/src/people.ts
2385
2415
  var DEFAULT_TIME_ZONE = "America/New_York";
2386
2416
  var DEFAULT_PREFERRED_HOUR = 9;
@@ -2447,10 +2477,49 @@ async function setPreferences(store, userId, prefs) {
2447
2477
  });
2448
2478
  }
2449
2479
 
2480
+ // plugins/tracker/src/retime.ts
2481
+ var DAY_MS2 = 24 * 60 * 60 * 1000;
2482
+ function localDay(at, zone) {
2483
+ const w = wallClock(at, zone);
2484
+ return `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2485
+ }
2486
+ function occurrenceDay(s, due, zone) {
2487
+ return s.kind === "period" ? periodDate(s, due, zone) : localDay(due, zone);
2488
+ }
2489
+ function retimed(s, day, owner) {
2490
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
2491
+ const lead = s.kind === "period" ? s.leadDays ?? 0 : 0;
2492
+ let after = new Date(Date.parse(`${day}T00:00:00Z`) - (lead + 2) * DAY_MS2);
2493
+ for (let i = 0;i < 1000; i++) {
2494
+ const due = nextDue(s, after, ctx);
2495
+ if (!due)
2496
+ return null;
2497
+ const on = occurrenceDay(s, due, owner.timeZone);
2498
+ if (on === day)
2499
+ return due;
2500
+ if (on > day)
2501
+ return null;
2502
+ after = due;
2503
+ }
2504
+ return null;
2505
+ }
2506
+ async function heldRuns(store, task) {
2507
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
2508
+ }
2509
+ async function restoreHeldRun(store, task, held, before, after, now) {
2510
+ const s = task.schedule;
2511
+ const first = held[0];
2512
+ if (!s || !first)
2513
+ return null;
2514
+ const due = retimed(s, occurrenceDay(s, new Date(first.dueAt), before.timeZone), after);
2515
+ if (!due)
2516
+ return null;
2517
+ return store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: due.toISOString(), dedupeKey: scheduledKey(task.id, due), at: now.toISOString() });
2518
+ }
2519
+
2450
2520
  // plugins/tracker/src/actions.ts
2451
2521
  var MAX_ANSWER = 2000;
2452
- var MAX_REMINDER_TEXT = 1500;
2453
- var MAX_TITLE = 100;
2522
+ var NO_LONGER_LISTED = "You are no longer on this tracker's list.";
2454
2523
  var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
2455
2524
  function clip(text, max = MAX_ANSWER) {
2456
2525
  return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
@@ -2505,7 +2574,7 @@ async function registerPerson(d, user, input) {
2505
2574
  d.admissions.markRegistered(user.id, d.clock.now().toISOString());
2506
2575
  const zoneChanged = updated.timeZone !== user.timeZone;
2507
2576
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2508
- await rescheduleOwned(d, updated, zoneChanged);
2577
+ await rescheduleOwned(d, updated, zoneChanged, user);
2509
2578
  return registeredText(updated, first);
2510
2579
  }
2511
2580
  async function saveSettings(d, user, input) {
@@ -2519,7 +2588,7 @@ async function saveSettings(d, user, input) {
2519
2588
  }
2520
2589
  const zoneChanged = updated.timeZone !== user.timeZone;
2521
2590
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2522
- await rescheduleOwned(d, updated, zoneChanged);
2591
+ await rescheduleOwned(d, updated, zoneChanged, user);
2523
2592
  return null;
2524
2593
  }
2525
2594
  async function setHour(d, user, hour) {
@@ -2533,73 +2602,46 @@ async function setHour(d, user, hour) {
2533
2602
  return err.message;
2534
2603
  throw err;
2535
2604
  }
2536
- const moved = await rescheduleOwned(d, updated, false);
2605
+ const moved = await rescheduleOwned(d, updated, false, user);
2537
2606
  const note = moved > 0 ? ` ${moved} recurring reminder(s) moved to it.` : "";
2538
2607
  return `Your preferred hour is now ${String(hour).padStart(2, "0")}:00, ${updated.timeZone} time.${note}`;
2539
2608
  }
2540
- async function rescheduleOwned(d, owner, zoneChanged) {
2609
+ async function rescheduleOwned(d, owner, zoneChanged, before) {
2541
2610
  let moved = 0;
2542
- for (const task of await d.store.listTasks({ ownerId: owner.id, status: "active" })) {
2611
+ const live = [...await d.store.listTasks({ ownerId: owner.id, status: "active" }), ...await d.store.listTasks({ ownerId: owner.id, status: "paused" })];
2612
+ for (const task of live) {
2543
2613
  const s = task.schedule;
2544
2614
  if (!s || s.kind !== "calendar" && s.kind !== "period")
2545
2615
  continue;
2546
2616
  if (!zoneChanged && s.hour !== undefined)
2547
2617
  continue;
2548
- const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2549
- const now = d.clock.now();
2550
- await reschedule(d.store, task, owner, s, owner.id, now);
2551
- for (const o of snoozes) {
2552
- await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2553
- }
2618
+ const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
2619
+ const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
2620
+ if (held.length > 0)
2621
+ await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
2554
2622
  moved++;
2555
2623
  }
2556
2624
  return moved;
2557
2625
  }
2558
- function reminderSchedule(when, repeat, zone, now) {
2559
- if (repeat === "none") {
2560
- if (!when)
2561
- return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2562
- return { kind: "once", at: when.toISOString() };
2563
- }
2564
- const w = wallClock(when ?? now, zone);
2565
- const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2566
- const base = { kind: "calendar", every: 1, unit: repeat, start };
2567
- if (!when)
2568
- return base;
2569
- return {
2570
- ...base,
2571
- hour: w.hour,
2572
- minute: w.minute,
2573
- ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2574
- ...repeat === "month" ? { dayOfMonth: w.day } : {}
2575
- };
2576
- }
2577
- async function remind(d, user, input) {
2578
- const type = d.types.reminder;
2579
- if (!type)
2580
- return "Reminders are not available on this bot.";
2581
- const text = input.text.trim();
2582
- if (text.length === 0)
2583
- return "Say what to remind you of.";
2584
- if (text.length > MAX_REMINDER_TEXT)
2585
- return `That reminder is longer than ${MAX_REMINDER_TEXT} characters.`;
2626
+ async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
2627
+ const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2586
2628
  const now = d.clock.now();
2587
- let when = null;
2588
- if (input.when !== undefined && input.when.trim() !== "") {
2589
- const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2590
- if (!parsed.ok)
2591
- return parsed.error;
2592
- when = parsed.at;
2629
+ const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
2630
+ for (const o of snoozes) {
2631
+ await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2593
2632
  }
2594
- const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2595
- if (typeof schedule === "string")
2596
- return schedule;
2597
- const actor = { userId: user.id, admin: user.admin };
2598
- const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2599
- const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2600
- const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2601
- return clip(`Reminder \`${task.id}\` set: ${text}
2602
- Next: ${at}${cadence}.`);
2633
+ return updated;
2634
+ }
2635
+ async function liveTaskCap(d, owner) {
2636
+ const active = await d.store.listTasks({ ownerId: owner.id, status: "active" });
2637
+ const paused = await d.store.listTasks({ ownerId: owner.id, status: "paused" });
2638
+ if (active.length + paused.length < MAX_LIVE_TASKS)
2639
+ return null;
2640
+ const where = d.webEditor ? " or delete one on the web" : "";
2641
+ return `You already have ${MAX_LIVE_TASKS} active or paused tasks, the most one person may. Finish one${where} first.`;
2642
+ }
2643
+ function said(r) {
2644
+ return r.ok ? r.text : r.error;
2603
2645
  }
2604
2646
  async function loadTaskList(d, user) {
2605
2647
  const entries = await taskList(d.store, { userId: user.id, admin: user.admin });
@@ -2632,6 +2674,8 @@ async function ownTask(d, user, taskId) {
2632
2674
  return task && task.ownerId === user.id ? task : null;
2633
2675
  }
2634
2676
  async function answerLatest(d, user, input) {
2677
+ if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
2678
+ return `\`until\` is longer than ${MAX_WHEN} characters.`;
2635
2679
  const task = await ownTask(d, user, input.taskId);
2636
2680
  if (!task)
2637
2681
  return NO_SUCH_TASK;
@@ -2662,18 +2706,136 @@ function answeredText(kind, snoozeUntil, user, now) {
2662
2706
  return "Marked done.";
2663
2707
  return "Recorded.";
2664
2708
  }
2665
- async function resumeTask(d, user, taskId) {
2709
+
2710
+ // plugins/tracker/src/manage.ts
2711
+ async function ownLiveTask(d, user, taskId) {
2666
2712
  const task = await ownTask(d, user, taskId);
2713
+ return task && task.status !== "archived" ? task : null;
2714
+ }
2715
+ function editable(task) {
2716
+ return task.status === "active" || task.status === "paused";
2717
+ }
2718
+ var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
2719
+ async function pauseTask(d, user, taskId) {
2720
+ const task = await ownLiveTask(d, user, taskId);
2667
2721
  if (!task)
2668
- return NO_SUCH_TASK;
2669
- const removed = await d.health.resumeTask(task, user.id, d.clock.now());
2670
- if (removed === null)
2671
- return task.status === "paused" ? "That task was not paused by failed DMs." : `That task is ${task.status}, not paused.`;
2722
+ return { ok: false, error: NO_SUCH_TASK };
2723
+ if (task.status !== "active")
2724
+ return { ok: false, error: `That task is ${task.status}, not active.` };
2725
+ const at = d.clock.now().toISOString();
2726
+ await d.store.updateTask(task.id, { status: "paused", at });
2727
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
2728
+ return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
2729
+ }
2730
+ async function resumeTask(d, user, taskId) {
2731
+ const task = await ownLiveTask(d, user, taskId);
2732
+ if (!task)
2733
+ return { ok: false, error: NO_SUCH_TASK };
2734
+ if (task.status !== "paused")
2735
+ return { ok: false, error: `That task is ${task.status}, not paused.` };
2736
+ const now = d.clock.now();
2737
+ if (d.health.isPaused(user.id))
2738
+ await d.health.resume(user.id, now);
2739
+ const removed = await d.health.resumeTask(task, user.id, now);
2740
+ if (removed === null) {
2741
+ const current = await d.store.getTask(task.id);
2742
+ if (current?.status === "paused") {
2743
+ const at = now.toISOString();
2744
+ const active = await d.store.updateTask(task.id, { status: "active", at });
2745
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "resumed", detail: "resumed by the owner", at });
2746
+ await materialize(d.store, active, user, now);
2747
+ }
2748
+ return { ok: true, text: `Resumed \`${task.id}\`.` };
2749
+ }
2672
2750
  const names = [];
2673
2751
  for (const id of removed)
2674
2752
  names.push((await d.store.getUser(id))?.displayName ?? id);
2675
2753
  const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2676
- return `Resumed \`${task.id}\`${without}.`;
2754
+ return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
2755
+ }
2756
+ async function deleteTask(d, user, taskId) {
2757
+ const task = await ownLiveTask(d, user, taskId);
2758
+ if (!task)
2759
+ return { ok: false, error: NO_SUCH_TASK };
2760
+ const at = d.clock.now().toISOString();
2761
+ await d.store.deleteQueuedOccurrences(task.id);
2762
+ await d.store.updateTask(task.id, { status: "archived", at });
2763
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
2764
+ return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
2765
+ }
2766
+
2767
+ // plugins/tracker/src/reminders.ts
2768
+ var REPEATS = ["none", "day", "week", "month"];
2769
+ function reminderSchedule(when, repeat, zone, now) {
2770
+ if (repeat === "none") {
2771
+ if (!when)
2772
+ return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2773
+ return { kind: "once", at: when.toISOString() };
2774
+ }
2775
+ const w = wallClock(when ?? now, zone);
2776
+ const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2777
+ const base = { kind: "calendar", every: 1, unit: repeat, start };
2778
+ if (!when)
2779
+ return base;
2780
+ return {
2781
+ ...base,
2782
+ hour: w.hour,
2783
+ minute: w.minute,
2784
+ ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2785
+ ...repeat === "month" ? { dayOfMonth: w.day } : {}
2786
+ };
2787
+ }
2788
+ function repeatOf(schedule) {
2789
+ if (schedule?.kind !== "calendar")
2790
+ return "none";
2791
+ return schedule.unit;
2792
+ }
2793
+ function reminderPlan(d, user, input, keep) {
2794
+ const text = input.text.trim();
2795
+ if (text.length === 0)
2796
+ return { ok: false, error: "Say what to remind you of." };
2797
+ if (text.length > MAX_REMINDER_TEXT)
2798
+ return { ok: false, error: `That reminder is longer than ${MAX_REMINDER_TEXT} characters.` };
2799
+ if (!REPEATS.includes(input.repeat))
2800
+ return { ok: false, error: "`repeat` is once, daily, weekly or monthly." };
2801
+ const now = d.clock.now();
2802
+ let when = null;
2803
+ if (input.when !== undefined && input.when.trim().length > MAX_WHEN)
2804
+ return { ok: false, error: `\`when\` is longer than ${MAX_WHEN} characters.` };
2805
+ if (input.when !== undefined && input.when.trim() !== "") {
2806
+ const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2807
+ if (!parsed.ok)
2808
+ return { ok: false, error: parsed.error };
2809
+ when = parsed.at;
2810
+ }
2811
+ if (when === null && keep && repeatOf(keep) === input.repeat)
2812
+ return { ok: true, text, schedule: keep };
2813
+ const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2814
+ if (typeof schedule === "string")
2815
+ return { ok: false, error: schedule };
2816
+ return { ok: true, text, schedule };
2817
+ }
2818
+ async function createReminder(d, user, input) {
2819
+ const type = d.types.reminder;
2820
+ if (!type)
2821
+ return { ok: false, error: "Reminders are not available on this bot." };
2822
+ const plan = reminderPlan(d, user, input);
2823
+ if (!plan.ok)
2824
+ return plan;
2825
+ const capped = await liveTaskCap(d, user);
2826
+ if (capped)
2827
+ return { ok: false, error: capped };
2828
+ const { text, schedule } = plan;
2829
+ const now = d.clock.now();
2830
+ const actor = { userId: user.id, admin: user.admin };
2831
+ const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2832
+ const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2833
+ const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2834
+ return { ok: true, task, text: clip(`Reminder \`${task.id}\` set: ${text}
2835
+ Next: ${at}${cadence}.`) };
2836
+ }
2837
+ async function remind(d, user, input) {
2838
+ return said(await createReminder(d, user, input));
2677
2839
  }
2678
2840
 
2679
2841
  // plugins/tracker/src/interactions.ts
@@ -2887,6 +3049,8 @@ async function sendShare(d, p) {
2887
3049
  async function finishShare(d, p, err) {
2888
3050
  if (err === null)
2889
3051
  return `Invited <@${p.targetDiscordId}> to \`${p.taskId}\`. They get a DM to accept or decline.`;
3052
+ if (!await d.store.getUser(p.targetId) || !await d.store.getTask(p.taskId))
3053
+ return `<@${p.targetDiscordId}> ${CANNOT_SHARE}`;
2890
3054
  await d.store.removeRecipient(p.taskId, p.targetId);
2891
3055
  await d.store.addTaskEvent({
2892
3056
  taskId: p.taskId,
@@ -3078,12 +3242,9 @@ function runView(o, replies, people, viewer, now) {
3078
3242
  answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
3079
3243
  };
3080
3244
  }
3081
- function changeView(e, viewer, now) {
3082
- return {
3083
- at: formatInstant(e.at, viewer.timeZone, now),
3084
- kind: e.kind.replaceAll("_", " "),
3085
- detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
3086
- };
3245
+ function changeView(e, viewer, now, names = new Map) {
3246
+ const detail = e.kind === "paused" || e.kind === "resumed" ? e.detail.replace(PERSON_REF, (_, id) => names.get(id) ?? "someone no longer on the tracker") : null;
3247
+ return { at: formatInstant(e.at, viewer.timeZone, now), kind: e.kind.replaceAll("_", " "), detail };
3087
3248
  }
3088
3249
  function runLine(v) {
3089
3250
  const late = v.late ? " (late)" : "";
@@ -3114,6 +3275,16 @@ async function loadHistory(d, user, taskId) {
3114
3275
  people.set(u.id, u);
3115
3276
  }
3116
3277
  }
3278
+ const names = new Map;
3279
+ for (const e of changes) {
3280
+ for (const [, id] of e.detail.matchAll(PERSON_REF)) {
3281
+ if (id === undefined || names.has(id))
3282
+ continue;
3283
+ const u = await d.store.getUser(id);
3284
+ if (u)
3285
+ names.set(id, u.displayName ?? id);
3286
+ }
3287
+ }
3117
3288
  const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
3118
3289
  return {
3119
3290
  task,
@@ -3121,7 +3292,7 @@ async function loadHistory(d, user, taskId) {
3121
3292
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
3122
3293
  runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
3123
3294
  earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
3124
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
3295
+ changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
3125
3296
  earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
3126
3297
  };
3127
3298
  }
@@ -3204,86 +3375,33 @@ ${block}
3204
3375
  ${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
3205
3376
  }
3206
3377
 
3207
- // plugins/tracker/src/tracked.ts
3208
- var MAX_TITLE2 = 100;
3209
- var MAX_NOTE = 300;
3210
- var MAX_NEAR = 100;
3378
+ // plugins/tracker/src/price.ts
3211
3379
  var MAX_PRICE_TASKS = 20;
3212
3380
  var DEFAULT_POLL_HOURS = 12;
3213
3381
  var MAX_POLL_HOURS = 168;
3214
- var DEFAULT_LEAD_DAYS = 7;
3215
- var MAX_LEAD_DAYS = 365;
3216
- var MAX_EVERY = 100;
3217
3382
  function plural2(n, word) {
3218
3383
  return `${n} ${word}${n === 1 ? "" : "s"}`;
3219
3384
  }
3220
- async function addRenewal(d, user, input) {
3221
- const type = d.types.renewal;
3222
- if (!type)
3223
- return "Renewals are not available on this bot.";
3224
- const name = input.name.trim();
3225
- if (name.length === 0)
3226
- return "Say what renews: a subscription, a domain, a warranty.";
3227
- if (name.length > MAX_TITLE2)
3228
- return `That name is longer than ${MAX_TITLE2} characters.`;
3229
- if (!Number.isFinite(input.amount) || input.amount < 0)
3230
- return "The amount has to be zero or more.";
3231
- const currency = input.currency.trim().toUpperCase();
3232
- if (!/^[A-Z]{3}$/.test(currency))
3233
- return "The currency is a three-letter code, such as USD or EUR.";
3234
- const note = input.note?.trim() ?? "";
3235
- if (note.length > MAX_NOTE)
3236
- return `The note is longer than ${MAX_NOTE} characters.`;
3237
- const every = input.every ?? 1;
3238
- if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3239
- return `\`every\` is a whole number from 1 to ${MAX_EVERY}.`;
3240
- const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3241
- if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3242
- return `\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`;
3243
- const renews = input.renews.trim();
3244
- const now = d.clock.now();
3245
- const days = /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, now, user.timeZone) : null;
3246
- if (days === null)
3247
- return "Give the date as YYYY-MM-DD, for example 2026-12-01.";
3248
- if (days < 0)
3249
- return "That date has passed: give the next renewal or expiry date.";
3250
- const schedule = { kind: "period", every, unit: input.unit ?? "year", anchor: renews, leadDays: lead };
3251
- const config = { amount: input.amount, currency, ...note ? { note } : {} };
3252
- const actor = { userId: user.id, admin: user.admin };
3253
- let created;
3254
- try {
3255
- created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3256
- } catch (err) {
3257
- if (err instanceof ScheduleError)
3258
- return `That schedule does not work: ${err.message}.`;
3259
- throw err;
3260
- }
3261
- const { task } = created;
3262
- let next = created.next;
3263
- const first = nextDue(schedule, new Date(0), { zone: user.timeZone, preferredHour: user.preferredHour });
3264
- if (first && first.getTime() <= now.getTime()) {
3265
- next = await d.store.createOccurrence({
3266
- taskId: task.id,
3267
- lane: task.lane,
3268
- dueAt: first.toISOString(),
3269
- dedupeKey: scheduledKey(task.id, first),
3270
- at: now.toISOString()
3271
- });
3272
- }
3273
- const cadence = describeSchedule(schedule, user, user.timeZone, now);
3274
- const when = days === 0 ? "today" : `in ${plural2(days, "day")}`;
3275
- const ask = !next ? "never (that schedule has no next date)" : Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3276
- return clip([
3277
- `Renewal \`${task.id}\` set: ${name}, ${money(input.amount, currency)}, ${cadence}.`,
3278
- `First ask: ${ask}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3279
- ].join(`
3280
- `));
3281
- }
3282
3385
  function nearPattern(near) {
3283
3386
  const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3284
3387
  return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
3285
3388
  }
3286
- async function preview(d, v) {
3389
+ function priceSettingsPlan(input) {
3390
+ const hours = input.hours ?? DEFAULT_POLL_HOURS;
3391
+ if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3392
+ return { ok: false, error: `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.` };
3393
+ const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3394
+ if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3395
+ return { ok: false, error: "`drop` is a percentage from 1 to 90." };
3396
+ const baseline = input.baseline ?? DEFAULT_BASELINE;
3397
+ if (!BASELINE_RULES.includes(baseline))
3398
+ return { ok: false, error: "`baseline` is first, last or peak." };
3399
+ const name = input.name?.trim() ?? "";
3400
+ if (name.length > MAX_TITLE)
3401
+ return { ok: false, error: `That name is longer than ${MAX_TITLE} characters.` };
3402
+ return { ok: true, name, hours, drop, baseline };
3403
+ }
3404
+ async function previewPrice(d, v) {
3287
3405
  if (!d.fetch)
3288
3406
  return { ok: false, error: "Price tracking is not available on this bot." };
3289
3407
  try {
@@ -3309,65 +3427,168 @@ async function ownedPriceTasks(d, user) {
3309
3427
  const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
3310
3428
  return [...active, ...paused].filter((t) => t.type === "price").length;
3311
3429
  }
3430
+ function atCap(d) {
3431
+ const where = d.webEditor ? ", or delete one on the web," : "";
3432
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\`${where} first.`;
3433
+ }
3312
3434
  function titleFor(url) {
3313
3435
  const u = new URL(url);
3314
- return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE2);
3436
+ return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE);
3315
3437
  }
3316
- async function trackPrice(d, user, input) {
3438
+ async function startPrice(d, user, input) {
3317
3439
  if (!d.types.price || !d.fetch)
3318
- return "Price tracking is not available on this bot.";
3440
+ return { ok: false, error: "Price tracking is not available on this bot." };
3319
3441
  const url = input.url.trim();
3442
+ if (url.length > MAX_URL)
3443
+ return { ok: false, error: `That address is longer than ${MAX_URL} characters.` };
3320
3444
  const problem = urlProblem(url);
3321
3445
  if (problem)
3322
- return problem;
3323
- const hours = input.hours ?? DEFAULT_POLL_HOURS;
3324
- if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3325
- return `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.`;
3326
- const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3327
- if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3328
- return "`drop` is a percentage from 1 to 90.";
3329
- const baseline = input.baseline ?? DEFAULT_BASELINE;
3330
- if (!BASELINE_RULES.includes(baseline))
3331
- return "`baseline` is first, last or peak.";
3446
+ return { ok: false, error: problem };
3447
+ const settings = priceSettingsPlan(input);
3448
+ if (!settings.ok)
3449
+ return settings;
3332
3450
  const near = input.near?.trim() ?? "";
3333
3451
  if (near.length > MAX_NEAR)
3334
- return `\`near\` is longer than ${MAX_NEAR} characters.`;
3335
- const name = input.name?.trim() ?? "";
3336
- if (name.length > MAX_TITLE2)
3337
- return `That name is longer than ${MAX_TITLE2} characters.`;
3338
- if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3339
- return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3340
- }
3341
- const v = {
3342
- url,
3343
- title: name || titleFor(url),
3344
- hours,
3345
- config: { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} }
3346
- };
3452
+ return { ok: false, error: `\`near\` is longer than ${MAX_NEAR} characters.` };
3453
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3454
+ return { ok: false, error: atCap(d) };
3455
+ const capped = await liveTaskCap(d, user);
3456
+ if (capped)
3457
+ return { ok: false, error: capped };
3458
+ const { name, hours, drop, baseline } = settings;
3459
+ const config = { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} };
3460
+ return { ok: true, pending: { url, title: name || titleFor(url), hours, config } };
3461
+ }
3462
+ async function trackPrice(d, user, input) {
3463
+ const started = await startPrice(d, user, input);
3464
+ if (!started.ok)
3465
+ return started.error;
3466
+ const { pending } = started;
3347
3467
  return {
3348
- outside: () => preview(d, v),
3349
- finish: (result) => finishPrice(d, user, v, result)
3468
+ outside: () => previewPrice(d, pending),
3469
+ finish: async (result) => said(await finishPrice(d, user, pending, result))
3350
3470
  };
3351
3471
  }
3352
- async function finishPrice(d, user, v, seen) {
3472
+ async function finishPrice(d, asked, v, seen) {
3473
+ const user = await d.store.getUser(asked.id);
3474
+ if (!user || !d.admissions.isRegistered(user.id))
3475
+ return { ok: false, error: NO_LONGER_LISTED };
3353
3476
  if (!seen.ok)
3354
- return seen.error;
3477
+ return seen;
3355
3478
  const type = d.types.price;
3356
3479
  if (!type)
3357
- return "Price tracking is not available on this bot.";
3358
- if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3359
- return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3360
- }
3480
+ return { ok: false, error: "Price tracking is not available on this bot." };
3481
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3482
+ return { ok: false, error: atCap(d) };
3483
+ const capped = await liveTaskCap(d, user);
3484
+ if (capped)
3485
+ return { ok: false, error: capped };
3361
3486
  const now = d.clock.now();
3362
3487
  const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
3363
3488
  const actor = { userId: user.id, admin: user.admin };
3364
3489
  const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
3365
3490
  const rule = v.config.baseline ?? DEFAULT_BASELINE;
3366
- return clip([
3367
- `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3368
- `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3369
- ].join(`
3370
- `));
3491
+ return {
3492
+ ok: true,
3493
+ task,
3494
+ text: clip([
3495
+ `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3496
+ `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3497
+ ].join(`
3498
+ `))
3499
+ };
3500
+ }
3501
+
3502
+ // plugins/tracker/src/tracked.ts
3503
+ var DEFAULT_LEAD_DAYS = 7;
3504
+ var MAX_LEAD_DAYS = 365;
3505
+ var MAX_EVERY = 100;
3506
+ function plural3(n, word) {
3507
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3508
+ }
3509
+ var PERIOD_UNITS = ["year", "month", "week", "day"];
3510
+ function renewalPlan(d, user, input) {
3511
+ const bad = (error) => ({ ok: false, error });
3512
+ const name = input.name.trim();
3513
+ if (name.length === 0)
3514
+ return bad("Say what renews: a subscription, a domain, a warranty.");
3515
+ if (name.length > MAX_TITLE)
3516
+ return bad(`That name is longer than ${MAX_TITLE} characters.`);
3517
+ if (!Number.isFinite(input.amount) || input.amount < 0)
3518
+ return bad("The amount has to be zero or more.");
3519
+ const currency = input.currency.trim().toUpperCase();
3520
+ if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
3521
+ return bad("The currency is a three-letter code, such as USD or EUR.");
3522
+ const note = input.note?.trim() ?? "";
3523
+ if (note.length > MAX_NOTE)
3524
+ return bad(`The note is longer than ${MAX_NOTE} characters.`);
3525
+ const unit = input.unit ?? "year";
3526
+ if (!PERIOD_UNITS.includes(unit))
3527
+ return bad("`unit` is yearly, monthly, weekly or daily.");
3528
+ const every = input.every ?? 1;
3529
+ if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3530
+ return bad(`\`every\` is a whole number from 1 to ${MAX_EVERY}.`);
3531
+ const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3532
+ if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3533
+ return bad(`\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`);
3534
+ const renews = input.renews.trim();
3535
+ const days = renews.length === DATE_LENGTH && /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, d.clock.now(), user.timeZone) : null;
3536
+ if (days === null)
3537
+ return bad("Give the date as YYYY-MM-DD, for example 2026-12-01.");
3538
+ if (days < 0)
3539
+ return bad("That date has passed: give the next renewal or expiry date.");
3540
+ return { ok: true, name, amount: input.amount, currency, note, days, schedule: { kind: "period", every, unit, anchor: renews, leadDays: lead } };
3541
+ }
3542
+ async function firstAskIfDue(d, task, owner, schedule) {
3543
+ const now = d.clock.now();
3544
+ const first = nextDue(schedule, new Date(0), { zone: owner.timeZone, preferredHour: owner.preferredHour });
3545
+ if (!first || first.getTime() > now.getTime())
3546
+ return null;
3547
+ return d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: first.toISOString(), dedupeKey: scheduledKey(task.id, first), at: now.toISOString() });
3548
+ }
3549
+ function askText(next, days, user, now) {
3550
+ const when = days === 0 ? "today" : `in ${plural3(days, "day")}`;
3551
+ if (!next)
3552
+ return "never (that schedule has no next date)";
3553
+ return Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3554
+ }
3555
+ async function createRenewal(d, user, input) {
3556
+ const type = d.types.renewal;
3557
+ if (!type)
3558
+ return { ok: false, error: "Renewals are not available on this bot." };
3559
+ const plan = renewalPlan(d, user, input);
3560
+ if (!plan.ok)
3561
+ return plan;
3562
+ const capped = await liveTaskCap(d, user);
3563
+ if (capped)
3564
+ return { ok: false, error: capped };
3565
+ const { name, currency, note, schedule } = plan;
3566
+ const now = d.clock.now();
3567
+ const config = { amount: plan.amount, currency, ...note ? { note } : {} };
3568
+ const actor = { userId: user.id, admin: user.admin };
3569
+ let created;
3570
+ try {
3571
+ created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3572
+ } catch (err) {
3573
+ if (err instanceof ScheduleError)
3574
+ return { ok: false, error: `That schedule does not work: ${err.message}.` };
3575
+ throw err;
3576
+ }
3577
+ const { task } = created;
3578
+ const next = await firstAskIfDue(d, task, user, schedule) ?? created.next;
3579
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
3580
+ return {
3581
+ ok: true,
3582
+ task,
3583
+ text: clip([
3584
+ `Renewal \`${task.id}\` set: ${name}, ${money(plan.amount, currency)}, ${cadence}.`,
3585
+ `First ask: ${askText(next, plan.days, user, now)}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3586
+ ].join(`
3587
+ `))
3588
+ };
3589
+ }
3590
+ async function addRenewal(d, user, input) {
3591
+ return said(await createRenewal(d, user, input));
3371
3592
  }
3372
3593
  async function decideRenewal(d, user, input) {
3373
3594
  const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
@@ -3517,7 +3738,7 @@ ${body}` : body);
3517
3738
  },
3518
3739
  {
3519
3740
  name: "register",
3520
- build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(64)),
3741
+ build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(MAX_ZONE)),
3521
3742
  handle: (interaction) => run(interaction, "admitted", (d, user) => {
3522
3743
  const hour = interaction.options.getInteger("hour");
3523
3744
  const zone = interaction.options.getString("zone");
@@ -3530,7 +3751,7 @@ ${body}` : body);
3530
3751
  },
3531
3752
  {
3532
3753
  name: "remind",
3533
- build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(100)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
3754
+ build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(MAX_WHEN)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
3534
3755
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3535
3756
  const when = interaction.options.getString("when");
3536
3757
  return remind(d, user, {
@@ -3542,7 +3763,7 @@ ${body}` : body);
3542
3763
  },
3543
3764
  {
3544
3765
  name: "renewal",
3545
- build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(100)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(3).setMaxLength(3)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(10)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3766
+ build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(MAX_TITLE)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(DATE_LENGTH)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3546
3767
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3547
3768
  const every = interaction.options.getInteger("every");
3548
3769
  const lead = interaction.options.getInteger("lead");
@@ -3562,7 +3783,7 @@ ${body}` : body);
3562
3783
  },
3563
3784
  {
3564
3785
  name: "price",
3565
- build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(1000)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(100)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3786
+ build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(MAX_URL)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(MAX_TITLE)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3566
3787
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3567
3788
  const name = interaction.options.getString("name");
3568
3789
  const hours = interaction.options.getInteger("hours");
@@ -3586,7 +3807,7 @@ ${body}` : body);
3586
3807
  },
3587
3808
  {
3588
3809
  name: "task",
3589
- build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3810
+ build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(MAX_WHEN))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("resume").setDescription("Resume a paused task; one paused for failed DMs goes on without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3590
3811
  handle: (interaction) => {
3591
3812
  const sub = interaction.options.getSubcommand();
3592
3813
  const taskId = interaction.options.getString("task", true);
@@ -3610,7 +3831,7 @@ ${body}` : body);
3610
3831
  case "history":
3611
3832
  return historyWithSeries(d, user, taskId);
3612
3833
  case "resume":
3613
- return resumeTask(d, user, taskId);
3834
+ return said(await resumeTask(d, user, taskId));
3614
3835
  case "share": {
3615
3836
  const pending = await prepareShare(d, user, taskId, { discordId: shareWith?.id ?? "", membership: membership ?? "unknown" });
3616
3837
  if (typeof pending === "string")
@@ -3896,6 +4117,7 @@ var MIGRATIONS = [
3896
4117
  CREATE INDEX web_sessions_expiry ON web_sessions (expires_at);
3897
4118
  `
3898
4119
  ];
4120
+ var BUSY_TIMEOUT_MS = 5000;
3899
4121
  function migrate(db) {
3900
4122
  const current = db.query("PRAGMA user_version").get().user_version;
3901
4123
  if (current > MIGRATIONS.length) {
@@ -3907,14 +4129,223 @@ function migrate(db) {
3907
4129
  db.exec(`PRAGMA user_version = ${version + 1}`);
3908
4130
  })();
3909
4131
  }
4132
+ return current;
3910
4133
  }
3911
4134
  function openDatabase(path) {
3912
4135
  const db = new Database(path, { create: true });
3913
- if (path !== ":memory:")
3914
- db.exec("PRAGMA journal_mode = WAL");
3915
- db.exec("PRAGMA busy_timeout = 5000");
3916
- migrate(db);
3917
- return db;
4136
+ try {
4137
+ if (path !== ":memory:")
4138
+ db.exec("PRAGMA journal_mode = WAL");
4139
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4140
+ db.exec("PRAGMA secure_delete = ON");
4141
+ migrate(db);
4142
+ return db;
4143
+ } catch (err) {
4144
+ db.close();
4145
+ throw err;
4146
+ }
4147
+ }
4148
+ var META_TABLE = "tracker_meta";
4149
+ var VACUUMED = "vacuumed_for_secure_delete";
4150
+ function vacuumOnce(db, path) {
4151
+ if (path === ":memory:")
4152
+ return { ran: false };
4153
+ try {
4154
+ db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4155
+ if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4156
+ return { ran: false };
4157
+ db.exec("PRAGMA busy_timeout = 0");
4158
+ try {
4159
+ db.exec("VACUUM");
4160
+ const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4161
+ if (r && Number(r.busy) !== 0)
4162
+ throw new Error("the write-ahead log is busy");
4163
+ } finally {
4164
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4165
+ }
4166
+ db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4167
+ return { ran: true };
4168
+ } catch (err) {
4169
+ return { ran: false, error: err instanceof Error ? err.message : String(err) };
4170
+ }
4171
+ }
4172
+
4173
+ // plugins/tracker/src/roster.ts
4174
+ var FORGOTTEN = "forgotten";
4175
+ function historyNames(kind, detail, id) {
4176
+ switch (kind) {
4177
+ case "recipient_invited":
4178
+ case "recipient_accepted":
4179
+ case "recipient_declined":
4180
+ case "recipient_opted_out":
4181
+ return detail === id;
4182
+ case "blocked":
4183
+ return detail === `${id} 24h` || detail === `${id} permanent`;
4184
+ case "recipient_removed":
4185
+ return detail.startsWith(`${id}: `);
4186
+ case "paused":
4187
+ return detail.startsWith(`delivery to {${id}} paused:`);
4188
+ default:
4189
+ return false;
4190
+ }
4191
+ }
4192
+ function eventNames(type, text, id) {
4193
+ return type === "delivered" && text.startsWith(`${id} `);
4194
+ }
4195
+ function errorForms(id) {
4196
+ return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
4197
+ }
4198
+ function errorNames(text, id) {
4199
+ return text !== null && errorForms(id).test(text);
4200
+ }
4201
+ function redactError(text, id) {
4202
+ return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
4203
+ }
4204
+
4205
+ class Roster {
4206
+ db;
4207
+ constructor(db) {
4208
+ this.db = db;
4209
+ }
4210
+ people() {
4211
+ const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
4212
+ a.admitted_by, a.registered_at, h.paused_at, h.failures
4213
+ FROM users u
4214
+ LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
4215
+ LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
4216
+ ORDER BY u.seq`).all();
4217
+ const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
4218
+ const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
4219
+ WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
4220
+ return rows.map((r) => {
4221
+ const id = `u${Number(r.seq)}`;
4222
+ const counts = { active: 0, paused: 0, done: 0, archived: 0 };
4223
+ for (const o of owned)
4224
+ if (o.owner_id === id && o.status in counts)
4225
+ counts[o.status] = Number(o.n);
4226
+ const str = (v) => v === null || v === undefined ? null : String(v);
4227
+ return {
4228
+ id,
4229
+ discordId: str(r.discord_id),
4230
+ displayName: str(r.display_name),
4231
+ timeZone: String(r.time_zone),
4232
+ preferredHour: Number(r.preferred_hour),
4233
+ admin: Number(r.admin) === 1,
4234
+ createdAt: String(r.created_at),
4235
+ admittedBy: str(r.admitted_by),
4236
+ registeredAt: str(r.registered_at),
4237
+ deliveryPausedAt: str(r.paused_at),
4238
+ failures: Number(r.failures ?? 0),
4239
+ owned: counts,
4240
+ receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
4241
+ };
4242
+ });
4243
+ }
4244
+ checkpoint() {
4245
+ this.db.exec("PRAGMA busy_timeout = 0");
4246
+ try {
4247
+ const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4248
+ return !r || Number(r.busy) === 0;
4249
+ } finally {
4250
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4251
+ }
4252
+ }
4253
+ othersNamed(taskId, userId, name) {
4254
+ const ids = new Set;
4255
+ for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
4256
+ ids.add(r.user_id);
4257
+ for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
4258
+ const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
4259
+ if (id)
4260
+ ids.add(id);
4261
+ }
4262
+ ids.delete(userId);
4263
+ const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
4264
+ return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
4265
+ }
4266
+ admins() {
4267
+ return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
4268
+ }
4269
+ activeBlocks(now) {
4270
+ const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
4271
+ WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
4272
+ return rows.map((r) => ({
4273
+ id: `b${Number(r.seq)}`,
4274
+ ownerId: String(r.owner_id),
4275
+ recipientId: String(r.recipient_id),
4276
+ expiresAt: r.expires_at === null ? null : String(r.expires_at),
4277
+ createdAt: String(r.created_at)
4278
+ }));
4279
+ }
4280
+ erase(userId) {
4281
+ const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
4282
+ if (seq === undefined)
4283
+ throw new Error(`no user ${userId}`);
4284
+ return this.db.transaction(() => {
4285
+ const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
4286
+ if (!person)
4287
+ throw new Error(`no user ${userId}`);
4288
+ const rows = {};
4289
+ const del = (table, where, ...params) => {
4290
+ rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
4291
+ };
4292
+ const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
4293
+ const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
4294
+ const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
4295
+ const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
4296
+ const recipientOf = new Set([
4297
+ ...pausedFor,
4298
+ ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
4299
+ ...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
4300
+ ]);
4301
+ del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
4302
+ del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
4303
+ del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4304
+ del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
4305
+ del("series", `task_id IN (${THEIR_TASKS})`, userId);
4306
+ del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4307
+ del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4308
+ del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
4309
+ del("tasks", "owner_id = ?", userId);
4310
+ const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
4311
+ for (const r of containing("task_events", "kind, detail", "detail")) {
4312
+ if (historyNames(String(r.kind), String(r.detail), userId))
4313
+ del("task_events", "seq = ?", Number(r.seq));
4314
+ }
4315
+ for (const r of containing("events", "type, text", "text")) {
4316
+ if (eventNames(String(r.type), String(r.text), userId))
4317
+ del("events", "seq = ?", Number(r.seq));
4318
+ else if (errorNames(String(r.text), userId))
4319
+ this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
4320
+ }
4321
+ for (const r of containing("occurrences", "error", "error")) {
4322
+ if (errorNames(String(r.error), userId))
4323
+ this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
4324
+ }
4325
+ if (person.display_name !== null) {
4326
+ const legacy = `delivery to ${person.display_name} paused:`;
4327
+ const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
4328
+ for (const r of rowsNaming) {
4329
+ if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
4330
+ continue;
4331
+ if (this.othersNamed(r.task_id, userId, person.display_name))
4332
+ continue;
4333
+ del("task_events", "seq = ?", r.seq);
4334
+ }
4335
+ }
4336
+ del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
4337
+ this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
4338
+ del("admissions", "user_id = ?", userId);
4339
+ this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
4340
+ del("delivery_health", "user_id = ?", userId);
4341
+ del("web_sessions", "user_id = ?", userId);
4342
+ del("web_login_tokens", "user_id = ?", userId);
4343
+ del("users", "seq = ?", Number(seq));
4344
+ const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4345
+ const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
4346
+ return { rows, freed };
4347
+ })();
4348
+ }
3918
4349
  }
3919
4350
 
3920
4351
  // plugins/tracker/src/store.ts
@@ -4237,6 +4668,99 @@ class SqliteStore {
4237
4668
  }
4238
4669
  }
4239
4670
 
4671
+ // plugins/tracker/src/admin.ts
4672
+ var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
4673
+ var NO_SUCH_BLOCK = "There is no such block in force.";
4674
+ var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
4675
+ var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
4676
+ var TICK_WAIT_MS = 20000;
4677
+ var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
4678
+ var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
4679
+ function isConfiguredAdmin(d, u) {
4680
+ return u.discordId !== null && d.configuredAdmins.has(u.discordId);
4681
+ }
4682
+ function ticksSettled(d) {
4683
+ return d.lanes.idle(TICK_WAIT_MS);
4684
+ }
4685
+ function isPersonId(id) {
4686
+ return /^u[1-9][0-9]{0,15}$/.test(id);
4687
+ }
4688
+ async function person(d, id) {
4689
+ return isPersonId(id) ? d.store.getUser(id) : null;
4690
+ }
4691
+ function who(u) {
4692
+ return u.displayName ?? u.discordId ?? u.id;
4693
+ }
4694
+ async function setAdminFlag(d, admin, targetId, flag) {
4695
+ if (!admin.admin)
4696
+ return { ok: false, error: NOT_ADMIN };
4697
+ const target = await person(d, targetId);
4698
+ if (!target)
4699
+ return { ok: false, error: NO_SUCH_PERSON };
4700
+ if (target.admin === flag)
4701
+ return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
4702
+ if (!flag && d.roster.admins() <= 1)
4703
+ return { ok: false, error: LAST_ADMIN };
4704
+ if (!flag && isConfiguredAdmin(d, target))
4705
+ return { ok: false, error: CONFIGURED_ADMIN };
4706
+ await d.store.updateUser(target.id, { admin: flag });
4707
+ d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
4708
+ return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
4709
+ }
4710
+ async function resumeDelivery(d, admin, targetId) {
4711
+ if (!admin.admin)
4712
+ return { ok: false, error: NOT_ADMIN };
4713
+ const target = await person(d, targetId);
4714
+ if (!target)
4715
+ return { ok: false, error: NO_SUCH_PERSON };
4716
+ const resumed = await d.health.resume(target.id, d.clock.now());
4717
+ if (!resumed)
4718
+ return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
4719
+ return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
4720
+ }
4721
+ async function liftDeclineBlock(d, admin, blockId) {
4722
+ if (!admin.admin)
4723
+ return { ok: false, error: NOT_ADMIN };
4724
+ const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
4725
+ const now = d.clock.now();
4726
+ if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
4727
+ return { ok: false, error: NO_SUCH_BLOCK };
4728
+ await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
4729
+ const owner = await d.store.getUser(block.ownerId);
4730
+ const recipient = await d.store.getUser(block.recipientId);
4731
+ return {
4732
+ ok: true,
4733
+ text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
4734
+ };
4735
+ }
4736
+ async function forgetPerson(d, actor, targetId) {
4737
+ if (actor.id !== targetId && !actor.admin)
4738
+ return { ok: false, error: NOT_ADMIN };
4739
+ const target = await person(d, targetId);
4740
+ if (!target)
4741
+ return { ok: false, error: NO_SUCH_PERSON };
4742
+ if (target.admin && d.roster.admins() <= 1)
4743
+ return { ok: false, error: LAST_ADMIN };
4744
+ if (actor.id !== target.id && isConfiguredAdmin(d, target))
4745
+ return { ok: false, error: CONFIGURED_ADMIN };
4746
+ const now = d.clock.now();
4747
+ const erased = await d.health.exclusive(async () => {
4748
+ if (d.lanes.busy())
4749
+ return null;
4750
+ const e = d.roster.erase(target.id);
4751
+ for (const taskId of e.freed)
4752
+ await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
4753
+ return e;
4754
+ });
4755
+ if (erased === null)
4756
+ return { ok: false, error: BUSY };
4757
+ if (!d.roster.checkpoint())
4758
+ d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
4759
+ const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
4760
+ d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
4761
+ return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
4762
+ }
4763
+
4240
4764
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
4241
4765
  var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
4242
4766
  box-sizing: border-box;
@@ -5405,6 +5929,8 @@ var LAYOUT_CSS = `
5405
5929
  .tr-main { max-width: 60rem; margin: 0 auto; padding: var(--rb-space-4); }
5406
5930
  .tr-main > section { margin-bottom: var(--rb-space-5); }
5407
5931
  .tr-stack { display: grid; gap: var(--rb-space-3); max-width: 28rem; }
5932
+ .tr-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-2); }
5933
+ .tr-row form { margin: 0; }
5408
5934
  .tr-foot { margin-top: var(--rb-space-5); }
5409
5935
  `;
5410
5936
  var STYLESHEET = `${THEME_CSS}
@@ -5461,6 +5987,7 @@ function page(f) {
5461
5987
  <span class="rb-muted">${f.signedIn.name}</span>
5462
5988
  <a class="rb-link" href="${f.base}/">My tasks</a>
5463
5989
  <a class="rb-link" href="${f.base}/settings">Settings</a>
5990
+ ${f.signedIn.admin ? html`<a class="rb-link" href="${f.base}/admin">Admin</a>` : null}
5464
5991
  <form method="post" action="${f.base}/logout"><input type="hidden" name="csrf" value="${f.signedIn.csrf}"><button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Sign out</button></form>
5465
5992
  </nav>` : null;
5466
5993
  return html`<!doctype html>
@@ -5506,13 +6033,35 @@ function readCookie(request, name) {
5506
6033
  function cookie(name, value, o) {
5507
6034
  return `${name}=${value}; Path=${o.base}/; Max-Age=${o.maxAgeSeconds}; HttpOnly; Secure; SameSite=${o.sameSite}`;
5508
6035
  }
6036
+ async function readBody(request, max) {
6037
+ const declared = request.headers.get("content-length");
6038
+ if (declared !== null && !(/^[0-9]{1,12}$/.test(declared) && Number(declared) <= max))
6039
+ return null;
6040
+ if (!request.body)
6041
+ return "";
6042
+ const reader = request.body.getReader();
6043
+ const chunks = [];
6044
+ let size = 0;
6045
+ for (;; ) {
6046
+ const { done, value } = await reader.read();
6047
+ if (done)
6048
+ break;
6049
+ size += value.byteLength;
6050
+ if (size > max) {
6051
+ await reader.cancel().catch(() => {});
6052
+ return null;
6053
+ }
6054
+ chunks.push(value);
6055
+ }
6056
+ return Buffer.concat(chunks).toString("utf8");
6057
+ }
5509
6058
 
5510
6059
  // plugins/tracker/src/web/pages.ts
5511
6060
  function name3(u) {
5512
6061
  return u.displayName ?? "you";
5513
6062
  }
5514
6063
  function framed(v, title, body) {
5515
- return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf }, body });
6064
+ return page({ base: v.base, title, signedIn: { name: name3(v.user), csrf: v.csrf, admin: v.user.admin }, body });
5516
6065
  }
5517
6066
  function signInHelpPage(base, note) {
5518
6067
  return page({
@@ -5551,30 +6100,39 @@ function cadence(entry, viewer, now) {
5551
6100
  return "once";
5552
6101
  return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
5553
6102
  }
5554
- function tasksPage(v, data, now) {
6103
+ function newLinks(v) {
6104
+ return html`<p class="tr-row">
6105
+ <a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
6106
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
6107
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
6108
+ </p>`;
6109
+ }
6110
+ function tasksPage(v, data, now, flash = null) {
5555
6111
  const rows = data.entries.map((e) => html`<tr>
5556
6112
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
5557
6113
  <td>${e.from ? name3(e.from) : "you"}</td>
5558
6114
  <td>${when2(e, v.user, now)}</td>
5559
6115
  <td>${cadence(e, v.user, now)}</td>
5560
6116
  </tr>`);
5561
- const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks. <code>/remind</code> in Discord sets one.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
6117
+ const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5562
6118
  <thead><tr><th scope="col">Task</th><th scope="col">From</th><th scope="col">Next</th><th scope="col">Repeats</th></tr></thead>
5563
6119
  <tbody>${rows}</tbody>
5564
6120
  </table></div>`;
5565
6121
  const paused = data.paused.length === 0 ? null : html`<section>
5566
6122
  <h2>Paused</h2>
5567
- <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; <code>/task resume ${p.task.id}</code> goes on without them.` : null}</li>`)}</ul>
6123
+ <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; Resume on its page goes on without them.` : null}</li>`)}</ul>
5568
6124
  </section>`;
5569
6125
  return framed(v, "My tasks", html`<section>
5570
6126
  <h1>My tasks</h1>
6127
+ ${flash}
5571
6128
  <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
6129
+ ${newLinks(v)}
5572
6130
  ${active}
5573
6131
  </section>
5574
6132
  ${paused}
5575
6133
  <p class="rb-muted tr-foot">${ADMIN_DISCLOSURE}</p>`);
5576
6134
  }
5577
- function historyPage(v, h) {
6135
+ function historyPage(v, h, extra = {}) {
5578
6136
  const runs = h.runs.length === 0 ? html`<p class="rb-muted">No runs yet.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5579
6137
  <thead><tr><th scope="col">Due</th><th scope="col">Status</th><th scope="col">Answers</th></tr></thead>
5580
6138
  <tbody>${h.runs.map((r) => html`<tr>
@@ -5586,7 +6144,10 @@ function historyPage(v, h) {
5586
6144
  return framed(v, h.task.title, html`<section>
5587
6145
  <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
5588
6146
  <h1>${h.task.title}</h1>
5589
- <p>${h.task.type}, ${h.task.status}, ${h.cadence}. Next: ${h.next ?? "nothing scheduled"}.</p>
6147
+ ${extra.flash ?? null}
6148
+ ${extra.owner ? html`<p class="rb-muted">Owned by ${extra.owner}; you can see it, not change it.</p>` : null}
6149
+ <p>${h.task.type}, ${h.task.status === "archived" ? "deleted" : h.task.status}, ${h.cadence}. Next: ${h.task.status === "active" ? h.next ?? "nothing scheduled" : "nothing, while it is not active"}.</p>
6150
+ ${extra.controls ?? null}
5590
6151
  </section>
5591
6152
  <section>
5592
6153
  <h2>Runs</h2>
@@ -5614,11 +6175,15 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
5614
6175
  </div>
5615
6176
  <div class="rb-field">
5616
6177
  <label class="rb-label" for="zone">Time zone</label>
5617
- <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="64" required${form.error ? html` aria-invalid="true"` : null}>
6178
+ <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="${MAX_ZONE}" required${form.error ? html` aria-invalid="true"` : null}>
5618
6179
  <p class="rb-field__help">An IANA name, such as America/New_York or Europe/London.</p>
5619
6180
  </div>
5620
6181
  <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
5621
6182
  </form>
6183
+ </section>
6184
+ <section>
6185
+ <h2>Forget me</h2>
6186
+ <p>Delete everything the tracker holds about you, for good. <a class="rb-link" href="${v.base}/forget">Forget me</a></p>
5622
6187
  </section>`);
5623
6188
  }
5624
6189
  function notFoundPage(base, v) {
@@ -5629,6 +6194,849 @@ function errorPage(base, title, message) {
5629
6194
  return page({ base, title, body: html`<section class="rb-card"><h1>${title}</h1><p>${message}</p></section>` });
5630
6195
  }
5631
6196
 
6197
+ // plugins/tracker/src/web/editor-pages.ts
6198
+ var EDITOR_TYPES = ["reminder", "renewal", "price"];
6199
+ var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
6200
+ var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
6201
+ var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
6202
+ var PRICE_SETTINGS = [
6203
+ { name: "name", label: "Name", kind: "text", maxlength: MAX_TITLE, help: "Leave it empty to use the page's address." },
6204
+ { name: "hours", label: "Hours between checks", kind: "number", min: 1, max: MAX_POLL_HOURS, help: `1 to ${MAX_POLL_HOURS}; 12 when empty.` },
6205
+ { name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
6206
+ { name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
6207
+ ];
6208
+ function fieldsFor(type, mode) {
6209
+ switch (type) {
6210
+ case "reminder":
6211
+ return [
6212
+ { name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
6213
+ {
6214
+ name: "when",
6215
+ label: "When",
6216
+ kind: "text",
6217
+ maxlength: MAX_WHEN,
6218
+ help: mode === "new" ? 'For example "in 20 minutes", "tomorrow 9am", "fri at 17:30". A repeating one with none starts today at your preferred hour.' : "Leave it empty to keep the time it has, unless you change how it repeats."
6219
+ },
6220
+ { name: "repeat", label: "Repeat", kind: "select", options: REPEAT_OPTIONS }
6221
+ ];
6222
+ case "renewal":
6223
+ return [
6224
+ { name: "name", label: "What renews", kind: "text", required: true, maxlength: MAX_TITLE, help: "A subscription, a domain, a warranty." },
6225
+ { name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true },
6226
+ { name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
6227
+ { name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
6228
+ { name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
6229
+ { name: "every", label: "Every how many of those", kind: "number", min: 1, max: MAX_EVERY, help: "1 when empty." },
6230
+ { name: "lead", label: "Days before the date to ask", kind: "number", min: 0, max: MAX_LEAD_DAYS, help: "7 when empty." },
6231
+ { name: "note", label: "Note", kind: "text", maxlength: MAX_NOTE, help: "Carried on every ask: where to cancel, which account." }
6232
+ ];
6233
+ case "price":
6234
+ return mode === "edit" ? PRICE_SETTINGS : [
6235
+ { name: "url", label: "Product page", kind: "url", required: true, maxlength: MAX_URL },
6236
+ ...PRICE_SETTINGS,
6237
+ { name: "near", label: "Words just before the price", kind: "text", maxlength: MAX_NEAR, help: "Only if I cannot find the price on my own." }
6238
+ ];
6239
+ }
6240
+ }
6241
+ function prose(text) {
6242
+ return html`${text.split("`").map((part, i) => i % 2 === 1 ? html`<code>${part}</code>` : part)}`;
6243
+ }
6244
+ function field(f, values) {
6245
+ const value = values[f.name] ?? "";
6246
+ const help = f.help ? html`<p class="rb-field__help">${f.help}</p>` : null;
6247
+ if (f.kind === "select") {
6248
+ return html`<div class="rb-field">
6249
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6250
+ <select class="rb-select" id="${f.name}" name="${f.name}">${(f.options ?? []).map(([v, label]) => html`<option value="${v}"${v === value ? html` selected` : null}>${label}</option>`)}</select>
6251
+ ${help}
6252
+ </div>`;
6253
+ }
6254
+ const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
6255
+ return html`<div class="rb-field">
6256
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6257
+ <input class="rb-input" id="${f.name}" name="${f.name}" type="${type}" value="${value}"${f.maxlength ? html` maxlength="${f.maxlength}"` : null}${f.min !== undefined ? html` min="${f.min}"` : null}${f.max !== undefined ? html` max="${f.max}"` : null}${f.decimal ? html` inputmode="decimal"` : null}${f.required ? html` required` : null}>
6258
+ ${help}
6259
+ </div>`;
6260
+ }
6261
+ function alert(error) {
6262
+ return error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class="rb-alert__title">Not saved</p><p>${prose(error)}</p></div>` : null;
6263
+ }
6264
+ function taskHref(v, id, action = "") {
6265
+ return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6266
+ }
6267
+ var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
6268
+ function newTaskPage(v, type, values, error) {
6269
+ const intro = type === "price" ? html`<p class="rb-muted">I read the page once now; nothing is made unless I find a price in it.</p>` : null;
6270
+ return framed(v, `New ${NOUN[type]}`, html`<section>
6271
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
6272
+ <h1>New ${NOUN[type]}</h1>
6273
+ ${intro}
6274
+ ${alert(error)}
6275
+ <form method="post" action="${v.base}/new/${type}" class="tr-stack">
6276
+ <input type="hidden" name="csrf" value="${v.csrf}">
6277
+ ${fieldsFor(type, "new").map((f) => field(f, values))}
6278
+ <div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
6279
+ </form>
6280
+ </section>`);
6281
+ }
6282
+ function editTaskPage(v, task, values, o) {
6283
+ const type = task.type;
6284
+ return framed(v, `Edit ${task.title}`, html`<section>
6285
+ <p><a class="rb-link" href="${taskHref(v, task.id)}">Back to the task</a></p>
6286
+ <h1>Edit ${task.title}</h1>
6287
+ <p class="rb-muted">Now: ${o.now}.${task.status === "paused" ? " Paused." : ""}</p>
6288
+ ${o.page ? html`<p class="rb-muted">Page: ${o.page}. To track another page, make a new price tracker.</p>` : null}
6289
+ ${alert(o.error)}
6290
+ <form method="post" action="${taskHref(v, task.id, "edit")}" class="tr-stack">
6291
+ <input type="hidden" name="csrf" value="${v.csrf}">
6292
+ ${fieldsFor(type, "edit").map((f) => field(f, values))}
6293
+ <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
6294
+ </form>
6295
+ </section>`);
6296
+ }
6297
+ function confirmDeletePage(v, task) {
6298
+ return framed(v, `Delete ${task.title}`, html`<section class="rb-card">
6299
+ <h1>Delete ${task.title}?</h1>
6300
+ <p>It stops at once and leaves your lists; nothing more is sent about it. Its history is kept, and an admin of this tracker can still see it.</p>
6301
+ <form method="post" action="${taskHref(v, task.id, "delete")}" class="tr-row">
6302
+ <input type="hidden" name="csrf" value="${v.csrf}">
6303
+ <input type="hidden" name="confirm" value="yes">
6304
+ <button class="rb-btn rb-btn--danger" type="submit">Delete</button>
6305
+ <a class="rb-btn rb-btn--ghost" href="${taskHref(v, task.id)}">Keep it</a>
6306
+ </form>
6307
+ </section>`);
6308
+ }
6309
+ function post(v, task, action, label, style) {
6310
+ return html`<form method="post" action="${taskHref(v, task.id, action)}"><input type="hidden" name="csrf" value="${v.csrf}"><button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
6311
+ }
6312
+ function ownerControls(v, task) {
6313
+ if (task.status === "archived")
6314
+ return null;
6315
+ const live = task.status === "active" || task.status === "paused";
6316
+ const editor = EDITOR_TYPES.includes(task.type);
6317
+ return html`<div class="tr-row">
6318
+ ${live && editor ? html`<a class="rb-btn rb-btn--primary rb-btn--sm" href="${taskHref(v, task.id, "edit")}">Edit</a>` : null}
6319
+ ${task.status === "active" ? post(v, task, "pause", "Pause", "rb-btn--ghost") : null}
6320
+ ${task.status === "paused" ? post(v, task, "resume", "Resume", "rb-btn--accent") : null}
6321
+ ${post(v, task, "delete", "Delete", "rb-btn--danger")}
6322
+ </div>`;
6323
+ }
6324
+ var NOTICES = {
6325
+ created: "Created.",
6326
+ saved: "Saved.",
6327
+ paused: "Paused: nothing is sent until you resume it.",
6328
+ resumed: "Resumed.",
6329
+ deleted: "Deleted. Its history is kept."
6330
+ };
6331
+ function notice(key, error) {
6332
+ if (error)
6333
+ return html`<div class="rb-alert rb-alert--danger" role="alert"><p>${prose(error)}</p></div>`;
6334
+ const text = key !== null && Object.hasOwn(NOTICES, key) ? NOTICES[key] : undefined;
6335
+ return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6336
+ }
6337
+
6338
+ // plugins/tracker/src/web/admin-pages.ts
6339
+ var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
6340
+ var CONFIRM_WORD = "forget";
6341
+ function nameOf(p, fallback = "someone") {
6342
+ if (!p)
6343
+ return fallback;
6344
+ return p.displayName ?? p.id;
6345
+ }
6346
+ function unmention(text) {
6347
+ return text.replace(/<@([0-9]{17,20})>/g, "$1");
6348
+ }
6349
+ function flash(result) {
6350
+ if (!result)
6351
+ return null;
6352
+ const text = prose(unmention(result.text));
6353
+ return result.ok ? html`<div class="rb-alert rb-alert--success" role="status"><p>${text}</p></div>` : html`<div class="rb-alert rb-alert--danger" role="alert"><p>${text}</p></div>`;
6354
+ }
6355
+ function hidden(v) {
6356
+ return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
6357
+ }
6358
+ function adminNav(v) {
6359
+ return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a></p>`;
6360
+ }
6361
+ function personHref(v, id, action = "") {
6362
+ return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6363
+ }
6364
+ function admittedBy(p, names) {
6365
+ if (p.admittedBy === null)
6366
+ return "the configuration";
6367
+ if (p.admittedBy === FORGOTTEN)
6368
+ return "an admin since forgotten";
6369
+ return names.get(p.admittedBy) ?? p.admittedBy;
6370
+ }
6371
+ function delivery(p, viewer, now) {
6372
+ if (p.deliveryPausedAt)
6373
+ return `paused since ${formatInstant(p.deliveryPausedAt, viewer.timeZone, now)}`;
6374
+ return p.failures > 0 ? `on (${p.failures} failed in a row)` : "on";
6375
+ }
6376
+ function counts(p) {
6377
+ const o = p.owned;
6378
+ return `${o.active} active, ${o.paused} paused, ${o.done} done, ${o.archived} deleted; receives ${p.receiving}`;
6379
+ }
6380
+ function adminPage(v, data, o = {}) {
6381
+ const names = new Map(data.people.map((p) => [p.id, nameOf(p)]));
6382
+ const rows = data.people.map((p) => html`<tr>
6383
+ <td><a class="rb-link" href="${personHref(v, p.id)}">${nameOf(p)}</a>${p.admin ? html` <span class="rb-badge">admin</span>` : null}</td>
6384
+ <td>${p.registeredAt ? "registered" : "admitted, not registered"}</td>
6385
+ <td>${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</td>
6386
+ <td>${delivery(p, v.user, data.now)}</td>
6387
+ <td>${counts(p)}</td>
6388
+ </tr>`);
6389
+ const blocks = data.blocks.length === 0 ? html`<p class="rb-muted">No decline blocks are in force.</p>` : html`<ul>${data.blocks.map((b) => html`<li class="tr-row">${names.get(b.ownerId) ?? b.ownerId} may not invite ${names.get(b.recipientId) ?? b.recipientId} ${b.expiresAt ? `until ${formatInstant(b.expiresAt, v.user.timeZone, data.now)}` : "until an admin lifts it"}
6390
+ <form method="post" action="${v.base}/admin/blocks/${encodeURIComponent(b.id)}/lift">${hidden(v)}<button class="rb-btn rb-btn--ghost rb-btn--sm" type="submit">Lift</button></form></li>`)}</ul>`;
6391
+ return framed(v, "Admin", html`<section>
6392
+ <h1>People</h1>
6393
+ ${adminNav(v)}
6394
+ ${flash(o.result ?? null)}
6395
+ <div class="rb-table-scroll"><table class="rb-table">
6396
+ <thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
6397
+ <tbody>${rows}</tbody>
6398
+ </table></div>
6399
+ <p class="rb-muted">Admitted by: ${data.people.map((p) => `${nameOf(p)} -- ${admittedBy(p, names)}`).join("; ")}.</p>
6400
+ </section>
6401
+ <section>
6402
+ <h2>Allow a person</h2>
6403
+ <form method="post" action="${v.base}/admin/allow" class="tr-stack">
6404
+ ${hidden(v)}
6405
+ <div class="rb-field">
6406
+ <label class="rb-label" for="discord_id">Discord user id</label>
6407
+ <input class="rb-input" id="discord_id" name="discord_id" value="${o.allow ?? ""}" maxlength="20" inputmode="numeric" required>
6408
+ <p class="rb-field__help">What <code>/allow</code> does: they can then <code>/register</code>.</p>
6409
+ </div>
6410
+ <div><button class="rb-btn rb-btn--primary" type="submit">Allow</button></div>
6411
+ </form>
6412
+ </section>
6413
+ <section>
6414
+ <h2>Decline blocks</h2>
6415
+ ${blocks}
6416
+ </section>`);
6417
+ }
6418
+ function adminTasksPage(v, tasks) {
6419
+ const rows = tasks.map(({ task, owner, receiving }) => html`<tr>
6420
+ <td><a class="rb-link" href="${taskHref(v, task.id)}">${task.title}</a></td>
6421
+ <td>${task.type}</td>
6422
+ <td>${owner}</td>
6423
+ <td>${task.status === "archived" ? "deleted" : task.status}</td>
6424
+ <td>${receiving}</td>
6425
+ </tr>`);
6426
+ return framed(v, "All tasks", html`<section>
6427
+ <h1>All tasks</h1>
6428
+ ${adminNav(v)}
6429
+ ${tasks.length === 0 ? html`<p class="rb-muted">There are no tasks.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
6430
+ <thead><tr><th scope="col">Task</th><th scope="col">Type</th><th scope="col">Owner</th><th scope="col">Status</th><th scope="col">Recipients</th></tr></thead>
6431
+ <tbody>${rows}</tbody>
6432
+ </table></div>`}
6433
+ </section>`);
6434
+ }
6435
+ function post2(v, p, action, label, style) {
6436
+ return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
6437
+ }
6438
+ function personPage(v, p, tasks, now, result = null, configured = false) {
6439
+ return framed(v, nameOf(p), html`<section>
6440
+ ${adminNav(v)}
6441
+ <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
6442
+ ${flash(result)}
6443
+ <ul>
6444
+ <li>Discord id: ${p.discordId ?? "none"}</li>
6445
+ <li>${p.registeredAt ? `Registered ${formatInstant(p.registeredAt, v.user.timeZone, now)}` : "Admitted, not registered yet"}</li>
6446
+ <li>Zone and hour: ${p.timeZone}, ${String(p.preferredHour).padStart(2, "0")}:00</li>
6447
+ <li>Delivery: ${delivery(p, v.user, now)}</li>
6448
+ <li>Tasks: ${counts(p)}</li>
6449
+ </ul>
6450
+ ${configured ? html`<p class="rb-muted">${CONFIGURED_NOTE}</p>` : null}
6451
+ <div class="tr-row">
6452
+ ${p.admin ? configured ? null : post2(v, p, "revoke", "Revoke admin", "rb-btn--ghost") : post2(v, p, "grant", "Make admin", "rb-btn--ghost")}
6453
+ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn--accent") : null}
6454
+ ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
6455
+ </div>
6456
+ </section>
6457
+ <section>
6458
+ <h2>Their tasks</h2>
6459
+ ${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
6460
+ </section>`);
6461
+ }
6462
+ var WHAT_GOES = "every task (deleted ones too) with its runs, replies and history; your replies, answers and history on other people's tasks, " + "and your place on the tasks shared with you; decline blocks either way; delivery pauses; your settings and sign-in sessions; and your place on the list.";
6463
+ function forgetPage(v, note) {
6464
+ return framed(v, "Forget me", html`<section class="rb-card">
6465
+ <h1>Forget me</h1>
6466
+ <p>This deletes everything the tracker holds about you, at once and for good: ${WHAT_GOES}</p>
6467
+ <p>Nothing is kept or archived. Messages the bot already sent you stay in your Discord DMs, where only you can delete them. To use the tracker again later, an admin has to <code>/allow</code> you again, and you start from nothing.</p>
6468
+ ${note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${note}</p></div>` : null}
6469
+ <form method="post" action="${v.base}/forget">${hidden(v)}<button class="rb-btn rb-btn--danger" type="submit">Continue</button></form>
6470
+ </section>`);
6471
+ }
6472
+ function confirmForgetPage(v, o) {
6473
+ const title = o.self ? "Delete everything about you?" : `Remove ${o.name} from the tracker?`;
6474
+ const what = o.self ? html`<p>Everything the tracker holds about you is deleted: ${WHAT_GOES} You are signed out.</p>` : html`<p>Everything the tracker holds about ${o.name} is deleted, as forget-me deletes it: their tasks, their replies and history, blocks, pauses, settings and sessions. They are signed out and must be allowed again to come back.</p>`;
6475
+ return framed(v, o.self ? "Forget me" : `Remove ${o.name}`, html`<section class="rb-card">
6476
+ <h1>${title}</h1>
6477
+ ${what}
6478
+ ${o.note ? html`<div class="rb-alert rb-alert--warning" role="note"><p>${o.note}</p></div>` : null}
6479
+ ${o.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p>${o.error}</p></div>` : null}
6480
+ <form method="post" action="${o.action}" class="tr-stack">
6481
+ ${hidden(v)}
6482
+ <input type="hidden" name="confirm" value="yes">
6483
+ <div class="rb-field">
6484
+ <label class="rb-label" for="word">Type ${CONFIRM_WORD} to confirm</label>
6485
+ <input class="rb-input" id="word" name="word" maxlength="20" autocomplete="off" required>
6486
+ </div>
6487
+ <div class="tr-row"><button class="rb-btn rb-btn--danger" type="submit">${o.self ? "Delete everything" : "Remove"}</button>
6488
+ <a class="rb-btn rb-btn--ghost" href="${o.self ? `${v.base}/settings` : `${v.base}/admin`}">Keep</a></div>
6489
+ </form>
6490
+ </section>`);
6491
+ }
6492
+
6493
+ // plugins/tracker/src/web/admin.ts
6494
+ function unknownPage(base) {
6495
+ return htmlResponse(notFoundPage(base), 404);
6496
+ }
6497
+ function fresh(a, admin, fn) {
6498
+ return a.queue(async () => {
6499
+ const me = await a.d.store.getUser(a.v.user.id);
6500
+ if (!me || admin && !me.admin)
6501
+ return null;
6502
+ return fn(me);
6503
+ });
6504
+ }
6505
+ function said2(done) {
6506
+ return done.ok ? { ok: true, text: done.text } : { ok: false, text: done.error };
6507
+ }
6508
+ function confirmed(form) {
6509
+ if (form.get("confirm") !== "yes")
6510
+ return "ask";
6511
+ return (form.get("word") ?? "").trim().toLowerCase() === CONFIRM_WORD ? "yes" : "wrong";
6512
+ }
6513
+ var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
6514
+ async function peoplePage(a, result, status, allow) {
6515
+ const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
6516
+ return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
6517
+ }
6518
+ async function onePerson(a, id, result = null) {
6519
+ const p = a.d.roster.people().find((x) => x.id === id);
6520
+ if (!p)
6521
+ return htmlResponse(notFoundPage(a.v.base, a.v), 404);
6522
+ const tasks = await a.d.store.listTasks({ ownerId: p.id });
6523
+ return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p)), result && !result.ok ? 400 : 200);
6524
+ }
6525
+ async function adminGet(a, r) {
6526
+ if (r.kind === "admin")
6527
+ return peoplePage(a, null, 200);
6528
+ if (r.kind === "admin-person")
6529
+ return onePerson(a, r.id);
6530
+ const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
6531
+ const rows = [];
6532
+ for (const task of await a.d.store.listTasks()) {
6533
+ const receiving = (await a.d.store.listRecipients(task.id)).filter((x) => x.state === "accepted").length;
6534
+ rows.push({ task, owner: people.get(task.ownerId) ?? task.ownerId, receiving });
6535
+ }
6536
+ return htmlResponse(adminTasksPage(a.v, rows));
6537
+ }
6538
+ async function adminPost(a, r, form) {
6539
+ const base = a.v.base;
6540
+ if (r.kind === "admin-allow") {
6541
+ const raw = (form.get("discord_id") ?? "").trim();
6542
+ if (!/^[0-9]{17,20}$/.test(raw))
6543
+ return peoplePage(a, { ok: false, text: "That is not a Discord user id (17 to 20 digits)." }, 400, raw.slice(0, 20));
6544
+ const membership = await a.memberOf(raw);
6545
+ const text = await fresh(a, true, (me) => allowPerson(a.d, me, { discordId: raw, bot: false, membership }));
6546
+ if (text === null)
6547
+ return unknownPage(base);
6548
+ const ok = text.startsWith("Allowed") || text.includes("already on the list");
6549
+ return peoplePage(a, { ok, text }, ok ? 200 : 400);
6550
+ }
6551
+ if (r.kind === "admin-lift") {
6552
+ const done = await fresh(a, true, (me) => liftDeclineBlock(a.d, me, r.id));
6553
+ if (done === null)
6554
+ return unknownPage(base);
6555
+ return peoplePage(a, said2(done), done.ok ? 200 : 400);
6556
+ }
6557
+ if (r.kind !== "admin-act")
6558
+ return unknownPage(base);
6559
+ if (r.action === "forget") {
6560
+ const target = a.d.roster.people().find((x) => x.id === r.id);
6561
+ if (!target)
6562
+ return htmlResponse(notFoundPage(base, a.v), 404);
6563
+ const step = confirmed(form);
6564
+ const self = target.id === a.v.user.id;
6565
+ const note = self && isConfiguredAdmin(a.d, target) ? { note: CONFIGURED_SELF } : {};
6566
+ const page = (error) => confirmForgetPage(a.v, { action: personHref(a.v, target.id, "forget"), self, name: nameOf(target), ...note, ...error ? { error } : {} });
6567
+ if (step === "ask")
6568
+ return htmlResponse(page());
6569
+ if (step === "wrong")
6570
+ return htmlResponse(page(WRONG_WORD), 400);
6571
+ if (!await ticksSettled(a.d))
6572
+ return htmlResponse(page(BUSY), 503);
6573
+ const done = await fresh(a, true, (me) => forgetPerson(a.d, me, target.id));
6574
+ if (done === null)
6575
+ return unknownPage(base);
6576
+ if (!done.ok)
6577
+ return htmlResponse(page(done.error), done.error === NO_SUCH_PERSON ? 404 : done.error === BUSY ? 503 : 400);
6578
+ if (self)
6579
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6580
+ return peoplePage(a, said2(done), 200);
6581
+ }
6582
+ const act = r.action === "grant" ? (me) => setAdminFlag(a.d, me, r.id, true) : r.action === "revoke" ? (me) => setAdminFlag(a.d, me, r.id, false) : (me) => resumeDelivery(a.d, me, r.id);
6583
+ const done = await fresh(a, true, act);
6584
+ if (done === null)
6585
+ return unknownPage(base);
6586
+ if (!done.ok && done.error === NO_SUCH_PERSON)
6587
+ return htmlResponse(notFoundPage(base, a.v), 404);
6588
+ const me = await a.d.store.getUser(a.v.user.id);
6589
+ if (!me?.admin)
6590
+ return redirect(`${base}/`);
6591
+ return onePerson(a, r.id, said2(done));
6592
+ }
6593
+ async function forgetRoute(a, method, form) {
6594
+ const base = a.v.base;
6595
+ const note = isConfiguredAdmin(a.d, a.v.user) ? CONFIGURED_SELF : undefined;
6596
+ if (method === "GET" || !form)
6597
+ return htmlResponse(forgetPage(a.v, note));
6598
+ const page = (error) => confirmForgetPage(a.v, { action: `${base}/forget`, self: true, name: nameOf(a.v.user), ...note ? { note } : {}, ...error ? { error } : {} });
6599
+ const step = confirmed(form);
6600
+ if (step === "ask")
6601
+ return htmlResponse(page());
6602
+ if (step === "wrong")
6603
+ return htmlResponse(page(WRONG_WORD), 400);
6604
+ if (!await ticksSettled(a.d))
6605
+ return htmlResponse(page(BUSY), 503);
6606
+ const done = await fresh(a, false, (me) => forgetPerson(a.d, me, me.id));
6607
+ if (done === null)
6608
+ return redirect(`${base}/signin`, [a.signOutCookie]);
6609
+ if (!done.ok)
6610
+ return htmlResponse(page(done.error), done.error === BUSY ? 503 : 400);
6611
+ return redirect(`${base}/signin?forgotten=1`, [a.signOutCookie]);
6612
+ }
6613
+
6614
+ // plugins/tracker/src/edit.ts
6615
+ async function owned(d, user, taskId, type) {
6616
+ const task = await ownLiveTask(d, user, taskId);
6617
+ if (!task || task.type !== type)
6618
+ return NO_SUCH_TASK;
6619
+ if (!editable(task))
6620
+ return FINISHED;
6621
+ return task;
6622
+ }
6623
+ function same(a, b) {
6624
+ return JSON.stringify(a) === JSON.stringify(b);
6625
+ }
6626
+ async function applyEdit(d, task, user, patch) {
6627
+ const fields = Object.keys(patch);
6628
+ if (fields.length === 0)
6629
+ return task;
6630
+ const at = d.clock.now().toISOString();
6631
+ const updated = await d.store.updateTask(task.id, { ...patch, at });
6632
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
6633
+ return updated;
6634
+ }
6635
+ async function nextRun(d, task) {
6636
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
6637
+ return queued[0] ?? null;
6638
+ }
6639
+ function savedText(task, user, next, now, what) {
6640
+ const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
6641
+ const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
6642
+ return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
6643
+ }
6644
+ var blank = (v) => v === undefined || v.trim() === "";
6645
+ async function editReminder(d, user, taskId, input) {
6646
+ const task = await owned(d, user, taskId, "reminder");
6647
+ if (typeof task === "string")
6648
+ return { ok: false, error: task };
6649
+ const config = task.config ?? {};
6650
+ const text = blank(input.text) ? String(config.text ?? "") : input.text;
6651
+ const repeat = input.repeat ?? repeatOf(task.schedule);
6652
+ const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
6653
+ if (!plan.ok)
6654
+ return plan;
6655
+ let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
6656
+ if (!same(plan.schedule, task.schedule))
6657
+ updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
6658
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
6659
+ }
6660
+ var DAY_MS3 = 24 * 60 * 60 * 1000;
6661
+ function periodDateFrom(s, date, owner) {
6662
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
6663
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
6664
+ for (let i = 0;i < 1000; i++) {
6665
+ const due = nextDue(s, after, ctx);
6666
+ if (!due)
6667
+ return null;
6668
+ const on = periodDate(s, due, owner.timeZone);
6669
+ if (on >= date)
6670
+ return on;
6671
+ after = due;
6672
+ }
6673
+ return null;
6674
+ }
6675
+ function renewalDate(task, owner, now) {
6676
+ const s = task.schedule;
6677
+ if (s?.kind !== "period")
6678
+ return "";
6679
+ const w = wallClock(now, owner.timeZone);
6680
+ const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
6681
+ if (s.anchor >= today)
6682
+ return s.anchor;
6683
+ return periodDateFrom(s, today, owner) ?? s.anchor;
6684
+ }
6685
+ async function askedAbout(d, task, owner, date) {
6686
+ const old = task.schedule;
6687
+ if (old?.kind !== "period")
6688
+ return false;
6689
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
6690
+ return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
6691
+ }
6692
+ async function editRenewal(d, user, taskId, input) {
6693
+ const task = await owned(d, user, taskId, "renewal");
6694
+ if (typeof task === "string")
6695
+ return { ok: false, error: task };
6696
+ if (task.schedule?.kind !== "period")
6697
+ return { ok: false, error: FINISHED };
6698
+ const old = task.schedule;
6699
+ const now = d.clock.now();
6700
+ const config = task.config;
6701
+ const state = task.state;
6702
+ const carried = state && typeof state.amount === "number" ? state.amount : null;
6703
+ const note = input.note ?? config.note;
6704
+ const plan = renewalPlan(d, user, {
6705
+ name: blank(input.name) ? task.title : input.name,
6706
+ amount: input.amount ?? carried ?? config.amount,
6707
+ currency: blank(input.currency) ? config.currency : input.currency,
6708
+ renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
6709
+ every: input.every ?? old.every,
6710
+ unit: input.unit ?? old.unit,
6711
+ lead: input.lead ?? old.leadDays ?? 0,
6712
+ ...note !== undefined ? { note } : {}
6713
+ });
6714
+ if (!plan.ok)
6715
+ return plan;
6716
+ const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
6717
+ const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
6718
+ const problems = scheduleProblems(schedule);
6719
+ if (problems.length > 0)
6720
+ return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
6721
+ const patch = {};
6722
+ if (plan.name !== task.title)
6723
+ patch.title = plan.name;
6724
+ const newAmount = plan.amount !== (carried ?? config.amount);
6725
+ const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
6726
+ if (!same(nextConfig, config))
6727
+ patch.config = nextConfig;
6728
+ if (newAmount && carried !== null)
6729
+ patch.state = { ...state, amount: plan.amount };
6730
+ let updated = await applyEdit(d, task, user, patch);
6731
+ let next = await nextRun(d, updated);
6732
+ if (!same(schedule, old)) {
6733
+ const asked = await askedAbout(d, task, user, schedule.anchor);
6734
+ updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
6735
+ next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
6736
+ }
6737
+ const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
6738
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
6739
+ return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
6740
+ }
6741
+ async function editPrice(d, user, taskId, input) {
6742
+ const task = await owned(d, user, taskId, "price");
6743
+ if (typeof task === "string")
6744
+ return { ok: false, error: task };
6745
+ const config = task.config;
6746
+ const schedule = task.schedule;
6747
+ const plan = priceSettingsPlan({
6748
+ name: input.name ?? task.title,
6749
+ hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
6750
+ drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
6751
+ baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
6752
+ });
6753
+ if (!plan.ok)
6754
+ return plan;
6755
+ const patch = {};
6756
+ const title = plan.name || titleFor(config.url);
6757
+ if (title !== task.title)
6758
+ patch.title = title;
6759
+ const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
6760
+ if (!same(nextConfig, config))
6761
+ patch.config = nextConfig;
6762
+ let updated = await applyEdit(d, task, user, patch);
6763
+ if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
6764
+ const next = { ...schedule, every: plan.hours, unit: "hour" };
6765
+ updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
6766
+ }
6767
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
6768
+ }
6769
+
6770
+ // plugins/tracker/src/web/editor.ts
6771
+ var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
6772
+ var GONE = NO_LONGER_LISTED;
6773
+ function asViewer(e, fn) {
6774
+ return e.queue(async () => {
6775
+ const user = await e.d.store.getUser(e.v.user.id);
6776
+ return user ? fn(user) : { ok: false, error: GONE };
6777
+ });
6778
+ }
6779
+ var str2 = (form, name) => form.get(name) ?? "";
6780
+ var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6781
+ function int(form, name) {
6782
+ const raw = str2(form, name).trim();
6783
+ if (raw === "")
6784
+ return;
6785
+ return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
6786
+ }
6787
+ function decimal(form, name) {
6788
+ const raw = str2(form, name).trim();
6789
+ if (raw === "")
6790
+ return;
6791
+ return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
6792
+ }
6793
+ function typed(type, mode, form) {
6794
+ return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
6795
+ }
6796
+ function reminderInput(form) {
6797
+ const when = opt(form, "when");
6798
+ return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
6799
+ }
6800
+ function renewalInput(form) {
6801
+ const every = int(form, "every");
6802
+ const lead = int(form, "lead");
6803
+ const unit = opt(form, "unit");
6804
+ const note = opt(form, "note");
6805
+ return {
6806
+ name: str2(form, "name"),
6807
+ amount: decimal(form, "amount") ?? Number.NaN,
6808
+ currency: str2(form, "currency"),
6809
+ renews: str2(form, "renews"),
6810
+ ...every !== undefined ? { every } : {},
6811
+ ...lead !== undefined ? { lead } : {},
6812
+ ...unit !== undefined ? { unit } : {},
6813
+ ...note !== undefined ? { note } : {}
6814
+ };
6815
+ }
6816
+ function priceSettings(form) {
6817
+ const name = opt(form, "name");
6818
+ const hours = int(form, "hours");
6819
+ const drop = decimal(form, "drop");
6820
+ const baseline = opt(form, "baseline");
6821
+ return {
6822
+ ...name !== undefined ? { name } : {},
6823
+ ...hours !== undefined ? { hours } : {},
6824
+ ...drop !== undefined ? { drop } : {},
6825
+ ...baseline !== undefined ? { baseline } : {}
6826
+ };
6827
+ }
6828
+ function reminderEdit(form) {
6829
+ const text = opt(form, "text");
6830
+ const when = opt(form, "when");
6831
+ const repeat = opt(form, "repeat");
6832
+ return { ...text !== undefined ? { text } : {}, ...when !== undefined ? { when } : {}, ...repeat !== undefined ? { repeat } : {} };
6833
+ }
6834
+ function renewalEdit(form) {
6835
+ const name = opt(form, "name");
6836
+ const amount = decimal(form, "amount");
6837
+ const currency = opt(form, "currency");
6838
+ const renews = opt(form, "renews");
6839
+ const every = int(form, "every");
6840
+ const lead = int(form, "lead");
6841
+ const unit = opt(form, "unit");
6842
+ return {
6843
+ ...name !== undefined ? { name } : {},
6844
+ ...amount !== undefined ? { amount } : {},
6845
+ ...currency !== undefined ? { currency } : {},
6846
+ ...renews !== undefined ? { renews } : {},
6847
+ ...every !== undefined ? { every } : {},
6848
+ ...lead !== undefined ? { lead } : {},
6849
+ ...unit !== undefined ? { unit } : {},
6850
+ ...form.has("note") ? { note: str2(form, "note") } : {}
6851
+ };
6852
+ }
6853
+ function priceEdit(form) {
6854
+ const { name: _name, ...rest } = priceSettings(form);
6855
+ return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
6856
+ }
6857
+ var DEFAULTS = {
6858
+ reminder: { repeat: "none" },
6859
+ renewal: { unit: "year", every: "1", lead: "7" },
6860
+ price: { hours: "12", drop: "10", baseline: "last" }
6861
+ };
6862
+ function newGet(e, type) {
6863
+ return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
6864
+ }
6865
+ async function newPost(e, type, form) {
6866
+ let made;
6867
+ if (type === "reminder")
6868
+ made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
6869
+ else if (type === "renewal")
6870
+ made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
6871
+ else {
6872
+ const near = opt(form, "near");
6873
+ const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
6874
+ const who = e.v.user.id;
6875
+ if (e.reading.has(who))
6876
+ made = { ok: false, error: READING };
6877
+ else {
6878
+ e.reading.add(who);
6879
+ try {
6880
+ const started = await asViewer(e, (u) => startPrice(e.d, u, input));
6881
+ if (!started.ok)
6882
+ made = started;
6883
+ else {
6884
+ const { pending } = started;
6885
+ const seen = await previewPrice(e.d, pending);
6886
+ made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
6887
+ }
6888
+ } finally {
6889
+ e.reading.delete(who);
6890
+ }
6891
+ }
6892
+ }
6893
+ if (!made.ok)
6894
+ return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
6895
+ return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
6896
+ }
6897
+ async function taskPage(e, id, done, error) {
6898
+ const view = await loadHistory(e.d, e.v.user, id);
6899
+ if (!view)
6900
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6901
+ const own = view.task.ownerId === e.v.user.id;
6902
+ const controls = own ? ownerControls(e.v, view.task) : null;
6903
+ const owner = own ? null : (await e.d.store.getUser(view.task.ownerId))?.displayName ?? view.task.ownerId;
6904
+ return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error), owner }), error ? 400 : 200);
6905
+ }
6906
+ function current(d, user, task) {
6907
+ const now = d.clock.now();
6908
+ if (task.type === "reminder")
6909
+ return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
6910
+ if (task.type === "renewal" && task.schedule?.kind === "period") {
6911
+ const s = task.schedule;
6912
+ const c = task.config;
6913
+ const carried = task.state?.amount;
6914
+ const renews = renewalDate(task, user, now);
6915
+ return {
6916
+ name: task.title,
6917
+ amount: String(typeof carried === "number" ? carried : c.amount),
6918
+ currency: c.currency,
6919
+ renews,
6920
+ unit: s.unit,
6921
+ every: String(s.every),
6922
+ lead: String(s.leadDays ?? 0),
6923
+ note: c.note ?? ""
6924
+ };
6925
+ }
6926
+ if (task.type !== "price")
6927
+ return {};
6928
+ const c = task.config;
6929
+ const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
6930
+ return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
6931
+ }
6932
+ function editPage(e, task, values, error) {
6933
+ const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
6934
+ const page = task.type === "price" ? String(task.config.url) : undefined;
6935
+ return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
6936
+ }
6937
+ async function editableTask(e, id) {
6938
+ const task = await ownLiveTask(e.d, e.v.user, id);
6939
+ return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
6940
+ }
6941
+ async function editGet(e, id) {
6942
+ const task = await editableTask(e, id);
6943
+ if (!task)
6944
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6945
+ if (task.status !== "active" && task.status !== "paused")
6946
+ return redirect(taskHref(e.v, task.id));
6947
+ return htmlResponse(editPage(e, task, current(e.d, e.v.user, task)));
6948
+ }
6949
+ async function editPost(e, id, form) {
6950
+ const task = await editableTask(e, id);
6951
+ if (!task)
6952
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6953
+ const saved = task.type === "reminder" ? await asViewer(e, (u) => editReminder(e.d, u, task.id, reminderEdit(form))) : task.type === "renewal" ? await asViewer(e, (u) => editRenewal(e.d, u, task.id, renewalEdit(form))) : await asViewer(e, (u) => editPrice(e.d, u, task.id, priceEdit(form)));
6954
+ if (saved.ok)
6955
+ return redirect(`${taskHref(e.v, task.id)}?done=saved`);
6956
+ if (saved.error === NO_SUCH_TASK)
6957
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6958
+ return htmlResponse(editPage(e, task, typed(task.type, "edit", form), saved.error), 400);
6959
+ }
6960
+ async function actionPost(e, id, action, form) {
6961
+ const task = await ownLiveTask(e.d, e.v.user, id);
6962
+ if (!task)
6963
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6964
+ if (action === "delete" && str2(form, "confirm") !== "yes")
6965
+ return htmlResponse(confirmDeletePage(e.v, task));
6966
+ const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
6967
+ const done = await asViewer(e, (u) => act(e.d, u, task.id));
6968
+ if (!done.ok)
6969
+ return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
6970
+ if (action === "delete")
6971
+ return redirect(`${e.v.base}/?done=deleted`);
6972
+ return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
6973
+ }
6974
+
6975
+ // plugins/tracker/src/web/routes.ts
6976
+ var TASK_PATH = /^\/tasks\/([^/]+)$/;
6977
+ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
6978
+ var NEW_PATH = /^\/new\/([a-z]+)$/;
6979
+ var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
6980
+ var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
6981
+ var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
6982
+ function isAdminRoute(r) {
6983
+ return r.kind.startsWith("admin");
6984
+ }
6985
+ function segment(raw) {
6986
+ try {
6987
+ return decodeURIComponent(raw ?? "");
6988
+ } catch {
6989
+ return "";
6990
+ }
6991
+ }
6992
+ function route(path) {
6993
+ if (path === "/")
6994
+ return { kind: "tasks" };
6995
+ if (path === "/settings")
6996
+ return { kind: "settings" };
6997
+ if (path === "/logout")
6998
+ return { kind: "logout" };
6999
+ if (path === "/forget")
7000
+ return { kind: "forget" };
7001
+ if (path === "/admin")
7002
+ return { kind: "admin" };
7003
+ if (path === "/admin/tasks")
7004
+ return { kind: "admin-tasks" };
7005
+ if (path === "/admin/allow")
7006
+ return { kind: "admin-allow" };
7007
+ const personAct = PERSON_ACTION.exec(path);
7008
+ if (personAct)
7009
+ return { kind: "admin-act", id: segment(personAct[1]), action: personAct[2] };
7010
+ const onePerson = PERSON_PATH.exec(path);
7011
+ if (onePerson)
7012
+ return { kind: "admin-person", id: segment(onePerson[1]) };
7013
+ const lift = BLOCK_LIFT.exec(path);
7014
+ if (lift)
7015
+ return { kind: "admin-lift", id: segment(lift[1]) };
7016
+ const created = NEW_PATH.exec(path);
7017
+ if (created) {
7018
+ const type = created[1];
7019
+ return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
7020
+ }
7021
+ const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
7022
+ if (!one)
7023
+ return null;
7024
+ const id = segment(one[1]);
7025
+ const action = one[2];
7026
+ if (action === undefined)
7027
+ return { kind: "task", id };
7028
+ if (action === "edit")
7029
+ return { kind: "edit", id };
7030
+ return { kind: "act", id, action };
7031
+ }
7032
+ function methodsOf(r) {
7033
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift")
7034
+ return "POST";
7035
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget")
7036
+ return "GET, POST";
7037
+ return "GET";
7038
+ }
7039
+
5632
7040
  // plugins/tracker/src/web/sessions.ts
5633
7041
  var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5634
7042
 
@@ -5670,14 +7078,18 @@ var MEMBER_CHECK_TIMEOUT_MS = 3000;
5670
7078
  var MEMBER_RETRY_MS = 60 * 1000;
5671
7079
  var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
5672
7080
  var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
5673
- var MAX_FORM_BYTES = 8 * 1024;
5674
- var TASK_PATH = /^\/tasks\/([^/]+)$/;
7081
+ var MAX_FORM_BYTES = 32 * 1024;
5675
7082
  function plain(status, text, extra = {}) {
5676
7083
  return new Response(text, {
5677
7084
  status,
5678
7085
  headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...extra }
5679
7086
  });
5680
7087
  }
7088
+ function signInNote(q) {
7089
+ if (q.get("forgotten") === "1")
7090
+ return "Everything the tracker held about you is deleted, and you are signed out.";
7091
+ return q.get("out") === "1" ? "You are signed out." : undefined;
7092
+ }
5681
7093
  async function lookupWithin(lookup, ms) {
5682
7094
  let timer;
5683
7095
  const late = new Promise((resolve) => {
@@ -5696,6 +7108,7 @@ function createWebHandler(w) {
5696
7108
  const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
5697
7109
  const inFlight = new Map;
5698
7110
  const failedAt = new Map;
7111
+ const reading = new Set;
5699
7112
  async function authenticate(d, request) {
5700
7113
  const id = readCookie(request, SESSION_COOKIE);
5701
7114
  if (!id)
@@ -5761,14 +7174,17 @@ function createWebHandler(w) {
5761
7174
  d.sessions.deleteForUser(user.id);
5762
7175
  return { kind: "signed-out", note: RECHECK_FAILED };
5763
7176
  }
7177
+ async function memberOf(discordId) {
7178
+ if (w.guildId === null)
7179
+ return "not-checked";
7180
+ return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
7181
+ }
5764
7182
  async function readForm(request) {
5765
7183
  const type = request.headers.get("content-type") ?? "";
5766
7184
  if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
5767
7185
  return null;
5768
- const body = await request.text();
5769
- if (Buffer.byteLength(body) > MAX_FORM_BYTES)
5770
- return null;
5771
- return new URLSearchParams(body);
7186
+ const body = await readBody(request, MAX_FORM_BYTES);
7187
+ return body === null ? null : new URLSearchParams(body);
5772
7188
  }
5773
7189
  function forbidden() {
5774
7190
  return htmlResponse(errorPage(base, "Refused", "That form was stale or came from somewhere else. Go back, reload the page and try again."), 403);
@@ -5806,7 +7222,7 @@ function createWebHandler(w) {
5806
7222
  let error = null;
5807
7223
  if (!/^([0-9]|1[0-9]|2[0-3])$/.test(hour))
5808
7224
  error = "The preferred hour must be a whole hour from 0 to 23.";
5809
- else if (zone === "" || zone.length > 64)
7225
+ else if (zone === "" || zone.length > MAX_ZONE)
5810
7226
  error = "Give a time zone, such as America/New_York.";
5811
7227
  else {
5812
7228
  error = await w.queue(async () => {
@@ -5841,48 +7257,73 @@ function createWebHandler(w) {
5841
7257
  });
5842
7258
  }
5843
7259
  if (path === "/signin")
5844
- return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
7260
+ return only("GET") ?? htmlResponse(signInHelpPage(base, signInNote(url.searchParams)));
5845
7261
  if (path === "/login")
5846
7262
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
5847
- const task = TASK_PATH.exec(path);
5848
- const route = path === "/" ? "tasks" : path === "/settings" ? "settings" : path === "/logout" ? "logout" : task ? "history" : null;
5849
- if (route === null)
7263
+ const r = route(path);
7264
+ if (r === null)
5850
7265
  return htmlResponse(notFoundPage(base), 404);
5851
- const wrong = route === "logout" ? only("POST") : route === "settings" ? null : only("GET");
5852
- if (wrong)
5853
- return wrong;
7266
+ const allowed = methodsOf(r);
7267
+ const admin = isAdminRoute(r);
7268
+ if (!admin && !allowed.split(", ").includes(method))
7269
+ return plain(405, "Method not allowed", { Allow: allowed });
5854
7270
  const auth = await authenticate(d, request);
5855
7271
  if (auth.kind === "signed-out")
5856
7272
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
5857
7273
  if (auth.kind !== "ok")
5858
7274
  return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
7275
+ if (admin && !auth.user.admin)
7276
+ return unknownPage(base);
7277
+ if (admin && !allowed.split(", ").includes(method))
7278
+ return plain(405, "Method not allowed", { Allow: allowed });
5859
7279
  const v = { base, user: auth.user, csrf: auth.session.csrf };
7280
+ const e = { d, v, queue: w.queue, reading };
7281
+ const a = { d, v, queue: w.queue, memberOf: (id) => memberOf(id), signOutCookie: clear(SESSION_COOKIE) };
5860
7282
  if (method === "POST") {
5861
7283
  const form = await readForm(request);
5862
7284
  if (!form)
5863
7285
  return plain(400, "Bad request");
5864
7286
  if (!safeEqual(auth.session.csrf, form.get("csrf") ?? ""))
5865
7287
  return forbidden();
5866
- if (route === "logout") {
5867
- d.sessions.delete(auth.id);
5868
- return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
7288
+ switch (r.kind) {
7289
+ case "logout":
7290
+ d.sessions.delete(auth.id);
7291
+ return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
7292
+ case "settings":
7293
+ return settingsPost(d, v, form);
7294
+ case "new":
7295
+ return newPost(e, r.type, form);
7296
+ case "edit":
7297
+ return editPost(e, r.id, form);
7298
+ case "act":
7299
+ return actionPost(e, r.id, r.action, form);
7300
+ case "forget":
7301
+ return forgetRoute(a, method, form);
7302
+ case "admin-allow":
7303
+ case "admin-act":
7304
+ case "admin-lift":
7305
+ return adminPost(a, r, form);
7306
+ default:
7307
+ return plain(405, "Method not allowed", { Allow: allowed });
5869
7308
  }
5870
- return settingsPost(d, v, form);
5871
7309
  }
5872
7310
  const now = d.clock.now();
5873
- switch (route) {
7311
+ const done = url.searchParams.get("done");
7312
+ switch (r.kind) {
5874
7313
  case "tasks":
5875
- return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now));
5876
- case "history": {
5877
- let id;
5878
- try {
5879
- id = decodeURIComponent(task?.[1] ?? "");
5880
- } catch {
5881
- return htmlResponse(notFoundPage(base, v), 404);
5882
- }
5883
- const view = await loadHistory(d, auth.user, id);
5884
- return view ? htmlResponse(historyPage(v, view)) : htmlResponse(notFoundPage(base, v), 404);
5885
- }
7314
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
7315
+ case "task":
7316
+ return taskPage(e, r.id, done);
7317
+ case "edit":
7318
+ return editGet(e, r.id);
7319
+ case "new":
7320
+ return newGet(e, r.type);
7321
+ case "forget":
7322
+ return forgetRoute(a, method, null);
7323
+ case "admin":
7324
+ case "admin-tasks":
7325
+ case "admin-person":
7326
+ return adminGet(a, r);
5886
7327
  default:
5887
7328
  return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
5888
7329
  }
@@ -5975,6 +7416,28 @@ function createPlugin(host, options = {}) {
5975
7416
  if (failed > 0)
5976
7417
  host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
5977
7418
  }
7419
+ const running = new Set;
7420
+ const tracked = (p) => {
7421
+ running.add(p);
7422
+ const done = () => void running.delete(p);
7423
+ p.then(done, done);
7424
+ return p;
7425
+ };
7426
+ const lanes = {
7427
+ busy: () => running.size > 0,
7428
+ async idle(ms) {
7429
+ const until = Date.now() + ms;
7430
+ while (running.size > 0) {
7431
+ const left = until - Date.now();
7432
+ if (left <= 0)
7433
+ return false;
7434
+ let timer;
7435
+ await Promise.race([Promise.allSettled([...running]), new Promise((r) => timer = setTimeout(r, left))]);
7436
+ clearTimeout(timer);
7437
+ }
7438
+ return true;
7439
+ }
7440
+ };
5978
7441
  let discordClient = null;
5979
7442
  const capture = (interaction) => {
5980
7443
  const client = interaction.client;
@@ -6018,6 +7481,9 @@ function createPlugin(host, options = {}) {
6018
7481
  const openedStore = new SqliteStore(opened);
6019
7482
  const openedClaims = new ClaimStore(opened);
6020
7483
  try {
7484
+ const vacuum = vacuumOnce(opened, path);
7485
+ if ("error" in vacuum)
7486
+ host.log.warn(`could not vacuum the tracker's database (${vacuum.error}); trying again at the next start`);
6021
7487
  const granted = await seedAdmins(openedStore, adminIds, clock.now());
6022
7488
  const admissions = new Admissions(opened);
6023
7489
  for (const id of adminIds) {
@@ -6059,7 +7525,11 @@ function createPlugin(host, options = {}) {
6059
7525
  log: host.log,
6060
7526
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
6061
7527
  logins: new LoginLinks(opened),
6062
- sessions: new Sessions(opened)
7528
+ sessions: new Sessions(opened),
7529
+ roster: new Roster(opened),
7530
+ configuredAdmins: new Set(adminIds),
7531
+ lanes,
7532
+ webEditor: webOrigin !== null
6063
7533
  };
6064
7534
  if (guildId === null)
6065
7535
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
@@ -6079,11 +7549,11 @@ function createPlugin(host, options = {}) {
6079
7549
  ticks: [
6080
7550
  {
6081
7551
  name: "notify",
6082
- run: (signal) => tick("notify", signal)
7552
+ run: (signal) => tracked(tick("notify", signal))
6083
7553
  },
6084
7554
  {
6085
7555
  name: "poll",
6086
- run: (signal) => tick("poll", signal)
7556
+ run: (signal) => tracked(tick("poll", signal))
6087
7557
  }
6088
7558
  ],
6089
7559
  async http(request, info) {