@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.
- package/README.md +130 -12
- package/dist/plugin.js +1700 -230
- 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 ?
|
|
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
|
|
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
|
-
|
|
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
|
|
2549
|
-
const
|
|
2550
|
-
|
|
2551
|
-
|
|
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
|
|
2559
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
const
|
|
2598
|
-
const
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
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
|
-
|
|
3083
|
-
|
|
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/
|
|
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
|
-
|
|
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}`,
|
|
3436
|
+
return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE);
|
|
3315
3437
|
}
|
|
3316
|
-
async function
|
|
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
|
|
3324
|
-
if (!
|
|
3325
|
-
return
|
|
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
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
if (
|
|
3339
|
-
return
|
|
3340
|
-
}
|
|
3341
|
-
const
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
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: () =>
|
|
3349
|
-
finish: (result) => finishPrice(d, user,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
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
|
|
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
|
|
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(", ")};
|
|
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
|
-
|
|
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="
|
|
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 =
|
|
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
|
|
5769
|
-
|
|
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 >
|
|
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
|
|
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
|
|
5848
|
-
|
|
5849
|
-
if (route === null)
|
|
7263
|
+
const r = route(path);
|
|
7264
|
+
if (r === null)
|
|
5850
7265
|
return htmlResponse(notFoundPage(base), 404);
|
|
5851
|
-
const
|
|
5852
|
-
|
|
5853
|
-
|
|
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
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
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
|
-
|
|
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 "
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
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) {
|