@rackbops/plugin-tracker 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +51 -11
  2. package/dist/plugin.js +1696 -297
  3. package/package.json +3 -3
package/dist/plugin.js CHANGED
@@ -67,6 +67,11 @@ async function visibleHistory(store, actor, taskId) {
67
67
  return null;
68
68
  return store.listTaskEvents(taskId);
69
69
  }
70
+ async function visibleSeries(store, actor, taskId, filter) {
71
+ if (!await visibleTask(store, actor, taskId))
72
+ return null;
73
+ return store.listSeries(taskId, filter);
74
+ }
70
75
 
71
76
  // node_modules/@rackbops/docket-core/dist/capabilities.js
72
77
  var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
@@ -1545,13 +1550,13 @@ var price = defineTaskType({
1545
1550
  ]
1546
1551
  },
1547
1552
  async run(ctx) {
1548
- const { fetch } = ctx.ports;
1549
- if (!fetch)
1553
+ const { fetch: fetch2 } = ctx.ports;
1554
+ if (!fetch2)
1550
1555
  throw new Error("the price type needs the Fetch port; the host configured none");
1551
1556
  const state = stateOf(ctx.state);
1552
1557
  let body;
1553
1558
  try {
1554
- const response = await fetch.get(ctx.config.url);
1559
+ const response = await fetch2.get(ctx.config.url);
1555
1560
  if (response.status !== 200)
1556
1561
  return miss(ctx, state, `HTTP ${response.status}`);
1557
1562
  body = response.body;
@@ -2063,6 +2068,285 @@ function resumedNotice(r) {
2063
2068
  return `I could not DM you ${PAUSE_AFTER} times in a row, so your reminders were paused. ${what} ` + "Check that you accept DMs from members of a server we share, or they will pause again.";
2064
2069
  }
2065
2070
 
2071
+ // plugins/tracker/src/fetch.ts
2072
+ import { lookup } from "dns/promises";
2073
+ import { isIP } from "net";
2074
+
2075
+ // plugins/tracker/src/page.ts
2076
+ var MAX_BLOCKS = 20;
2077
+ var MAX_METAS = 300;
2078
+ var MAX_TAG = 1000;
2079
+ var escapeLt = (s) => s.replaceAll("<", "\\u003c");
2080
+ function jsonBody(body) {
2081
+ const trimmed = body.trim();
2082
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
2083
+ return null;
2084
+ try {
2085
+ return escapeLt(JSON.stringify(JSON.parse(trimmed)));
2086
+ } catch {
2087
+ return null;
2088
+ }
2089
+ }
2090
+ function pageForExtraction(body) {
2091
+ const json = jsonBody(body);
2092
+ if (json !== null)
2093
+ return json;
2094
+ const lower = body.toLowerCase();
2095
+ const blocks = [];
2096
+ const metas = [];
2097
+ const closes = new Map;
2098
+ const nextClose = (closing, from) => {
2099
+ const known = closes.get(closing);
2100
+ if (known !== undefined && (known < 0 || known >= from))
2101
+ return known;
2102
+ const found = lower.indexOf(closing, from);
2103
+ closes.set(closing, found);
2104
+ return found;
2105
+ };
2106
+ let at = 0;
2107
+ while (at < body.length) {
2108
+ let lt = body.indexOf("<", at);
2109
+ if (lt < 0)
2110
+ break;
2111
+ const gt = body.indexOf(">", lt);
2112
+ if (gt < 0)
2113
+ break;
2114
+ lt = body.lastIndexOf("<", gt);
2115
+ const tag = body.slice(lt, gt + 1);
2116
+ const tagLower = lower.slice(lt, gt + 1);
2117
+ at = gt + 1;
2118
+ if (tagLower.startsWith("<script") || tagLower.startsWith("<style")) {
2119
+ const closing = tagLower.startsWith("<script") ? "</script" : "</style";
2120
+ const end = nextClose(closing, at);
2121
+ if (end < 0)
2122
+ continue;
2123
+ if (closing === "</script" && blocks.length < MAX_BLOCKS && /type\s*=\s*["']?application\/ld\+json/.test(tagLower.slice(0, MAX_TAG))) {
2124
+ blocks.push(`<script type="application/ld+json">${escapeLt(body.slice(at, end))}</script>`);
2125
+ }
2126
+ at = end;
2127
+ } else if (tagLower.startsWith("<meta") && tag.length <= MAX_TAG && metas.length < MAX_METAS) {
2128
+ metas.push(tag);
2129
+ }
2130
+ }
2131
+ return [...blocks, ...metas, body.replaceAll("<", " ")].join(`
2132
+ `);
2133
+ }
2134
+
2135
+ // plugins/tracker/src/fetch.ts
2136
+ var TIMEOUT_MS = 15000;
2137
+ var MAX_BYTES = 3 * 1024 * 1024;
2138
+ var MAX_REDIRECTS = 5;
2139
+ var USER_AGENT = "Mozilla/5.0 (compatible; RackbopsClerk/1.0; price tracker)";
2140
+
2141
+ class FetchRefusedError extends Error {
2142
+ name = "FetchRefusedError";
2143
+ }
2144
+ var systemResolve = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
2145
+ function v4Parts(ip) {
2146
+ const parts = ip.split(".").map(Number);
2147
+ return parts.length === 4 && parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255) ? parts : null;
2148
+ }
2149
+ function isPublicV4(ip) {
2150
+ const p = v4Parts(ip);
2151
+ if (!p)
2152
+ return false;
2153
+ const [a = 0, b = 0, c = 0] = p;
2154
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
2155
+ return false;
2156
+ if (a === 100 && b >= 64 && b <= 127)
2157
+ return false;
2158
+ if (a === 169 && b === 254)
2159
+ return false;
2160
+ if (a === 172 && b >= 16 && b <= 31)
2161
+ return false;
2162
+ if (a === 192 && b === 168)
2163
+ return false;
2164
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
2165
+ return false;
2166
+ if (a === 198 && (b === 18 || b === 19))
2167
+ return false;
2168
+ if (a === 198 && b === 51 && c === 100)
2169
+ return false;
2170
+ if (a === 203 && b === 0 && c === 113)
2171
+ return false;
2172
+ return true;
2173
+ }
2174
+ function v6Groups(ip) {
2175
+ let text = ip.toLowerCase();
2176
+ const zone = text.indexOf("%");
2177
+ if (zone >= 0)
2178
+ text = text.slice(0, zone);
2179
+ const lastColon = text.lastIndexOf(":");
2180
+ if (text.slice(lastColon + 1).includes(".")) {
2181
+ const v4 = v4Parts(text.slice(lastColon + 1));
2182
+ if (!v4)
2183
+ return null;
2184
+ const hex = (hi, lo) => (hi << 8 | lo).toString(16);
2185
+ text = `${text.slice(0, lastColon + 1)}${hex(v4[0] ?? 0, v4[1] ?? 0)}:${hex(v4[2] ?? 0, v4[3] ?? 0)}`;
2186
+ }
2187
+ const halves = text.split("::");
2188
+ if (halves.length > 2)
2189
+ return null;
2190
+ const parse = (s) => s === "" ? [] : s.split(":").map((g) => /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : Number.NaN);
2191
+ const head = parse(halves[0] ?? "");
2192
+ const rest = halves.length === 2 ? parse(halves[1] ?? "") : [];
2193
+ const known = head.length + rest.length;
2194
+ if (halves.length === 1 && known !== 8)
2195
+ return null;
2196
+ if (known > 8)
2197
+ return null;
2198
+ const groups = [...head, ...new Array(8 - known).fill(0), ...rest];
2199
+ return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 65535) ? groups : null;
2200
+ }
2201
+ function isPublicV6(ip) {
2202
+ const g = v6Groups(ip);
2203
+ if (!g)
2204
+ return false;
2205
+ const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = g;
2206
+ const embedded = `${g6 >> 8}.${g6 & 255}.${g7 >> 8}.${g7 & 255}`;
2207
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0) {
2208
+ return g5 === 65535 ? isPublicV4(embedded) : false;
2209
+ }
2210
+ if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
2211
+ return isPublicV4(embedded);
2212
+ if ((g0 & 65024) === 64512)
2213
+ return false;
2214
+ if ((g0 & 65472) === 65152)
2215
+ return false;
2216
+ if ((g0 & 65280) === 65280)
2217
+ return false;
2218
+ if (g0 === 8193 && g1 === 3512)
2219
+ return false;
2220
+ if (g0 === 8193 && g1 < 512)
2221
+ return false;
2222
+ if (g0 === 8194)
2223
+ return false;
2224
+ return (g0 & 57344) === 8192;
2225
+ }
2226
+ function isPublicAddress(ip) {
2227
+ const version = isIP(ip);
2228
+ if (version === 4)
2229
+ return isPublicV4(ip);
2230
+ if (version === 6)
2231
+ return isPublicV6(ip);
2232
+ return false;
2233
+ }
2234
+ function urlProblem(raw) {
2235
+ let url;
2236
+ try {
2237
+ url = new URL(raw);
2238
+ } catch {
2239
+ return "That is not a web address.";
2240
+ }
2241
+ if (url.protocol !== "https:" && url.protocol !== "http:")
2242
+ return "Only http and https pages can be tracked.";
2243
+ if (url.username !== "" || url.password !== "")
2244
+ return "A page address with a user name or password in it cannot be tracked.";
2245
+ if (url.port !== "")
2246
+ return "Only pages on the standard web ports can be tracked.";
2247
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2248
+ if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
2249
+ return "Only public web pages can be tracked.";
2250
+ }
2251
+ if (isIP(host) !== 0 && !isPublicAddress(host))
2252
+ return "Only public web pages can be tracked.";
2253
+ return null;
2254
+ }
2255
+ function orAbort(p, signal) {
2256
+ if (signal.aborted)
2257
+ return Promise.reject(signal.reason);
2258
+ return new Promise((resolve, reject) => {
2259
+ const onAbort = () => reject(signal.reason);
2260
+ signal.addEventListener("abort", onAbort, { once: true });
2261
+ p.then((v) => {
2262
+ signal.removeEventListener("abort", onAbort);
2263
+ resolve(v);
2264
+ }, (e) => {
2265
+ signal.removeEventListener("abort", onAbort);
2266
+ reject(e);
2267
+ });
2268
+ });
2269
+ }
2270
+ async function checkHost(url, resolve, signal) {
2271
+ const problem = urlProblem(url.href);
2272
+ if (problem)
2273
+ throw new FetchRefusedError(problem);
2274
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2275
+ let addresses;
2276
+ if (isIP(host) !== 0)
2277
+ addresses = [host];
2278
+ else {
2279
+ try {
2280
+ addresses = await orAbort(resolve(host), signal);
2281
+ } catch {
2282
+ signal.throwIfAborted();
2283
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2284
+ }
2285
+ }
2286
+ if (addresses.length === 0)
2287
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2288
+ if (!addresses.every(isPublicAddress))
2289
+ throw new FetchRefusedError(`${host} is not on the public internet`);
2290
+ }
2291
+ async function readCapped(response, max) {
2292
+ if (!response.body)
2293
+ return "";
2294
+ const reader = response.body.getReader();
2295
+ const chunks = [];
2296
+ let size = 0;
2297
+ while (size < max) {
2298
+ const { done, value } = await reader.read();
2299
+ if (done)
2300
+ break;
2301
+ const room = max - size;
2302
+ chunks.push(value.byteLength > room ? value.subarray(0, room) : value);
2303
+ size += Math.min(value.byteLength, room);
2304
+ }
2305
+ await reader.cancel().catch(() => {});
2306
+ const all = new Uint8Array(size);
2307
+ let at = 0;
2308
+ for (const c of chunks) {
2309
+ all.set(c, at);
2310
+ at += c.byteLength;
2311
+ }
2312
+ return new TextDecoder().decode(all);
2313
+ }
2314
+ function createPageFetch(o = {}) {
2315
+ const resolve = o.resolve ?? systemResolve;
2316
+ const fetchImpl = o.fetchImpl ?? fetch;
2317
+ const timeoutMs = o.timeoutMs ?? TIMEOUT_MS;
2318
+ const maxBytes = o.maxBytes ?? MAX_BYTES;
2319
+ return {
2320
+ async get(raw, headers = {}) {
2321
+ const signal = o.signal ? AbortSignal.any([o.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
2322
+ let url = new URL(raw);
2323
+ for (let hop = 0;; hop++) {
2324
+ await checkHost(url, resolve, signal);
2325
+ signal.throwIfAborted();
2326
+ const response = await fetchImpl(url.href, {
2327
+ method: "GET",
2328
+ redirect: "manual",
2329
+ signal,
2330
+ headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", ...headers }
2331
+ });
2332
+ const location = response.headers.get("location");
2333
+ if (response.status >= 300 && response.status < 400 && location) {
2334
+ await response.body?.cancel().catch(() => {});
2335
+ if (hop >= MAX_REDIRECTS)
2336
+ throw new FetchRefusedError(`more than ${MAX_REDIRECTS} redirects`);
2337
+ url = new URL(location, url);
2338
+ continue;
2339
+ }
2340
+ const out = {};
2341
+ response.headers.forEach((value, key) => {
2342
+ out[key] = value;
2343
+ });
2344
+ return { status: response.status, body: pageForExtraction(await readCapped(response, maxBytes)), headers: out };
2345
+ }
2346
+ }
2347
+ };
2348
+ }
2349
+
2066
2350
  // plugins/tracker/src/discord-common.ts
2067
2351
  import { MessageFlags } from "discord.js";
2068
2352
  var STARTING = "The tracker is starting up; try again in a minute.";
@@ -2097,6 +2381,19 @@ function serial() {
2097
2381
  };
2098
2382
  }
2099
2383
 
2384
+ // plugins/tracker/src/limits.ts
2385
+ var MAX_TITLE = 100;
2386
+ var MAX_REMINDER_TEXT = 1500;
2387
+ var MAX_WHEN = 100;
2388
+ var MAX_NOTE = 300;
2389
+ var MAX_URL = 1000;
2390
+ var MAX_NEAR = 100;
2391
+ var CURRENCY_LENGTH = 3;
2392
+ var DATE_LENGTH = 10;
2393
+ var MAX_ZONE = 64;
2394
+ var MAX_TASK_ID = 24;
2395
+ var MAX_LIVE_TASKS = 200;
2396
+
2100
2397
  // plugins/tracker/src/people.ts
2101
2398
  var DEFAULT_TIME_ZONE = "America/New_York";
2102
2399
  var DEFAULT_PREFERRED_HOUR = 9;
@@ -2163,10 +2460,48 @@ async function setPreferences(store, userId, prefs) {
2163
2460
  });
2164
2461
  }
2165
2462
 
2463
+ // plugins/tracker/src/retime.ts
2464
+ var DAY_MS2 = 24 * 60 * 60 * 1000;
2465
+ function localDay(at, zone) {
2466
+ const w = wallClock(at, zone);
2467
+ return `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2468
+ }
2469
+ function occurrenceDay(s, due, zone) {
2470
+ return s.kind === "period" ? periodDate(s, due, zone) : localDay(due, zone);
2471
+ }
2472
+ function retimed(s, day, owner) {
2473
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
2474
+ const lead = s.kind === "period" ? s.leadDays ?? 0 : 0;
2475
+ let after = new Date(Date.parse(`${day}T00:00:00Z`) - (lead + 2) * DAY_MS2);
2476
+ for (let i = 0;i < 1000; i++) {
2477
+ const due = nextDue(s, after, ctx);
2478
+ if (!due)
2479
+ return null;
2480
+ const on = occurrenceDay(s, due, owner.timeZone);
2481
+ if (on === day)
2482
+ return due;
2483
+ if (on > day)
2484
+ return null;
2485
+ after = due;
2486
+ }
2487
+ return null;
2488
+ }
2489
+ async function heldRuns(store, task) {
2490
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
2491
+ }
2492
+ async function restoreHeldRun(store, task, held, before, after, now) {
2493
+ const s = task.schedule;
2494
+ const first = held[0];
2495
+ if (!s || !first)
2496
+ return null;
2497
+ const due = retimed(s, occurrenceDay(s, new Date(first.dueAt), before.timeZone), after);
2498
+ if (!due)
2499
+ return null;
2500
+ return store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: due.toISOString(), dedupeKey: scheduledKey(task.id, due), at: now.toISOString() });
2501
+ }
2502
+
2166
2503
  // plugins/tracker/src/actions.ts
2167
2504
  var MAX_ANSWER = 2000;
2168
- var MAX_REMINDER_TEXT = 1500;
2169
- var MAX_TITLE = 100;
2170
2505
  var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
2171
2506
  function clip(text, max = MAX_ANSWER) {
2172
2507
  return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
@@ -2221,7 +2556,7 @@ async function registerPerson(d, user, input) {
2221
2556
  d.admissions.markRegistered(user.id, d.clock.now().toISOString());
2222
2557
  const zoneChanged = updated.timeZone !== user.timeZone;
2223
2558
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2224
- await rescheduleOwned(d, updated, zoneChanged);
2559
+ await rescheduleOwned(d, updated, zoneChanged, user);
2225
2560
  return registeredText(updated, first);
2226
2561
  }
2227
2562
  async function saveSettings(d, user, input) {
@@ -2235,7 +2570,7 @@ async function saveSettings(d, user, input) {
2235
2570
  }
2236
2571
  const zoneChanged = updated.timeZone !== user.timeZone;
2237
2572
  if (zoneChanged || updated.preferredHour !== user.preferredHour)
2238
- await rescheduleOwned(d, updated, zoneChanged);
2573
+ await rescheduleOwned(d, updated, zoneChanged, user);
2239
2574
  return null;
2240
2575
  }
2241
2576
  async function setHour(d, user, hour) {
@@ -2249,73 +2584,46 @@ async function setHour(d, user, hour) {
2249
2584
  return err.message;
2250
2585
  throw err;
2251
2586
  }
2252
- const moved = await rescheduleOwned(d, updated, false);
2587
+ const moved = await rescheduleOwned(d, updated, false, user);
2253
2588
  const note = moved > 0 ? ` ${moved} recurring reminder(s) moved to it.` : "";
2254
2589
  return `Your preferred hour is now ${String(hour).padStart(2, "0")}:00, ${updated.timeZone} time.${note}`;
2255
2590
  }
2256
- async function rescheduleOwned(d, owner, zoneChanged) {
2591
+ async function rescheduleOwned(d, owner, zoneChanged, before) {
2257
2592
  let moved = 0;
2258
- for (const task of await d.store.listTasks({ ownerId: owner.id, status: "active" })) {
2593
+ const live = [...await d.store.listTasks({ ownerId: owner.id, status: "active" }), ...await d.store.listTasks({ ownerId: owner.id, status: "paused" })];
2594
+ for (const task of live) {
2259
2595
  const s = task.schedule;
2260
2596
  if (!s || s.kind !== "calendar" && s.kind !== "period")
2261
2597
  continue;
2262
2598
  if (!zoneChanged && s.hour !== undefined)
2263
2599
  continue;
2264
- const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2265
- const now = d.clock.now();
2266
- await reschedule(d.store, task, owner, s, owner.id, now);
2267
- for (const o of snoozes) {
2268
- await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2269
- }
2600
+ const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
2601
+ const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
2602
+ if (held.length > 0)
2603
+ await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
2270
2604
  moved++;
2271
2605
  }
2272
2606
  return moved;
2273
2607
  }
2274
- function reminderSchedule(when, repeat, zone, now) {
2275
- if (repeat === "none") {
2276
- if (!when)
2277
- return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2278
- return { kind: "once", at: when.toISOString() };
2279
- }
2280
- const w = wallClock(when ?? now, zone);
2281
- const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2282
- const base = { kind: "calendar", every: 1, unit: repeat, start };
2283
- if (!when)
2284
- return base;
2285
- return {
2286
- ...base,
2287
- hour: w.hour,
2288
- minute: w.minute,
2289
- ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2290
- ...repeat === "month" ? { dayOfMonth: w.day } : {}
2291
- };
2292
- }
2293
- async function remind(d, user, input) {
2294
- const type = d.types.reminder;
2295
- if (!type)
2296
- return "Reminders are not available on this bot.";
2297
- const text = input.text.trim();
2298
- if (text.length === 0)
2299
- return "Say what to remind you of.";
2300
- if (text.length > MAX_REMINDER_TEXT)
2301
- return `That reminder is longer than ${MAX_REMINDER_TEXT} characters.`;
2608
+ async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
2609
+ const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2302
2610
  const now = d.clock.now();
2303
- let when = null;
2304
- if (input.when !== undefined && input.when.trim() !== "") {
2305
- const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2306
- if (!parsed.ok)
2307
- return parsed.error;
2308
- when = parsed.at;
2611
+ const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
2612
+ for (const o of snoozes) {
2613
+ await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2309
2614
  }
2310
- const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2311
- if (typeof schedule === "string")
2312
- return schedule;
2313
- const actor = { userId: user.id, admin: user.admin };
2314
- const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2315
- const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2316
- const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2317
- return clip(`Reminder \`${task.id}\` set: ${text}
2318
- Next: ${at}${cadence}.`);
2615
+ return updated;
2616
+ }
2617
+ async function liveTaskCap(d, owner) {
2618
+ const active = await d.store.listTasks({ ownerId: owner.id, status: "active" });
2619
+ const paused = await d.store.listTasks({ ownerId: owner.id, status: "paused" });
2620
+ if (active.length + paused.length < MAX_LIVE_TASKS)
2621
+ return null;
2622
+ const where = d.webEditor ? " or delete one on the web" : "";
2623
+ return `You already have ${MAX_LIVE_TASKS} active or paused tasks, the most one person may. Finish one${where} first.`;
2624
+ }
2625
+ function said(r) {
2626
+ return r.ok ? r.text : r.error;
2319
2627
  }
2320
2628
  async function loadTaskList(d, user) {
2321
2629
  const entries = await taskList(d.store, { userId: user.id, admin: user.admin });
@@ -2348,6 +2656,8 @@ async function ownTask(d, user, taskId) {
2348
2656
  return task && task.ownerId === user.id ? task : null;
2349
2657
  }
2350
2658
  async function answerLatest(d, user, input) {
2659
+ if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
2660
+ return `\`until\` is longer than ${MAX_WHEN} characters.`;
2351
2661
  const task = await ownTask(d, user, input.taskId);
2352
2662
  if (!task)
2353
2663
  return NO_SUCH_TASK;
@@ -2378,107 +2688,136 @@ function answeredText(kind, snoozeUntil, user, now) {
2378
2688
  return "Marked done.";
2379
2689
  return "Recorded.";
2380
2690
  }
2381
- async function resumeTask(d, user, taskId) {
2691
+
2692
+ // plugins/tracker/src/manage.ts
2693
+ async function ownLiveTask(d, user, taskId) {
2382
2694
  const task = await ownTask(d, user, taskId);
2695
+ return task && task.status !== "archived" ? task : null;
2696
+ }
2697
+ function editable(task) {
2698
+ return task.status === "active" || task.status === "paused";
2699
+ }
2700
+ var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
2701
+ async function pauseTask(d, user, taskId) {
2702
+ const task = await ownLiveTask(d, user, taskId);
2383
2703
  if (!task)
2384
- return NO_SUCH_TASK;
2385
- const removed = await d.health.resumeTask(task, user.id, d.clock.now());
2386
- if (removed === null)
2387
- return task.status === "paused" ? "That task was not paused by failed DMs." : `That task is ${task.status}, not paused.`;
2704
+ return { ok: false, error: NO_SUCH_TASK };
2705
+ if (task.status !== "active")
2706
+ return { ok: false, error: `That task is ${task.status}, not active.` };
2707
+ const at = d.clock.now().toISOString();
2708
+ await d.store.updateTask(task.id, { status: "paused", at });
2709
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
2710
+ return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
2711
+ }
2712
+ async function resumeTask(d, user, taskId) {
2713
+ const task = await ownLiveTask(d, user, taskId);
2714
+ if (!task)
2715
+ return { ok: false, error: NO_SUCH_TASK };
2716
+ if (task.status !== "paused")
2717
+ return { ok: false, error: `That task is ${task.status}, not paused.` };
2718
+ const now = d.clock.now();
2719
+ if (d.health.isPaused(user.id))
2720
+ await d.health.resume(user.id, now);
2721
+ const removed = await d.health.resumeTask(task, user.id, now);
2722
+ if (removed === null) {
2723
+ const current = await d.store.getTask(task.id);
2724
+ if (current?.status === "paused") {
2725
+ const at = now.toISOString();
2726
+ const active = await d.store.updateTask(task.id, { status: "active", at });
2727
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "resumed", detail: "resumed by the owner", at });
2728
+ await materialize(d.store, active, user, now);
2729
+ }
2730
+ return { ok: true, text: `Resumed \`${task.id}\`.` };
2731
+ }
2388
2732
  const names = [];
2389
2733
  for (const id of removed)
2390
2734
  names.push((await d.store.getUser(id))?.displayName ?? id);
2391
2735
  const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2392
- return `Resumed \`${task.id}\`${without}.`;
2736
+ return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
2737
+ }
2738
+ async function deleteTask(d, user, taskId) {
2739
+ const task = await ownLiveTask(d, user, taskId);
2740
+ if (!task)
2741
+ return { ok: false, error: NO_SUCH_TASK };
2742
+ const at = d.clock.now().toISOString();
2743
+ await d.store.deleteQueuedOccurrences(task.id);
2744
+ await d.store.updateTask(task.id, { status: "archived", at });
2745
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
2746
+ return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
2393
2747
  }
2394
2748
 
2395
- // plugins/tracker/src/history.ts
2396
- var HISTORY_RUNS = 10;
2397
- var HISTORY_CHANGES = 5;
2398
- function replyText(r) {
2399
- if (r.kind === "text") {
2400
- const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
2401
- return `replied "${clip(text, 80)}"`;
2749
+ // plugins/tracker/src/reminders.ts
2750
+ var REPEATS = ["none", "day", "week", "month"];
2751
+ function reminderSchedule(when, repeat, zone, now) {
2752
+ if (repeat === "none") {
2753
+ if (!when)
2754
+ return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2755
+ return { kind: "once", at: when.toISOString() };
2402
2756
  }
2403
- if (r.kind === "decision")
2404
- return `chose ${String(r.payload?.choice ?? "?")}`;
2405
- return r.kind === "opt_out" ? "opted out" : r.kind;
2406
- }
2407
- function runView(o, replies, people, viewer, now) {
2408
- return {
2409
- due: formatInstant(o.dueAt, viewer.timeZone, now),
2410
- status: o.status,
2411
- late: o.late,
2412
- error: o.status === "failed" && o.error ? clip(o.error, 120) : null,
2413
- answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
2414
- };
2415
- }
2416
- function changeView(e, viewer, now) {
2757
+ const w = wallClock(when ?? now, zone);
2758
+ const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2759
+ const base = { kind: "calendar", every: 1, unit: repeat, start };
2760
+ if (!when)
2761
+ return base;
2417
2762
  return {
2418
- at: formatInstant(e.at, viewer.timeZone, now),
2419
- kind: e.kind.replaceAll("_", " "),
2420
- detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
2763
+ ...base,
2764
+ hour: w.hour,
2765
+ minute: w.minute,
2766
+ ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2767
+ ...repeat === "month" ? { dayOfMonth: w.day } : {}
2421
2768
  };
2422
2769
  }
2423
- function runLine(v) {
2424
- const late = v.late ? " (late)" : "";
2425
- const error = v.error ? `: ${v.error}` : "";
2426
- const answered = v.answers.length > 0 ? `; ${v.answers.join(", ")}` : "";
2427
- return `- ${v.due} -- ${v.status}${late}${error}${answered}`;
2428
- }
2429
- function changeLine(v) {
2430
- return `- ${v.at} ${v.kind}${v.detail !== null ? `: ${v.detail}` : ""}`;
2770
+ function repeatOf(schedule) {
2771
+ if (schedule?.kind !== "calendar")
2772
+ return "none";
2773
+ return schedule.unit;
2431
2774
  }
2432
- async function loadHistory(d, user, taskId) {
2433
- const actor = { userId: user.id, admin: user.admin };
2434
- const task = await visibleTask(d.store, actor, taskId.trim());
2435
- if (!task)
2436
- return null;
2775
+ function reminderPlan(d, user, input, keep) {
2776
+ const text = input.text.trim();
2777
+ if (text.length === 0)
2778
+ return { ok: false, error: "Say what to remind you of." };
2779
+ if (text.length > MAX_REMINDER_TEXT)
2780
+ return { ok: false, error: `That reminder is longer than ${MAX_REMINDER_TEXT} characters.` };
2781
+ if (!REPEATS.includes(input.repeat))
2782
+ return { ok: false, error: "`repeat` is once, daily, weekly or monthly." };
2437
2783
  const now = d.clock.now();
2438
- const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
2439
- const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
2440
- const runs = all.filter((o) => !upcoming(o));
2441
- const next = all.find(upcoming);
2442
- const replies = await visibleReplies(d.store, actor, task.id) ?? [];
2443
- const changes = await visibleHistory(d.store, actor, task.id) ?? [];
2444
- const people = new Map;
2445
- for (const r of replies) {
2446
- if (!people.has(r.userId)) {
2447
- const u = await d.store.getUser(r.userId);
2448
- if (u)
2449
- people.set(u.id, u);
2450
- }
2784
+ let when = null;
2785
+ if (input.when !== undefined && input.when.trim().length > MAX_WHEN)
2786
+ return { ok: false, error: `\`when\` is longer than ${MAX_WHEN} characters.` };
2787
+ if (input.when !== undefined && input.when.trim() !== "") {
2788
+ const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2789
+ if (!parsed.ok)
2790
+ return { ok: false, error: parsed.error };
2791
+ when = parsed.at;
2451
2792
  }
2452
- const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
2453
- return {
2454
- task,
2455
- cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
2456
- next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
2457
- runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
2458
- earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
2459
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
2460
- earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
2461
- };
2793
+ if (when === null && keep && repeatOf(keep) === input.repeat)
2794
+ return { ok: true, text, schedule: keep };
2795
+ const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2796
+ if (typeof schedule === "string")
2797
+ return { ok: false, error: schedule };
2798
+ return { ok: true, text, schedule };
2462
2799
  }
2463
- async function taskHistory(d, user, taskId) {
2464
- const h = await loadHistory(d, user, taskId);
2465
- if (!h)
2466
- return NO_SUCH_TASK;
2467
- const { task } = h;
2468
- const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${h.cadence}`];
2469
- lines.push(h.next ? `Next: ${h.next}` : "Next: nothing scheduled");
2470
- lines.push(h.runs.length === 0 ? "Runs: none yet" : "Runs:");
2471
- if (h.earlierRuns > 0)
2472
- lines.push(`- (${h.earlierRuns} earlier)`);
2473
- for (const v of h.runs)
2474
- lines.push(runLine(v));
2475
- lines.push("Changes:");
2476
- if (h.earlierChanges > 0)
2477
- lines.push(`- (${h.earlierChanges} earlier)`);
2478
- for (const c of h.changes)
2479
- lines.push(changeLine(c));
2480
- return clip(lines.join(`
2481
- `));
2800
+ async function createReminder(d, user, input) {
2801
+ const type = d.types.reminder;
2802
+ if (!type)
2803
+ return { ok: false, error: "Reminders are not available on this bot." };
2804
+ const plan = reminderPlan(d, user, input);
2805
+ if (!plan.ok)
2806
+ return plan;
2807
+ const capped = await liveTaskCap(d, user);
2808
+ if (capped)
2809
+ return { ok: false, error: capped };
2810
+ const { text, schedule } = plan;
2811
+ const now = d.clock.now();
2812
+ const actor = { userId: user.id, admin: user.admin };
2813
+ const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2814
+ const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2815
+ const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2816
+ return { ok: true, task, text: clip(`Reminder \`${task.id}\` set: ${text}
2817
+ Next: ${at}${cadence}.`) };
2818
+ }
2819
+ async function remind(d, user, input) {
2820
+ return said(await createReminder(d, user, input));
2482
2821
  }
2483
2822
 
2484
2823
  // plugins/tracker/src/interactions.ts
@@ -2765,102 +3104,488 @@ function reducesContact(ref) {
2765
3104
  return kind === "decline" || kind === "opt_out";
2766
3105
  }
2767
3106
 
2768
- // plugins/tracker/src/interactions.ts
2769
- function replyRow(occurrenceId) {
2770
- return new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(replyButtonId(occurrenceId)).setLabel("Reply").setStyle(ButtonStyle.Secondary));
2771
- }
2772
- function replyModal(occurrenceId) {
2773
- return new ModalBuilder().setCustomId(modalId(occurrenceId)).setTitle("Reply").addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId(REPLY_FIELD).setLabel("Your reply").setStyle(TextInputStyle.Paragraph).setRequired(true).setMaxLength(MAX_REPLY_TEXT)));
3107
+ // plugins/tracker/src/interactions.ts
3108
+ function replyRow(occurrenceId) {
3109
+ return new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(replyButtonId(occurrenceId)).setLabel("Reply").setStyle(ButtonStyle.Secondary));
3110
+ }
3111
+ function replyModal(occurrenceId) {
3112
+ return new ModalBuilder().setCustomId(modalId(occurrenceId)).setTitle("Reply").addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId(REPLY_FIELD).setLabel("Your reply").setStyle(TextInputStyle.Paragraph).setRequired(true).setMaxLength(MAX_REPLY_TEXT)));
3113
+ }
3114
+ function createInteractionHandler(c) {
3115
+ const membershipOf = (i) => c.membershipOf(i, i.user.id);
3116
+ async function onButton(interaction, d, ref) {
3117
+ await interaction.deferUpdate();
3118
+ const gated = !reducesContact(ref);
3119
+ const membership = gated ? await membershipOf(interaction) : "not-checked";
3120
+ const result = await c.queue(async () => {
3121
+ if (!gated) {
3122
+ const person = await d.store.findUserByDiscordId(interaction.user.id);
3123
+ if (!person)
3124
+ return { notice: null, pressed: { ok: false, error: "That is not yours to answer." } };
3125
+ return { notice: null, pressed: await press(d, person, ref) };
3126
+ }
3127
+ const entry = await enter(d, interaction.user.id, membership, "registered");
3128
+ if (!entry.ok)
3129
+ return { notice: null, pressed: { ok: false, error: entry.content } };
3130
+ return { notice: entry.notice, pressed: await press(d, entry.user, ref) };
3131
+ });
3132
+ if (result.pressed.ok) {
3133
+ const { status, keepReplyFor } = result.pressed;
3134
+ await interaction.editReply({
3135
+ content: clip(`${interaction.message.content}
3136
+
3137
+ -- ${status}`),
3138
+ components: keepReplyFor ? [replyRow(keepReplyFor)] : [],
3139
+ ...NO_MENTIONS
3140
+ });
3141
+ } else {
3142
+ await interaction.followUp({ content: result.pressed.error, ...EPHEMERAL });
3143
+ }
3144
+ if (result.notice)
3145
+ await interaction.followUp({ content: result.notice, ...EPHEMERAL });
3146
+ }
3147
+ async function onReplyButton(interaction, d, occurrenceId) {
3148
+ const person = await d.store.findUserByDiscordId(interaction.user.id);
3149
+ const registered = person ? d.admissions.isRegistered(person.id) : false;
3150
+ const refusal = decideAccess({ membership: "not-checked", person, registered, need: "registered" }) ?? (person ? await replyRefusal(d, person, occurrenceId) : NOT_ADMITTED);
3151
+ if (refusal) {
3152
+ await interaction.reply({ content: refusal, ...EPHEMERAL });
3153
+ return;
3154
+ }
3155
+ await interaction.showModal(replyModal(occurrenceId));
3156
+ }
3157
+ async function onReplyModal(interaction, d, occurrenceId) {
3158
+ await interaction.deferReply(EPHEMERAL);
3159
+ const membership = await membershipOf(interaction);
3160
+ const text = interaction.fields.getTextInputValue(REPLY_FIELD);
3161
+ const content = await c.queue(async () => {
3162
+ const entry = await enter(d, interaction.user.id, membership, "registered");
3163
+ if (!entry.ok)
3164
+ return entry.content;
3165
+ const body = await submitReply(d, entry.user, occurrenceId, text);
3166
+ return entry.notice ? `${entry.notice}
3167
+
3168
+ ${body}` : body;
3169
+ });
3170
+ await interaction.editReply({ content: clip(content), ...NO_MENTIONS });
3171
+ }
3172
+ async function failed(interaction) {
3173
+ try {
3174
+ if (interaction.deferred || interaction.replied)
3175
+ await interaction.followUp({ content: FAILED, ...EPHEMERAL });
3176
+ else
3177
+ await interaction.reply({ content: FAILED, ...EPHEMERAL });
3178
+ } catch (err) {
3179
+ c.log.warn(`could not tell the presser of ${interaction.customId} that it failed: ${err instanceof Error ? err.message : String(err)}`);
3180
+ }
3181
+ }
3182
+ return async (interaction) => {
3183
+ const parsed = parseCustomId(interaction.customId);
3184
+ const d = c.deps();
3185
+ if (!parsed || !d) {
3186
+ await interaction.reply({ content: d ? "That button is not one of mine." : STARTING, ...EPHEMERAL });
3187
+ return;
3188
+ }
3189
+ try {
3190
+ if (parsed.kind === "reply-modal" && interaction.isModalSubmit())
3191
+ return await onReplyModal(interaction, d, parsed.occurrenceId);
3192
+ if (interaction.isMessageComponent() && parsed.kind === "reply-button")
3193
+ return await onReplyButton(interaction, d, parsed.occurrenceId);
3194
+ if (interaction.isMessageComponent() && parsed.kind === "ref")
3195
+ return await onButton(interaction, d, parsed.ref);
3196
+ await interaction.reply({ content: "That is not one of mine.", ...EPHEMERAL });
3197
+ } catch (err) {
3198
+ c.log.error(`interaction ${interaction.customId} failed`, err);
3199
+ await failed(interaction);
3200
+ }
3201
+ };
3202
+ }
3203
+
3204
+ // plugins/tracker/src/history.ts
3205
+ var HISTORY_RUNS = 10;
3206
+ var HISTORY_CHANGES = 5;
3207
+ function replyText(r) {
3208
+ if (r.kind === "text") {
3209
+ const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
3210
+ return `replied "${clip(text, 80)}"`;
3211
+ }
3212
+ if (r.kind === "decision")
3213
+ return `chose ${String(r.payload?.choice ?? "?")}`;
3214
+ return r.kind === "opt_out" ? "opted out" : r.kind;
3215
+ }
3216
+ function runView(o, replies, people, viewer, now) {
3217
+ return {
3218
+ due: formatInstant(o.dueAt, viewer.timeZone, now),
3219
+ status: o.status,
3220
+ late: o.late,
3221
+ error: o.status === "failed" && o.error ? clip(o.error, 120) : null,
3222
+ answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
3223
+ };
3224
+ }
3225
+ function changeView(e, viewer, now) {
3226
+ return {
3227
+ at: formatInstant(e.at, viewer.timeZone, now),
3228
+ kind: e.kind.replaceAll("_", " "),
3229
+ detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
3230
+ };
3231
+ }
3232
+ function runLine(v) {
3233
+ const late = v.late ? " (late)" : "";
3234
+ const error = v.error ? `: ${v.error}` : "";
3235
+ const answered = v.answers.length > 0 ? `; ${v.answers.join(", ")}` : "";
3236
+ return `- ${v.due} -- ${v.status}${late}${error}${answered}`;
3237
+ }
3238
+ function changeLine(v) {
3239
+ return `- ${v.at} ${v.kind}${v.detail !== null ? `: ${v.detail}` : ""}`;
3240
+ }
3241
+ async function loadHistory(d, user, taskId) {
3242
+ const actor = { userId: user.id, admin: user.admin };
3243
+ const task = await visibleTask(d.store, actor, taskId.trim());
3244
+ if (!task)
3245
+ return null;
3246
+ const now = d.clock.now();
3247
+ const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
3248
+ const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
3249
+ const runs = all.filter((o) => !upcoming(o));
3250
+ const next = all.find(upcoming);
3251
+ const replies = await visibleReplies(d.store, actor, task.id) ?? [];
3252
+ const changes = await visibleHistory(d.store, actor, task.id) ?? [];
3253
+ const people = new Map;
3254
+ for (const r of replies) {
3255
+ if (!people.has(r.userId)) {
3256
+ const u = await d.store.getUser(r.userId);
3257
+ if (u)
3258
+ people.set(u.id, u);
3259
+ }
3260
+ }
3261
+ const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
3262
+ return {
3263
+ task,
3264
+ cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
3265
+ next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
3266
+ runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
3267
+ earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
3268
+ changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
3269
+ earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
3270
+ };
3271
+ }
3272
+ async function taskHistory(d, user, taskId) {
3273
+ const h = await loadHistory(d, user, taskId);
3274
+ if (!h)
3275
+ return NO_SUCH_TASK;
3276
+ const { task } = h;
3277
+ const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${h.cadence}`];
3278
+ lines.push(h.next ? `Next: ${h.next}` : "Next: nothing scheduled");
3279
+ lines.push(h.runs.length === 0 ? "Runs: none yet" : "Runs:");
3280
+ if (h.earlierRuns > 0)
3281
+ lines.push(`- (${h.earlierRuns} earlier)`);
3282
+ for (const v of h.runs)
3283
+ lines.push(runLine(v));
3284
+ lines.push("Changes:");
3285
+ if (h.earlierChanges > 0)
3286
+ lines.push(`- (${h.earlierChanges} earlier)`);
3287
+ for (const c of h.changes)
3288
+ lines.push(changeLine(c));
3289
+ return clip(lines.join(`
3290
+ `));
3291
+ }
3292
+
3293
+ // plugins/tracker/src/series.ts
3294
+ var SERIES_SHOWN = 8;
3295
+ function pointLine(p, viewer, now) {
3296
+ const note = p.note && p.note !== "observed" ? ` (${p.note})` : "";
3297
+ return `- ${formatInstant(p.at, viewer.timeZone, now)} ${money(p.value, p.unit ?? "")}${note}`;
3298
+ }
3299
+ async function seriesLines(d, user, taskId) {
3300
+ const actor = { userId: user.id, admin: user.admin };
3301
+ const task = await visibleTask(d.store, actor, taskId.trim());
3302
+ if (!task || task.type !== "renewal" && task.type !== "price")
3303
+ return [];
3304
+ const points = await visibleSeries(d.store, actor, task.id) ?? [];
3305
+ const now = d.clock.now();
3306
+ const lines = [];
3307
+ if (task.type === "renewal") {
3308
+ const paid = points.filter((p) => p.note === "keep" || p.note === "renewed");
3309
+ if (paid.length === 0)
3310
+ return ["Paid: nothing recorded yet"];
3311
+ const unit = paid.at(-1)?.unit ?? "";
3312
+ const total = paid.reduce((sum, p) => sum + p.value, 0);
3313
+ lines.push(`Paid: ${paid.length} period${paid.length === 1 ? "" : "s"}, ${money(total, unit)} in all`);
3314
+ for (const p of paid.slice(-SERIES_SHOWN))
3315
+ lines.push(pointLine(p, user, now));
3316
+ return lines;
3317
+ }
3318
+ const runs = await visibleOccurrences(d.store, actor, task.id) ?? [];
3319
+ const checked = runs.filter((o) => o.status !== "queued" && o.summary).at(-1);
3320
+ if (checked?.summary)
3321
+ lines.push(`Last check: ${clip(checked.summary, 160)}`);
3322
+ if (points.length === 0) {
3323
+ lines.push("Prices: none read yet");
3324
+ return lines;
3325
+ }
3326
+ const unit = points.at(-1)?.unit ?? "";
3327
+ const values = points.map((p) => p.value);
3328
+ lines.push(`Prices: ${points.length} read, low ${money(Math.min(...values), unit)}, high ${money(Math.max(...values), unit)}`);
3329
+ for (const p of points.slice(-SERIES_SHOWN))
3330
+ lines.push(pointLine(p, user, now));
3331
+ return lines;
3332
+ }
3333
+ async function historyWithSeries(d, user, taskId) {
3334
+ const history = await taskHistory(d, user, taskId);
3335
+ const series = await seriesLines(d, user, taskId);
3336
+ if (series.length === 0)
3337
+ return history;
3338
+ const lines = history.split(`
3339
+ `);
3340
+ const block = series.join(`
3341
+ `);
3342
+ const rest = lines.slice(2).join(`
3343
+ `);
3344
+ const head = lines.slice(0, 2).join(`
3345
+ `);
3346
+ return clip(`${head}
3347
+ ${block}
3348
+ ${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
3349
+ }
3350
+
3351
+ // plugins/tracker/src/price.ts
3352
+ var MAX_PRICE_TASKS = 20;
3353
+ var DEFAULT_POLL_HOURS = 12;
3354
+ var MAX_POLL_HOURS = 168;
3355
+ function plural2(n, word) {
3356
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
2774
3357
  }
2775
- function createInteractionHandler(c) {
2776
- const membershipOf = (i) => c.membershipOf(i, i.user.id);
2777
- async function onButton(interaction, d, ref) {
2778
- await interaction.deferUpdate();
2779
- const gated = !reducesContact(ref);
2780
- const membership = gated ? await membershipOf(interaction) : "not-checked";
2781
- const result = await c.queue(async () => {
2782
- if (!gated) {
2783
- const person = await d.store.findUserByDiscordId(interaction.user.id);
2784
- if (!person)
2785
- return { notice: null, pressed: { ok: false, error: "That is not yours to answer." } };
2786
- return { notice: null, pressed: await press(d, person, ref) };
2787
- }
2788
- const entry = await enter(d, interaction.user.id, membership, "registered");
2789
- if (!entry.ok)
2790
- return { notice: null, pressed: { ok: false, error: entry.content } };
2791
- return { notice: entry.notice, pressed: await press(d, entry.user, ref) };
2792
- });
2793
- if (result.pressed.ok) {
2794
- const { status, keepReplyFor } = result.pressed;
2795
- await interaction.editReply({
2796
- content: clip(`${interaction.message.content}
2797
-
2798
- -- ${status}`),
2799
- components: keepReplyFor ? [replyRow(keepReplyFor)] : [],
2800
- ...NO_MENTIONS
2801
- });
2802
- } else {
2803
- await interaction.followUp({ content: result.pressed.error, ...EPHEMERAL });
2804
- }
2805
- if (result.notice)
2806
- await interaction.followUp({ content: result.notice, ...EPHEMERAL });
2807
- }
2808
- async function onReplyButton(interaction, d, occurrenceId) {
2809
- const person = await d.store.findUserByDiscordId(interaction.user.id);
2810
- const registered = person ? d.admissions.isRegistered(person.id) : false;
2811
- const refusal = decideAccess({ membership: "not-checked", person, registered, need: "registered" }) ?? (person ? await replyRefusal(d, person, occurrenceId) : NOT_ADMITTED);
2812
- if (refusal) {
2813
- await interaction.reply({ content: refusal, ...EPHEMERAL });
2814
- return;
3358
+ function nearPattern(near) {
3359
+ const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3360
+ return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
3361
+ }
3362
+ function priceSettingsPlan(input) {
3363
+ const hours = input.hours ?? DEFAULT_POLL_HOURS;
3364
+ if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3365
+ return { ok: false, error: `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.` };
3366
+ const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3367
+ if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3368
+ return { ok: false, error: "`drop` is a percentage from 1 to 90." };
3369
+ const baseline = input.baseline ?? DEFAULT_BASELINE;
3370
+ if (!BASELINE_RULES.includes(baseline))
3371
+ return { ok: false, error: "`baseline` is first, last or peak." };
3372
+ const name = input.name?.trim() ?? "";
3373
+ if (name.length > MAX_TITLE)
3374
+ return { ok: false, error: `That name is longer than ${MAX_TITLE} characters.` };
3375
+ return { ok: true, name, hours, drop, baseline };
3376
+ }
3377
+ async function previewPrice(d, v) {
3378
+ if (!d.fetch)
3379
+ return { ok: false, error: "Price tracking is not available on this bot." };
3380
+ try {
3381
+ const response = await d.fetch.get(v.url);
3382
+ if (response.status !== 200)
3383
+ return { ok: false, error: `That page answered HTTP ${response.status}, so there is no price to track.` };
3384
+ const found = extractPrice(response.body, v.config.pattern);
3385
+ if (!found) {
3386
+ return {
3387
+ ok: false,
3388
+ error: v.config.pattern ? "I found no price after that `near` text on the page. Check the words as the page shows them." : "I found no price on that page. Some shops show it only to a browser; `near` (the words just before the price) can help."
3389
+ };
2815
3390
  }
2816
- await interaction.showModal(replyModal(occurrenceId));
2817
- }
2818
- async function onReplyModal(interaction, d, occurrenceId) {
2819
- await interaction.deferReply(EPHEMERAL);
2820
- const membership = await membershipOf(interaction);
2821
- const text = interaction.fields.getTextInputValue(REPLY_FIELD);
2822
- const content = await c.queue(async () => {
2823
- const entry = await enter(d, interaction.user.id, membership, "registered");
2824
- if (!entry.ok)
2825
- return entry.content;
2826
- const body = await submitReply(d, entry.user, occurrenceId, text);
2827
- return entry.notice ? `${entry.notice}
3391
+ return { ok: true, text: money(found.value, found.currency ?? "") };
3392
+ } catch (err) {
3393
+ if (err instanceof FetchRefusedError)
3394
+ return { ok: false, error: `I cannot read that page: ${err.message}.` };
3395
+ return { ok: false, error: "I could not read that page just now; try again later." };
3396
+ }
3397
+ }
3398
+ async function ownedPriceTasks(d, user) {
3399
+ const active = await d.store.listTasks({ ownerId: user.id, status: "active" });
3400
+ const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
3401
+ return [...active, ...paused].filter((t) => t.type === "price").length;
3402
+ }
3403
+ function atCap(d) {
3404
+ const where = d.webEditor ? ", or delete one on the web," : "";
3405
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\`${where} first.`;
3406
+ }
3407
+ function titleFor(url) {
3408
+ const u = new URL(url);
3409
+ return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE);
3410
+ }
3411
+ async function startPrice(d, user, input) {
3412
+ if (!d.types.price || !d.fetch)
3413
+ return { ok: false, error: "Price tracking is not available on this bot." };
3414
+ const url = input.url.trim();
3415
+ if (url.length > MAX_URL)
3416
+ return { ok: false, error: `That address is longer than ${MAX_URL} characters.` };
3417
+ const problem = urlProblem(url);
3418
+ if (problem)
3419
+ return { ok: false, error: problem };
3420
+ const settings = priceSettingsPlan(input);
3421
+ if (!settings.ok)
3422
+ return settings;
3423
+ const near = input.near?.trim() ?? "";
3424
+ if (near.length > MAX_NEAR)
3425
+ return { ok: false, error: `\`near\` is longer than ${MAX_NEAR} characters.` };
3426
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3427
+ return { ok: false, error: atCap(d) };
3428
+ const capped = await liveTaskCap(d, user);
3429
+ if (capped)
3430
+ return { ok: false, error: capped };
3431
+ const { name, hours, drop, baseline } = settings;
3432
+ const config = { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} };
3433
+ return { ok: true, pending: { url, title: name || titleFor(url), hours, config } };
3434
+ }
3435
+ async function trackPrice(d, user, input) {
3436
+ const started = await startPrice(d, user, input);
3437
+ if (!started.ok)
3438
+ return started.error;
3439
+ const { pending } = started;
3440
+ return {
3441
+ outside: () => previewPrice(d, pending),
3442
+ finish: async (result) => said(await finishPrice(d, user, pending, result))
3443
+ };
3444
+ }
3445
+ async function finishPrice(d, user, v, seen) {
3446
+ if (!seen.ok)
3447
+ return seen;
3448
+ const type = d.types.price;
3449
+ if (!type)
3450
+ return { ok: false, error: "Price tracking is not available on this bot." };
3451
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS)
3452
+ return { ok: false, error: atCap(d) };
3453
+ const capped = await liveTaskCap(d, user);
3454
+ if (capped)
3455
+ return { ok: false, error: capped };
3456
+ const now = d.clock.now();
3457
+ const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
3458
+ const actor = { userId: user.id, admin: user.admin };
3459
+ const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
3460
+ const rule = v.config.baseline ?? DEFAULT_BASELINE;
3461
+ return {
3462
+ ok: true,
3463
+ task,
3464
+ text: clip([
3465
+ `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3466
+ `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.`
3467
+ ].join(`
3468
+ `))
3469
+ };
3470
+ }
2828
3471
 
2829
- ${body}` : body;
2830
- });
2831
- await interaction.editReply({ content: clip(content), ...NO_MENTIONS });
2832
- }
2833
- async function failed(interaction) {
2834
- try {
2835
- if (interaction.deferred || interaction.replied)
2836
- await interaction.followUp({ content: FAILED, ...EPHEMERAL });
2837
- else
2838
- await interaction.reply({ content: FAILED, ...EPHEMERAL });
2839
- } catch (err) {
2840
- c.log.warn(`could not tell the presser of ${interaction.customId} that it failed: ${err instanceof Error ? err.message : String(err)}`);
2841
- }
3472
+ // plugins/tracker/src/tracked.ts
3473
+ var DEFAULT_LEAD_DAYS = 7;
3474
+ var MAX_LEAD_DAYS = 365;
3475
+ var MAX_EVERY = 100;
3476
+ function plural3(n, word) {
3477
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3478
+ }
3479
+ var PERIOD_UNITS = ["year", "month", "week", "day"];
3480
+ function renewalPlan(d, user, input) {
3481
+ const bad = (error) => ({ ok: false, error });
3482
+ const name = input.name.trim();
3483
+ if (name.length === 0)
3484
+ return bad("Say what renews: a subscription, a domain, a warranty.");
3485
+ if (name.length > MAX_TITLE)
3486
+ return bad(`That name is longer than ${MAX_TITLE} characters.`);
3487
+ if (!Number.isFinite(input.amount) || input.amount < 0)
3488
+ return bad("The amount has to be zero or more.");
3489
+ const currency = input.currency.trim().toUpperCase();
3490
+ if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
3491
+ return bad("The currency is a three-letter code, such as USD or EUR.");
3492
+ const note = input.note?.trim() ?? "";
3493
+ if (note.length > MAX_NOTE)
3494
+ return bad(`The note is longer than ${MAX_NOTE} characters.`);
3495
+ const unit = input.unit ?? "year";
3496
+ if (!PERIOD_UNITS.includes(unit))
3497
+ return bad("`unit` is yearly, monthly, weekly or daily.");
3498
+ const every = input.every ?? 1;
3499
+ if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3500
+ return bad(`\`every\` is a whole number from 1 to ${MAX_EVERY}.`);
3501
+ const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3502
+ if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3503
+ return bad(`\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`);
3504
+ const renews = input.renews.trim();
3505
+ const days = renews.length === DATE_LENGTH && /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, d.clock.now(), user.timeZone) : null;
3506
+ if (days === null)
3507
+ return bad("Give the date as YYYY-MM-DD, for example 2026-12-01.");
3508
+ if (days < 0)
3509
+ return bad("That date has passed: give the next renewal or expiry date.");
3510
+ return { ok: true, name, amount: input.amount, currency, note, days, schedule: { kind: "period", every, unit, anchor: renews, leadDays: lead } };
3511
+ }
3512
+ async function firstAskIfDue(d, task, owner, schedule) {
3513
+ const now = d.clock.now();
3514
+ const first = nextDue(schedule, new Date(0), { zone: owner.timeZone, preferredHour: owner.preferredHour });
3515
+ if (!first || first.getTime() > now.getTime())
3516
+ return null;
3517
+ return d.store.createOccurrence({ taskId: task.id, lane: task.lane, dueAt: first.toISOString(), dedupeKey: scheduledKey(task.id, first), at: now.toISOString() });
3518
+ }
3519
+ function askText(next, days, user, now) {
3520
+ const when = days === 0 ? "today" : `in ${plural3(days, "day")}`;
3521
+ if (!next)
3522
+ return "never (that schedule has no next date)";
3523
+ return Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3524
+ }
3525
+ async function createRenewal(d, user, input) {
3526
+ const type = d.types.renewal;
3527
+ if (!type)
3528
+ return { ok: false, error: "Renewals are not available on this bot." };
3529
+ const plan = renewalPlan(d, user, input);
3530
+ if (!plan.ok)
3531
+ return plan;
3532
+ const capped = await liveTaskCap(d, user);
3533
+ if (capped)
3534
+ return { ok: false, error: capped };
3535
+ const { name, currency, note, schedule } = plan;
3536
+ const now = d.clock.now();
3537
+ const config = { amount: plan.amount, currency, ...note ? { note } : {} };
3538
+ const actor = { userId: user.id, admin: user.admin };
3539
+ let created;
3540
+ try {
3541
+ created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3542
+ } catch (err) {
3543
+ if (err instanceof ScheduleError)
3544
+ return { ok: false, error: `That schedule does not work: ${err.message}.` };
3545
+ throw err;
2842
3546
  }
2843
- return async (interaction) => {
2844
- const parsed = parseCustomId(interaction.customId);
2845
- const d = c.deps();
2846
- if (!parsed || !d) {
2847
- await interaction.reply({ content: d ? "That button is not one of mine." : STARTING, ...EPHEMERAL });
2848
- return;
2849
- }
2850
- try {
2851
- if (parsed.kind === "reply-modal" && interaction.isModalSubmit())
2852
- return await onReplyModal(interaction, d, parsed.occurrenceId);
2853
- if (interaction.isMessageComponent() && parsed.kind === "reply-button")
2854
- return await onReplyButton(interaction, d, parsed.occurrenceId);
2855
- if (interaction.isMessageComponent() && parsed.kind === "ref")
2856
- return await onButton(interaction, d, parsed.ref);
2857
- await interaction.reply({ content: "That is not one of mine.", ...EPHEMERAL });
2858
- } catch (err) {
2859
- c.log.error(`interaction ${interaction.customId} failed`, err);
2860
- await failed(interaction);
2861
- }
3547
+ const { task } = created;
3548
+ const next = await firstAskIfDue(d, task, user, schedule) ?? created.next;
3549
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
3550
+ return {
3551
+ ok: true,
3552
+ task,
3553
+ text: clip([
3554
+ `Renewal \`${task.id}\` set: ${name}, ${money(plan.amount, currency)}, ${cadence}.`,
3555
+ `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.`
3556
+ ].join(`
3557
+ `))
2862
3558
  };
2863
3559
  }
3560
+ async function addRenewal(d, user, input) {
3561
+ return said(await createRenewal(d, user, input));
3562
+ }
3563
+ async function decideRenewal(d, user, input) {
3564
+ const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
3565
+ if (!task || task.ownerId !== user.id)
3566
+ return NO_SUCH_TASK;
3567
+ if (task.type !== "renewal")
3568
+ return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
3569
+ if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
3570
+ return "The amount has to be zero or more.";
3571
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
3572
+ const latest = fired.at(-1);
3573
+ if (!latest)
3574
+ return "That renewal has no ask waiting for an answer yet.";
3575
+ const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
3576
+ try {
3577
+ await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
3578
+ } catch (err) {
3579
+ if (err instanceof ReplyRefusedError)
3580
+ return err.message;
3581
+ throw err;
3582
+ }
3583
+ if (input.choice === "cancel")
3584
+ return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
3585
+ const currency = task.config.currency ?? "";
3586
+ const paid = input.amount !== undefined ? ` at ${money(input.amount, currency)}` : "";
3587
+ return `Recorded: ${input.choice}${paid}.`;
3588
+ }
2864
3589
 
2865
3590
  // plugins/tracker/src/web/secrets.ts
2866
3591
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
@@ -2983,7 +3708,7 @@ ${body}` : body);
2983
3708
  },
2984
3709
  {
2985
3710
  name: "register",
2986
- build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(64)),
3711
+ 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)),
2987
3712
  handle: (interaction) => run(interaction, "admitted", (d, user) => {
2988
3713
  const hour = interaction.options.getInteger("hour");
2989
3714
  const zone = interaction.options.getString("zone");
@@ -2996,7 +3721,7 @@ ${body}` : body);
2996
3721
  },
2997
3722
  {
2998
3723
  name: "remind",
2999
- build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(100)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
3724
+ 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" })),
3000
3725
  handle: (interaction) => run(interaction, "registered", (d, user) => {
3001
3726
  const when = interaction.options.getString("when");
3002
3727
  return remind(d, user, {
@@ -3006,6 +3731,45 @@ ${body}` : body);
3006
3731
  });
3007
3732
  })
3008
3733
  },
3734
+ {
3735
+ name: "renewal",
3736
+ 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)),
3737
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3738
+ const every = interaction.options.getInteger("every");
3739
+ const lead = interaction.options.getInteger("lead");
3740
+ const unit = interaction.options.getString("unit");
3741
+ const note = interaction.options.getString("note");
3742
+ return addRenewal(d, user, {
3743
+ name: interaction.options.getString("name", true),
3744
+ amount: interaction.options.getNumber("amount", true),
3745
+ currency: interaction.options.getString("currency", true),
3746
+ renews: interaction.options.getString("renews", true),
3747
+ ...every !== null ? { every } : {},
3748
+ ...lead !== null ? { lead } : {},
3749
+ ...unit !== null ? { unit } : {},
3750
+ ...note !== null ? { note } : {}
3751
+ });
3752
+ })
3753
+ },
3754
+ {
3755
+ name: "price",
3756
+ 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)),
3757
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3758
+ const name = interaction.options.getString("name");
3759
+ const hours = interaction.options.getInteger("hours");
3760
+ const drop = interaction.options.getNumber("drop");
3761
+ const baseline = interaction.options.getString("baseline");
3762
+ const near = interaction.options.getString("near");
3763
+ return trackPrice(d, user, {
3764
+ url: interaction.options.getString("url", true),
3765
+ ...name !== null ? { name } : {},
3766
+ ...hours !== null ? { hours } : {},
3767
+ ...drop !== null ? { drop } : {},
3768
+ ...baseline !== null ? { baseline } : {},
3769
+ ...near !== null ? { near } : {}
3770
+ });
3771
+ })
3772
+ },
3009
3773
  {
3010
3774
  name: "tasks",
3011
3775
  build: (b) => b.setDescription("List your tasks and when each is next due"),
@@ -3013,7 +3777,7 @@ ${body}` : body);
3013
3777
  },
3014
3778
  {
3015
3779
  name: "task",
3016
- build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3780
+ 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))),
3017
3781
  handle: (interaction) => {
3018
3782
  const sub = interaction.options.getSubcommand();
3019
3783
  const taskId = interaction.options.getString("task", true);
@@ -3026,10 +3790,18 @@ ${body}` : body);
3026
3790
  const until = interaction.options.getString("until");
3027
3791
  return answerLatest(d, user, { taskId, kind: "snooze", ...until !== null ? { until } : {} });
3028
3792
  }
3793
+ case "decide": {
3794
+ const amount = interaction.options.getNumber("amount");
3795
+ return decideRenewal(d, user, {
3796
+ taskId,
3797
+ choice: interaction.options.getString("choice", true),
3798
+ ...amount !== null ? { amount } : {}
3799
+ });
3800
+ }
3029
3801
  case "history":
3030
- return taskHistory(d, user, taskId);
3802
+ return historyWithSeries(d, user, taskId);
3031
3803
  case "resume":
3032
- return resumeTask(d, user, taskId);
3804
+ return said(await resumeTask(d, user, taskId));
3033
3805
  case "share": {
3034
3806
  const pending = await prepareShare(d, user, taskId, { discordId: shareWith?.id ?? "", membership: membership ?? "unknown" });
3035
3807
  if (typeof pending === "string")
@@ -3078,7 +3850,28 @@ function healthResponse(health) {
3078
3850
  }
3079
3851
 
3080
3852
  // plugins/tracker/src/notify-lane.ts
3081
- function laneStore(store) {
3853
+ var FETCH_TYPES = new Set(["price"]);
3854
+ function takesType(kind, type) {
3855
+ return kind === "poll" ? FETCH_TYPES.has(type) : !FETCH_TYPES.has(type);
3856
+ }
3857
+ function untilAborted(type, signal) {
3858
+ const run = type.run;
3859
+ if (!run)
3860
+ return type;
3861
+ const stop = () => new ExecutorUnavailableError("the tick was aborted; the run waits for the next one");
3862
+ return {
3863
+ ...type,
3864
+ async run(ctx) {
3865
+ if (signal.aborted)
3866
+ throw stop();
3867
+ const outcome = await run.call(type, ctx);
3868
+ if (signal.aborted)
3869
+ throw stop();
3870
+ return outcome;
3871
+ }
3872
+ };
3873
+ }
3874
+ function laneStore(store, keep = () => true) {
3082
3875
  const view = Object.create(store);
3083
3876
  view.listOccurrences = async (filter = {}) => {
3084
3877
  const found = await store.listOccurrences(filter);
@@ -3086,7 +3879,8 @@ function laneStore(store) {
3086
3879
  return found;
3087
3880
  const kept = [];
3088
3881
  for (const o of found) {
3089
- if ((await store.getTask(o.taskId))?.status === "active")
3882
+ const task = await store.getTask(o.taskId);
3883
+ if (task?.status === "active" && keep(task))
3090
3884
  kept.push(o);
3091
3885
  }
3092
3886
  return kept;
@@ -3098,7 +3892,8 @@ async function runNotifyTick(d, signal) {
3098
3892
  return { kind: "no-dm" };
3099
3893
  if (signal?.aborted)
3100
3894
  return { kind: "aborted" };
3101
- const store = laneStore(d.store);
3895
+ const kind = d.kind ?? "notify";
3896
+ const store = laneStore(d.store, (task) => takesType(kind, task.type));
3102
3897
  const notifier = createDmNotifier({
3103
3898
  store,
3104
3899
  claims: d.claims,
@@ -3108,7 +3903,11 @@ async function runNotifyTick(d, signal) {
3108
3903
  ...signal ? { signal } : {},
3109
3904
  ...d.health ? { health: d.health } : {}
3110
3905
  });
3111
- const lanes = new Lanes({ store, clock: d.clock, types: d.types, notifier });
3906
+ let types = d.types;
3907
+ if (kind === "poll" && signal) {
3908
+ types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
3909
+ }
3910
+ const lanes = new Lanes({ store, clock: d.clock, types, notifier, ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {} });
3112
3911
  const result = await lanes.tickNotify();
3113
3912
  return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3114
3913
  }
@@ -3629,6 +4428,162 @@ class SqliteStore {
3629
4428
  }
3630
4429
  }
3631
4430
 
4431
+ // plugins/tracker/src/edit.ts
4432
+ async function owned(d, user, taskId, type) {
4433
+ const task = await ownLiveTask(d, user, taskId);
4434
+ if (!task || task.type !== type)
4435
+ return NO_SUCH_TASK;
4436
+ if (!editable(task))
4437
+ return FINISHED;
4438
+ return task;
4439
+ }
4440
+ function same(a, b) {
4441
+ return JSON.stringify(a) === JSON.stringify(b);
4442
+ }
4443
+ async function applyEdit(d, task, user, patch) {
4444
+ const fields = Object.keys(patch);
4445
+ if (fields.length === 0)
4446
+ return task;
4447
+ const at = d.clock.now().toISOString();
4448
+ const updated = await d.store.updateTask(task.id, { ...patch, at });
4449
+ await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "edited", detail: fields.join(", "), at });
4450
+ return updated;
4451
+ }
4452
+ async function nextRun(d, task) {
4453
+ const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
4454
+ return queued[0] ?? null;
4455
+ }
4456
+ function savedText(task, user, next, now, what) {
4457
+ const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
4458
+ const when = task.status === "paused" ? "paused" : next ? formatInstant(next.dueAt, user.timeZone, now) : "nothing scheduled";
4459
+ return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
4460
+ }
4461
+ var blank = (v) => v === undefined || v.trim() === "";
4462
+ async function editReminder(d, user, taskId, input) {
4463
+ const task = await owned(d, user, taskId, "reminder");
4464
+ if (typeof task === "string")
4465
+ return { ok: false, error: task };
4466
+ const config = task.config ?? {};
4467
+ const text = blank(input.text) ? String(config.text ?? "") : input.text;
4468
+ const repeat = input.repeat ?? repeatOf(task.schedule);
4469
+ const plan = reminderPlan(d, user, { text, repeat, ...input.when !== undefined ? { when: input.when } : {} }, task.schedule);
4470
+ if (!plan.ok)
4471
+ return plan;
4472
+ let updated = config.text === plan.text ? task : await applyEdit(d, task, user, { title: clip(plan.text, MAX_TITLE), config: { ...config, text: plan.text } });
4473
+ if (!same(plan.schedule, task.schedule))
4474
+ updated = await rescheduleKeepingSnoozes(d, updated, user, plan.schedule, user.id);
4475
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(reminder)") };
4476
+ }
4477
+ var DAY_MS3 = 24 * 60 * 60 * 1000;
4478
+ function periodDateFrom(s, date, owner) {
4479
+ const ctx = { zone: owner.timeZone, preferredHour: owner.preferredHour };
4480
+ let after = new Date(Date.parse(`${date}T00:00:00Z`) - ((s.leadDays ?? 0) + 2) * DAY_MS3);
4481
+ for (let i = 0;i < 1000; i++) {
4482
+ const due = nextDue(s, after, ctx);
4483
+ if (!due)
4484
+ return null;
4485
+ const on = periodDate(s, due, owner.timeZone);
4486
+ if (on >= date)
4487
+ return on;
4488
+ after = due;
4489
+ }
4490
+ return null;
4491
+ }
4492
+ function renewalDate(task, owner, now) {
4493
+ const s = task.schedule;
4494
+ if (s?.kind !== "period")
4495
+ return "";
4496
+ const w = wallClock(now, owner.timeZone);
4497
+ const today = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
4498
+ if (s.anchor >= today)
4499
+ return s.anchor;
4500
+ return periodDateFrom(s, today, owner) ?? s.anchor;
4501
+ }
4502
+ async function askedAbout(d, task, owner, date) {
4503
+ const old = task.schedule;
4504
+ if (old?.kind !== "period")
4505
+ return false;
4506
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
4507
+ return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
4508
+ }
4509
+ async function editRenewal(d, user, taskId, input) {
4510
+ const task = await owned(d, user, taskId, "renewal");
4511
+ if (typeof task === "string")
4512
+ return { ok: false, error: task };
4513
+ if (task.schedule?.kind !== "period")
4514
+ return { ok: false, error: FINISHED };
4515
+ const old = task.schedule;
4516
+ const now = d.clock.now();
4517
+ const config = task.config;
4518
+ const state = task.state;
4519
+ const carried = state && typeof state.amount === "number" ? state.amount : null;
4520
+ const note = input.note ?? config.note;
4521
+ const plan = renewalPlan(d, user, {
4522
+ name: blank(input.name) ? task.title : input.name,
4523
+ amount: input.amount ?? carried ?? config.amount,
4524
+ currency: blank(input.currency) ? config.currency : input.currency,
4525
+ renews: blank(input.renews) ? renewalDate(task, user, now) : input.renews,
4526
+ every: input.every ?? old.every,
4527
+ unit: input.unit ?? old.unit,
4528
+ lead: input.lead ?? old.leadDays ?? 0,
4529
+ ...note !== undefined ? { note } : {}
4530
+ });
4531
+ if (!plan.ok)
4532
+ return plan;
4533
+ const sameShape = plan.schedule.every === old.every && plan.schedule.unit === old.unit && plan.schedule.leadDays === (old.leadDays ?? 0);
4534
+ const schedule = sameShape && periodDateFrom(old, plan.schedule.anchor, user) === plan.schedule.anchor ? old : plan.schedule;
4535
+ const problems = scheduleProblems(schedule);
4536
+ if (problems.length > 0)
4537
+ return { ok: false, error: `That schedule does not work: ${problems.join("; ")}.` };
4538
+ const patch = {};
4539
+ if (plan.name !== task.title)
4540
+ patch.title = plan.name;
4541
+ const newAmount = plan.amount !== (carried ?? config.amount);
4542
+ const nextConfig = { amount: newAmount ? plan.amount : config.amount, currency: plan.currency, ...plan.note ? { note: plan.note } : {} };
4543
+ if (!same(nextConfig, config))
4544
+ patch.config = nextConfig;
4545
+ if (newAmount && carried !== null)
4546
+ patch.state = { ...state, amount: plan.amount };
4547
+ let updated = await applyEdit(d, task, user, patch);
4548
+ let next = await nextRun(d, updated);
4549
+ if (!same(schedule, old)) {
4550
+ const asked = await askedAbout(d, task, user, schedule.anchor);
4551
+ updated = await rescheduleKeepingSnoozes(d, updated, user, schedule, user.id);
4552
+ next = (asked ? null : await firstAskIfDue(d, updated, user, schedule)) ?? await nextRun(d, updated);
4553
+ }
4554
+ const ask = updated.status === "paused" ? "paused" : askText(next, plan.days, user, now);
4555
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
4556
+ return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
4557
+ }
4558
+ async function editPrice(d, user, taskId, input) {
4559
+ const task = await owned(d, user, taskId, "price");
4560
+ if (typeof task === "string")
4561
+ return { ok: false, error: task };
4562
+ const config = task.config;
4563
+ const schedule = task.schedule;
4564
+ const plan = priceSettingsPlan({
4565
+ name: input.name ?? task.title,
4566
+ hours: input.hours ?? (schedule?.kind === "poll" ? schedule.every : undefined),
4567
+ drop: input.drop ?? config.dropPercent ?? DEFAULT_DROP_PERCENT,
4568
+ baseline: input.baseline ?? config.baseline ?? DEFAULT_BASELINE
4569
+ });
4570
+ if (!plan.ok)
4571
+ return plan;
4572
+ const patch = {};
4573
+ const title = plan.name || titleFor(config.url);
4574
+ if (title !== task.title)
4575
+ patch.title = title;
4576
+ const nextConfig = { ...config, dropPercent: plan.drop, baseline: plan.baseline };
4577
+ if (!same(nextConfig, config))
4578
+ patch.config = nextConfig;
4579
+ let updated = await applyEdit(d, task, user, patch);
4580
+ if (schedule?.kind === "poll" && (schedule.every !== plan.hours || schedule.unit !== "hour")) {
4581
+ const next = { ...schedule, every: plan.hours, unit: "hour" };
4582
+ updated = await rescheduleKeepingSnoozes(d, updated, user, next, user.id);
4583
+ }
4584
+ return { ok: true, task: updated, text: savedText(updated, user, await nextRun(d, updated), d.clock.now(), "(price)") };
4585
+ }
4586
+
3632
4587
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
3633
4588
  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 {
3634
4589
  box-sizing: border-box;
@@ -4797,6 +5752,8 @@ var LAYOUT_CSS = `
4797
5752
  .tr-main { max-width: 60rem; margin: 0 auto; padding: var(--rb-space-4); }
4798
5753
  .tr-main > section { margin-bottom: var(--rb-space-5); }
4799
5754
  .tr-stack { display: grid; gap: var(--rb-space-3); max-width: 28rem; }
5755
+ .tr-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--rb-space-2); }
5756
+ .tr-row form { margin: 0; }
4800
5757
  .tr-foot { margin-top: var(--rb-space-5); }
4801
5758
  `;
4802
5759
  var STYLESHEET = `${THEME_CSS}
@@ -4898,6 +5855,28 @@ function readCookie(request, name) {
4898
5855
  function cookie(name, value, o) {
4899
5856
  return `${name}=${value}; Path=${o.base}/; Max-Age=${o.maxAgeSeconds}; HttpOnly; Secure; SameSite=${o.sameSite}`;
4900
5857
  }
5858
+ async function readBody(request, max) {
5859
+ const declared = request.headers.get("content-length");
5860
+ if (declared !== null && !(/^[0-9]{1,12}$/.test(declared) && Number(declared) <= max))
5861
+ return null;
5862
+ if (!request.body)
5863
+ return "";
5864
+ const reader = request.body.getReader();
5865
+ const chunks = [];
5866
+ let size = 0;
5867
+ for (;; ) {
5868
+ const { done, value } = await reader.read();
5869
+ if (done)
5870
+ break;
5871
+ size += value.byteLength;
5872
+ if (size > max) {
5873
+ await reader.cancel().catch(() => {});
5874
+ return null;
5875
+ }
5876
+ chunks.push(value);
5877
+ }
5878
+ return Buffer.concat(chunks).toString("utf8");
5879
+ }
4901
5880
 
4902
5881
  // plugins/tracker/src/web/pages.ts
4903
5882
  function name3(u) {
@@ -4943,30 +5922,39 @@ function cadence(entry, viewer, now) {
4943
5922
  return "once";
4944
5923
  return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
4945
5924
  }
4946
- function tasksPage(v, data, now) {
5925
+ function newLinks(v) {
5926
+ return html`<p class="tr-row">
5927
+ <a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
5928
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
5929
+ <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
5930
+ </p>`;
5931
+ }
5932
+ function tasksPage(v, data, now, flash = null) {
4947
5933
  const rows = data.entries.map((e) => html`<tr>
4948
5934
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
4949
5935
  <td>${e.from ? name3(e.from) : "you"}</td>
4950
5936
  <td>${when2(e, v.user, now)}</td>
4951
5937
  <td>${cadence(e, v.user, now)}</td>
4952
5938
  </tr>`);
4953
- const active = data.entries.length === 0 ? html`<p class="rb-muted">You have no active tasks. <code>/remind</code> in Discord sets one.</p>` : html`<div class="rb-table-scroll"><table class="rb-table">
5939
+ 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">
4954
5940
  <thead><tr><th scope="col">Task</th><th scope="col">From</th><th scope="col">Next</th><th scope="col">Repeats</th></tr></thead>
4955
5941
  <tbody>${rows}</tbody>
4956
5942
  </table></div>`;
4957
5943
  const paused = data.paused.length === 0 ? null : html`<section>
4958
5944
  <h2>Paused</h2>
4959
- <ul>${data.paused.map((p) => html`<li><a class="rb-link" href="${v.base}/tasks/${p.task.id}">${p.task.title}</a>${p.held.length > 0 ? html` -- I could not DM ${p.held.join(", ")}; <code>/task resume ${p.task.id}</code> goes on without them.` : null}</li>`)}</ul>
5945
+ <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>
4960
5946
  </section>`;
4961
5947
  return framed(v, "My tasks", html`<section>
4962
5948
  <h1>My tasks</h1>
5949
+ ${flash}
4963
5950
  <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
5951
+ ${newLinks(v)}
4964
5952
  ${active}
4965
5953
  </section>
4966
5954
  ${paused}
4967
5955
  <p class="rb-muted tr-foot">${ADMIN_DISCLOSURE}</p>`);
4968
5956
  }
4969
- function historyPage(v, h) {
5957
+ function historyPage(v, h, extra = {}) {
4970
5958
  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">
4971
5959
  <thead><tr><th scope="col">Due</th><th scope="col">Status</th><th scope="col">Answers</th></tr></thead>
4972
5960
  <tbody>${h.runs.map((r) => html`<tr>
@@ -4978,7 +5966,9 @@ function historyPage(v, h) {
4978
5966
  return framed(v, h.task.title, html`<section>
4979
5967
  <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
4980
5968
  <h1>${h.task.title}</h1>
4981
- <p>${h.task.type}, ${h.task.status}, ${h.cadence}. Next: ${h.next ?? "nothing scheduled"}.</p>
5969
+ ${extra.flash ?? null}
5970
+ <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>
5971
+ ${extra.controls ?? null}
4982
5972
  </section>
4983
5973
  <section>
4984
5974
  <h2>Runs</h2>
@@ -5006,7 +5996,7 @@ ${form.error ? html`<div class="rb-alert rb-alert--danger" role="alert"><p class
5006
5996
  </div>
5007
5997
  <div class="rb-field">
5008
5998
  <label class="rb-label" for="zone">Time zone</label>
5009
- <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="64" required${form.error ? html` aria-invalid="true"` : null}>
5999
+ <input class="rb-input" id="zone" name="zone" value="${form.zone}" maxlength="${MAX_ZONE}" required${form.error ? html` aria-invalid="true"` : null}>
5010
6000
  <p class="rb-field__help">An IANA name, such as America/New_York or Europe/London.</p>
5011
6001
  </div>
5012
6002
  <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
@@ -5021,6 +6011,390 @@ function errorPage(base, title, message) {
5021
6011
  return page({ base, title, body: html`<section class="rb-card"><h1>${title}</h1><p>${message}</p></section>` });
5022
6012
  }
5023
6013
 
6014
+ // plugins/tracker/src/web/editor-pages.ts
6015
+ var EDITOR_TYPES = ["reminder", "renewal", "price"];
6016
+ var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
6017
+ var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
6018
+ var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
6019
+ var PRICE_SETTINGS = [
6020
+ { name: "name", label: "Name", kind: "text", maxlength: MAX_TITLE, help: "Leave it empty to use the page's address." },
6021
+ { name: "hours", label: "Hours between checks", kind: "number", min: 1, max: MAX_POLL_HOURS, help: `1 to ${MAX_POLL_HOURS}; 12 when empty.` },
6022
+ { name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
6023
+ { name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
6024
+ ];
6025
+ function fieldsFor(type, mode) {
6026
+ switch (type) {
6027
+ case "reminder":
6028
+ return [
6029
+ { name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
6030
+ {
6031
+ name: "when",
6032
+ label: "When",
6033
+ kind: "text",
6034
+ maxlength: MAX_WHEN,
6035
+ 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."
6036
+ },
6037
+ { name: "repeat", label: "Repeat", kind: "select", options: REPEAT_OPTIONS }
6038
+ ];
6039
+ case "renewal":
6040
+ return [
6041
+ { name: "name", label: "What renews", kind: "text", required: true, maxlength: MAX_TITLE, help: "A subscription, a domain, a warranty." },
6042
+ { name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true },
6043
+ { name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
6044
+ { name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
6045
+ { name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
6046
+ { name: "every", label: "Every how many of those", kind: "number", min: 1, max: MAX_EVERY, help: "1 when empty." },
6047
+ { name: "lead", label: "Days before the date to ask", kind: "number", min: 0, max: MAX_LEAD_DAYS, help: "7 when empty." },
6048
+ { name: "note", label: "Note", kind: "text", maxlength: MAX_NOTE, help: "Carried on every ask: where to cancel, which account." }
6049
+ ];
6050
+ case "price":
6051
+ return mode === "edit" ? PRICE_SETTINGS : [
6052
+ { name: "url", label: "Product page", kind: "url", required: true, maxlength: MAX_URL },
6053
+ ...PRICE_SETTINGS,
6054
+ { name: "near", label: "Words just before the price", kind: "text", maxlength: MAX_NEAR, help: "Only if I cannot find the price on my own." }
6055
+ ];
6056
+ }
6057
+ }
6058
+ function prose(text) {
6059
+ return html`${text.split("`").map((part, i) => i % 2 === 1 ? html`<code>${part}</code>` : part)}`;
6060
+ }
6061
+ function field(f, values) {
6062
+ const value = values[f.name] ?? "";
6063
+ const help = f.help ? html`<p class="rb-field__help">${f.help}</p>` : null;
6064
+ if (f.kind === "select") {
6065
+ return html`<div class="rb-field">
6066
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6067
+ <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>
6068
+ ${help}
6069
+ </div>`;
6070
+ }
6071
+ const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
6072
+ return html`<div class="rb-field">
6073
+ <label class="rb-label" for="${f.name}">${f.label}</label>
6074
+ <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}>
6075
+ ${help}
6076
+ </div>`;
6077
+ }
6078
+ function alert(error) {
6079
+ 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;
6080
+ }
6081
+ function taskHref(v, id, action = "") {
6082
+ return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
6083
+ }
6084
+ var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
6085
+ function newTaskPage(v, type, values, error) {
6086
+ 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;
6087
+ return framed(v, `New ${NOUN[type]}`, html`<section>
6088
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
6089
+ <h1>New ${NOUN[type]}</h1>
6090
+ ${intro}
6091
+ ${alert(error)}
6092
+ <form method="post" action="${v.base}/new/${type}" class="tr-stack">
6093
+ <input type="hidden" name="csrf" value="${v.csrf}">
6094
+ ${fieldsFor(type, "new").map((f) => field(f, values))}
6095
+ <div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
6096
+ </form>
6097
+ </section>`);
6098
+ }
6099
+ function editTaskPage(v, task, values, o) {
6100
+ const type = task.type;
6101
+ return framed(v, `Edit ${task.title}`, html`<section>
6102
+ <p><a class="rb-link" href="${taskHref(v, task.id)}">Back to the task</a></p>
6103
+ <h1>Edit ${task.title}</h1>
6104
+ <p class="rb-muted">Now: ${o.now}.${task.status === "paused" ? " Paused." : ""}</p>
6105
+ ${o.page ? html`<p class="rb-muted">Page: ${o.page}. To track another page, make a new price tracker.</p>` : null}
6106
+ ${alert(o.error)}
6107
+ <form method="post" action="${taskHref(v, task.id, "edit")}" class="tr-stack">
6108
+ <input type="hidden" name="csrf" value="${v.csrf}">
6109
+ ${fieldsFor(type, "edit").map((f) => field(f, values))}
6110
+ <div><button class="rb-btn rb-btn--primary" type="submit">Save</button></div>
6111
+ </form>
6112
+ </section>`);
6113
+ }
6114
+ function confirmDeletePage(v, task) {
6115
+ return framed(v, `Delete ${task.title}`, html`<section class="rb-card">
6116
+ <h1>Delete ${task.title}?</h1>
6117
+ <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>
6118
+ <form method="post" action="${taskHref(v, task.id, "delete")}" class="tr-row">
6119
+ <input type="hidden" name="csrf" value="${v.csrf}">
6120
+ <input type="hidden" name="confirm" value="yes">
6121
+ <button class="rb-btn rb-btn--danger" type="submit">Delete</button>
6122
+ <a class="rb-btn rb-btn--ghost" href="${taskHref(v, task.id)}">Keep it</a>
6123
+ </form>
6124
+ </section>`);
6125
+ }
6126
+ function post(v, task, action, label, style) {
6127
+ 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>`;
6128
+ }
6129
+ function ownerControls(v, task) {
6130
+ if (task.status === "archived")
6131
+ return null;
6132
+ const live = task.status === "active" || task.status === "paused";
6133
+ const editor = EDITOR_TYPES.includes(task.type);
6134
+ return html`<div class="tr-row">
6135
+ ${live && editor ? html`<a class="rb-btn rb-btn--primary rb-btn--sm" href="${taskHref(v, task.id, "edit")}">Edit</a>` : null}
6136
+ ${task.status === "active" ? post(v, task, "pause", "Pause", "rb-btn--ghost") : null}
6137
+ ${task.status === "paused" ? post(v, task, "resume", "Resume", "rb-btn--accent") : null}
6138
+ ${post(v, task, "delete", "Delete", "rb-btn--danger")}
6139
+ </div>`;
6140
+ }
6141
+ var NOTICES = {
6142
+ created: "Created.",
6143
+ saved: "Saved.",
6144
+ paused: "Paused: nothing is sent until you resume it.",
6145
+ resumed: "Resumed.",
6146
+ deleted: "Deleted. Its history is kept."
6147
+ };
6148
+ function notice(key, error) {
6149
+ if (error)
6150
+ return html`<div class="rb-alert rb-alert--danger" role="alert"><p>${prose(error)}</p></div>`;
6151
+ const text = key !== null && Object.hasOwn(NOTICES, key) ? NOTICES[key] : undefined;
6152
+ return text ? html`<div class="rb-alert rb-alert--success" role="status"><p class="rb-alert__title">${text}</p></div>` : null;
6153
+ }
6154
+
6155
+ // plugins/tracker/src/web/editor.ts
6156
+ var READING = "I am still reading the page of your last new price tracker; try again when it is done.";
6157
+ var GONE = "You are no longer on this tracker's list.";
6158
+ function asViewer(e, fn) {
6159
+ return e.queue(async () => {
6160
+ const user = await e.d.store.getUser(e.v.user.id);
6161
+ return user ? fn(user) : { ok: false, error: GONE };
6162
+ });
6163
+ }
6164
+ var str2 = (form, name) => form.get(name) ?? "";
6165
+ var opt = (form, name) => str2(form, name).trim() === "" ? undefined : str2(form, name);
6166
+ function int(form, name) {
6167
+ const raw = str2(form, name).trim();
6168
+ if (raw === "")
6169
+ return;
6170
+ return /^[0-9]{1,9}$/.test(raw) ? Number(raw) : Number.NaN;
6171
+ }
6172
+ function decimal(form, name) {
6173
+ const raw = str2(form, name).trim();
6174
+ if (raw === "")
6175
+ return;
6176
+ return /^[0-9]{1,12}(\.[0-9]{1,6})?$/.test(raw) ? Number(raw) : Number.NaN;
6177
+ }
6178
+ function typed(type, mode, form) {
6179
+ return Object.fromEntries(fieldsFor(type, mode).map((f) => [f.name, str2(form, f.name)]));
6180
+ }
6181
+ function reminderInput(form) {
6182
+ const when = opt(form, "when");
6183
+ return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
6184
+ }
6185
+ function renewalInput(form) {
6186
+ const every = int(form, "every");
6187
+ const lead = int(form, "lead");
6188
+ const unit = opt(form, "unit");
6189
+ const note = opt(form, "note");
6190
+ return {
6191
+ name: str2(form, "name"),
6192
+ amount: decimal(form, "amount") ?? Number.NaN,
6193
+ currency: str2(form, "currency"),
6194
+ renews: str2(form, "renews"),
6195
+ ...every !== undefined ? { every } : {},
6196
+ ...lead !== undefined ? { lead } : {},
6197
+ ...unit !== undefined ? { unit } : {},
6198
+ ...note !== undefined ? { note } : {}
6199
+ };
6200
+ }
6201
+ function priceSettings(form) {
6202
+ const name = opt(form, "name");
6203
+ const hours = int(form, "hours");
6204
+ const drop = decimal(form, "drop");
6205
+ const baseline = opt(form, "baseline");
6206
+ return {
6207
+ ...name !== undefined ? { name } : {},
6208
+ ...hours !== undefined ? { hours } : {},
6209
+ ...drop !== undefined ? { drop } : {},
6210
+ ...baseline !== undefined ? { baseline } : {}
6211
+ };
6212
+ }
6213
+ function reminderEdit(form) {
6214
+ const text = opt(form, "text");
6215
+ const when = opt(form, "when");
6216
+ const repeat = opt(form, "repeat");
6217
+ return { ...text !== undefined ? { text } : {}, ...when !== undefined ? { when } : {}, ...repeat !== undefined ? { repeat } : {} };
6218
+ }
6219
+ function renewalEdit(form) {
6220
+ const name = opt(form, "name");
6221
+ const amount = decimal(form, "amount");
6222
+ const currency = opt(form, "currency");
6223
+ const renews = opt(form, "renews");
6224
+ const every = int(form, "every");
6225
+ const lead = int(form, "lead");
6226
+ const unit = opt(form, "unit");
6227
+ return {
6228
+ ...name !== undefined ? { name } : {},
6229
+ ...amount !== undefined ? { amount } : {},
6230
+ ...currency !== undefined ? { currency } : {},
6231
+ ...renews !== undefined ? { renews } : {},
6232
+ ...every !== undefined ? { every } : {},
6233
+ ...lead !== undefined ? { lead } : {},
6234
+ ...unit !== undefined ? { unit } : {},
6235
+ ...form.has("note") ? { note: str2(form, "note") } : {}
6236
+ };
6237
+ }
6238
+ function priceEdit(form) {
6239
+ const { name: _name, ...rest } = priceSettings(form);
6240
+ return { ...rest, ...form.has("name") ? { name: str2(form, "name") } : {} };
6241
+ }
6242
+ var DEFAULTS = {
6243
+ reminder: { repeat: "none" },
6244
+ renewal: { unit: "year", every: "1", lead: "7" },
6245
+ price: { hours: "12", drop: "10", baseline: "last" }
6246
+ };
6247
+ function newGet(e, type) {
6248
+ return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
6249
+ }
6250
+ async function newPost(e, type, form) {
6251
+ let made;
6252
+ if (type === "reminder")
6253
+ made = await asViewer(e, (u) => createReminder(e.d, u, reminderInput(form)));
6254
+ else if (type === "renewal")
6255
+ made = await asViewer(e, (u) => createRenewal(e.d, u, renewalInput(form)));
6256
+ else {
6257
+ const near = opt(form, "near");
6258
+ const input = { url: str2(form, "url"), ...priceSettings(form), ...near !== undefined ? { near } : {} };
6259
+ const who = e.v.user.id;
6260
+ if (e.reading.has(who))
6261
+ made = { ok: false, error: READING };
6262
+ else {
6263
+ e.reading.add(who);
6264
+ try {
6265
+ const started = await asViewer(e, (u) => startPrice(e.d, u, input));
6266
+ if (!started.ok)
6267
+ made = started;
6268
+ else {
6269
+ const { pending } = started;
6270
+ const seen = await previewPrice(e.d, pending);
6271
+ made = await asViewer(e, (u) => finishPrice(e.d, u, pending, seen));
6272
+ }
6273
+ } finally {
6274
+ e.reading.delete(who);
6275
+ }
6276
+ }
6277
+ }
6278
+ if (!made.ok)
6279
+ return htmlResponse(newTaskPage(e.v, type, typed(type, "new", form), made.error), 400);
6280
+ return redirect(`${taskHref(e.v, made.task.id)}?done=created`);
6281
+ }
6282
+ async function taskPage(e, id, done, error) {
6283
+ const view = await loadHistory(e.d, e.v.user, id);
6284
+ if (!view)
6285
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6286
+ const controls = view.task.ownerId === e.v.user.id ? ownerControls(e.v, view.task) : null;
6287
+ return htmlResponse(historyPage(e.v, view, { controls, flash: notice(done, error) }), error ? 400 : 200);
6288
+ }
6289
+ function current(d, user, task) {
6290
+ const now = d.clock.now();
6291
+ if (task.type === "reminder")
6292
+ return { text: String(task.config?.text ?? ""), when: "", repeat: repeatOf(task.schedule) };
6293
+ if (task.type === "renewal" && task.schedule?.kind === "period") {
6294
+ const s = task.schedule;
6295
+ const c = task.config;
6296
+ const carried = task.state?.amount;
6297
+ const renews = renewalDate(task, user, now);
6298
+ return {
6299
+ name: task.title,
6300
+ amount: String(typeof carried === "number" ? carried : c.amount),
6301
+ currency: c.currency,
6302
+ renews,
6303
+ unit: s.unit,
6304
+ every: String(s.every),
6305
+ lead: String(s.leadDays ?? 0),
6306
+ note: c.note ?? ""
6307
+ };
6308
+ }
6309
+ if (task.type !== "price")
6310
+ return {};
6311
+ const c = task.config;
6312
+ const every = task.schedule?.kind === "poll" ? task.schedule.every : 12;
6313
+ return { name: task.title, hours: String(every), drop: String(c.dropPercent ?? 10), baseline: c.baseline ?? "last" };
6314
+ }
6315
+ function editPage(e, task, values, error) {
6316
+ const now = task.schedule ? describeSchedule(task.schedule, e.v.user, e.v.user.timeZone, e.d.clock.now()) : "no schedule";
6317
+ const page = task.type === "price" ? String(task.config.url) : undefined;
6318
+ return editTaskPage(e.v, task, values, { now, ...page !== undefined ? { page } : {}, ...error !== undefined ? { error } : {} });
6319
+ }
6320
+ async function editableTask(e, id) {
6321
+ const task = await ownLiveTask(e.d, e.v.user, id);
6322
+ return task && (task.type === "reminder" || task.type === "renewal" || task.type === "price") ? task : null;
6323
+ }
6324
+ async function editGet(e, id) {
6325
+ const task = await editableTask(e, id);
6326
+ if (!task)
6327
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6328
+ if (task.status !== "active" && task.status !== "paused")
6329
+ return redirect(taskHref(e.v, task.id));
6330
+ return htmlResponse(editPage(e, task, current(e.d, e.v.user, task)));
6331
+ }
6332
+ async function editPost(e, id, form) {
6333
+ const task = await editableTask(e, id);
6334
+ if (!task)
6335
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6336
+ 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)));
6337
+ if (saved.ok)
6338
+ return redirect(`${taskHref(e.v, task.id)}?done=saved`);
6339
+ if (saved.error === NO_SUCH_TASK)
6340
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6341
+ return htmlResponse(editPage(e, task, typed(task.type, "edit", form), saved.error), 400);
6342
+ }
6343
+ async function actionPost(e, id, action, form) {
6344
+ const task = await ownLiveTask(e.d, e.v.user, id);
6345
+ if (!task)
6346
+ return htmlResponse(notFoundPage(e.v.base, e.v), 404);
6347
+ if (action === "delete" && str2(form, "confirm") !== "yes")
6348
+ return htmlResponse(confirmDeletePage(e.v, task));
6349
+ const act = action === "pause" ? pauseTask : action === "resume" ? resumeTask : deleteTask;
6350
+ const done = await asViewer(e, (u) => act(e.d, u, task.id));
6351
+ if (!done.ok)
6352
+ return done.error === NO_SUCH_TASK ? htmlResponse(notFoundPage(e.v.base, e.v), 404) : taskPage(e, task.id, null, done.error);
6353
+ if (action === "delete")
6354
+ return redirect(`${e.v.base}/?done=deleted`);
6355
+ return redirect(`${taskHref(e.v, task.id)}?done=${action === "pause" ? "paused" : "resumed"}`);
6356
+ }
6357
+
6358
+ // plugins/tracker/src/web/routes.ts
6359
+ var TASK_PATH = /^\/tasks\/([^/]+)$/;
6360
+ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
6361
+ var NEW_PATH = /^\/new\/([a-z]+)$/;
6362
+ function route(path) {
6363
+ if (path === "/")
6364
+ return { kind: "tasks" };
6365
+ if (path === "/settings")
6366
+ return { kind: "settings" };
6367
+ if (path === "/logout")
6368
+ return { kind: "logout" };
6369
+ const created = NEW_PATH.exec(path);
6370
+ if (created) {
6371
+ const type = created[1];
6372
+ return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
6373
+ }
6374
+ const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
6375
+ if (!one)
6376
+ return null;
6377
+ let id;
6378
+ try {
6379
+ id = decodeURIComponent(one[1] ?? "");
6380
+ } catch {
6381
+ id = "";
6382
+ }
6383
+ const action = one[2];
6384
+ if (action === undefined)
6385
+ return { kind: "task", id };
6386
+ if (action === "edit")
6387
+ return { kind: "edit", id };
6388
+ return { kind: "act", id, action };
6389
+ }
6390
+ function methodsOf(r) {
6391
+ if (r.kind === "logout" || r.kind === "act")
6392
+ return "POST";
6393
+ if (r.kind === "settings" || r.kind === "edit" || r.kind === "new")
6394
+ return "GET, POST";
6395
+ return "GET";
6396
+ }
6397
+
5024
6398
  // plugins/tracker/src/web/sessions.ts
5025
6399
  var SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5026
6400
 
@@ -5062,8 +6436,7 @@ var MEMBER_CHECK_TIMEOUT_MS = 3000;
5062
6436
  var MEMBER_RETRY_MS = 60 * 1000;
5063
6437
  var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
5064
6438
  var RECHECK_FAILED = "You are signed out: I could not check that you are still a member of this tracker's server.";
5065
- var MAX_FORM_BYTES = 8 * 1024;
5066
- var TASK_PATH = /^\/tasks\/([^/]+)$/;
6439
+ var MAX_FORM_BYTES = 32 * 1024;
5067
6440
  function plain(status, text, extra = {}) {
5068
6441
  return new Response(text, {
5069
6442
  status,
@@ -5088,6 +6461,7 @@ function createWebHandler(w) {
5088
6461
  const clear = (name) => cookie(name, "", { base, maxAgeSeconds: 0, sameSite: "Lax" });
5089
6462
  const inFlight = new Map;
5090
6463
  const failedAt = new Map;
6464
+ const reading = new Set;
5091
6465
  async function authenticate(d, request) {
5092
6466
  const id = readCookie(request, SESSION_COOKIE);
5093
6467
  if (!id)
@@ -5157,10 +6531,8 @@ function createWebHandler(w) {
5157
6531
  const type = request.headers.get("content-type") ?? "";
5158
6532
  if (!type.toLowerCase().startsWith("application/x-www-form-urlencoded"))
5159
6533
  return null;
5160
- const body = await request.text();
5161
- if (Buffer.byteLength(body) > MAX_FORM_BYTES)
5162
- return null;
5163
- return new URLSearchParams(body);
6534
+ const body = await readBody(request, MAX_FORM_BYTES);
6535
+ return body === null ? null : new URLSearchParams(body);
5164
6536
  }
5165
6537
  function forbidden() {
5166
6538
  return htmlResponse(errorPage(base, "Refused", "That form was stale or came from somewhere else. Go back, reload the page and try again."), 403);
@@ -5198,7 +6570,7 @@ function createWebHandler(w) {
5198
6570
  let error = null;
5199
6571
  if (!/^([0-9]|1[0-9]|2[0-3])$/.test(hour))
5200
6572
  error = "The preferred hour must be a whole hour from 0 to 23.";
5201
- else if (zone === "" || zone.length > 64)
6573
+ else if (zone === "" || zone.length > MAX_ZONE)
5202
6574
  error = "Give a time zone, such as America/New_York.";
5203
6575
  else {
5204
6576
  error = await w.queue(async () => {
@@ -5236,45 +6608,52 @@ function createWebHandler(w) {
5236
6608
  return only("GET") ?? htmlResponse(signInHelpPage(base, url.searchParams.get("out") === "1" ? "You are signed out." : undefined));
5237
6609
  if (path === "/login")
5238
6610
  return method === "GET" ? loginGet(d, url) : loginPost(d, request);
5239
- const task = TASK_PATH.exec(path);
5240
- const route = path === "/" ? "tasks" : path === "/settings" ? "settings" : path === "/logout" ? "logout" : task ? "history" : null;
5241
- if (route === null)
6611
+ const r = route(path);
6612
+ if (r === null)
5242
6613
  return htmlResponse(notFoundPage(base), 404);
5243
- const wrong = route === "logout" ? only("POST") : route === "settings" ? null : only("GET");
5244
- if (wrong)
5245
- return wrong;
6614
+ const allowed = methodsOf(r);
6615
+ if (!allowed.split(", ").includes(method))
6616
+ return plain(405, "Method not allowed", { Allow: allowed });
5246
6617
  const auth = await authenticate(d, request);
5247
6618
  if (auth.kind === "signed-out")
5248
6619
  return htmlResponse(signInHelpPage(base, auth.note), 403, { "Set-Cookie": clear(SESSION_COOKIE) });
5249
6620
  if (auth.kind !== "ok")
5250
6621
  return redirect(`${base}/signin`, auth.kind === "stale" ? [clear(SESSION_COOKIE)] : []);
5251
6622
  const v = { base, user: auth.user, csrf: auth.session.csrf };
6623
+ const e = { d, v, queue: w.queue, reading };
5252
6624
  if (method === "POST") {
5253
6625
  const form = await readForm(request);
5254
6626
  if (!form)
5255
6627
  return plain(400, "Bad request");
5256
6628
  if (!safeEqual(auth.session.csrf, form.get("csrf") ?? ""))
5257
6629
  return forbidden();
5258
- if (route === "logout") {
5259
- d.sessions.delete(auth.id);
5260
- return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
6630
+ switch (r.kind) {
6631
+ case "logout":
6632
+ d.sessions.delete(auth.id);
6633
+ return redirect(`${base}/signin?out=1`, [clear(SESSION_COOKIE)]);
6634
+ case "settings":
6635
+ return settingsPost(d, v, form);
6636
+ case "new":
6637
+ return newPost(e, r.type, form);
6638
+ case "edit":
6639
+ return editPost(e, r.id, form);
6640
+ case "act":
6641
+ return actionPost(e, r.id, r.action, form);
6642
+ default:
6643
+ return plain(405, "Method not allowed", { Allow: allowed });
5261
6644
  }
5262
- return settingsPost(d, v, form);
5263
6645
  }
5264
6646
  const now = d.clock.now();
5265
- switch (route) {
6647
+ const done = url.searchParams.get("done");
6648
+ switch (r.kind) {
5266
6649
  case "tasks":
5267
- return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now));
5268
- case "history": {
5269
- let id;
5270
- try {
5271
- id = decodeURIComponent(task?.[1] ?? "");
5272
- } catch {
5273
- return htmlResponse(notFoundPage(base, v), 404);
5274
- }
5275
- const view = await loadHistory(d, auth.user, id);
5276
- return view ? htmlResponse(historyPage(v, view)) : htmlResponse(notFoundPage(base, v), 404);
5277
- }
6650
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
6651
+ case "task":
6652
+ return taskPage(e, r.id, done);
6653
+ case "edit":
6654
+ return editGet(e, r.id);
6655
+ case "new":
6656
+ return newGet(e, r.type);
5278
6657
  default:
5279
6658
  return htmlResponse(settingsPage(v, { hour: String(auth.user.preferredHour), zone: auth.user.timeZone, saved: url.searchParams.get("saved") === "1" }));
5280
6659
  }
@@ -5307,7 +6686,8 @@ var DB_DIR = "tracker";
5307
6686
  var DB_FILE = "tracker.sqlite";
5308
6687
  var TRACKER_TYPES = Object.freeze({
5309
6688
  reminder,
5310
- renewal
6689
+ renewal,
6690
+ price
5311
6691
  });
5312
6692
  var NO_DM = {
5313
6693
  async sendDm() {
@@ -5320,6 +6700,7 @@ function createPlugin(host, options = {}) {
5320
6700
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
5321
6701
  const clock = options.clock ?? { now: () => new Date };
5322
6702
  const types = options.types ?? TRACKER_TYPES;
6703
+ const pageFetch = options.fetch ?? ((signal) => createPageFetch(signal ? { signal } : {}));
5323
6704
  const health = { activatedAt: null, lastTickAt: null, blocked: null };
5324
6705
  let db = null;
5325
6706
  let store = null;
@@ -5337,6 +6718,34 @@ function createPlugin(host, options = {}) {
5337
6718
  web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildId !== null } : null,
5338
6719
  ...options.membership ? { membership: options.membership } : {}
5339
6720
  });
6721
+ async function tick(kind, signal) {
6722
+ if (!store || !claims)
6723
+ return;
6724
+ const outcome = await runNotifyTick({
6725
+ store,
6726
+ claims,
6727
+ clock,
6728
+ types,
6729
+ dm,
6730
+ log: host.log,
6731
+ kind,
6732
+ ...kind === "poll" ? { fetch: pageFetch(signal) } : {},
6733
+ ...delivery ? { health: delivery } : {}
6734
+ }, signal);
6735
+ if (outcome.kind === "no-dm") {
6736
+ if (!warnedNoDm)
6737
+ host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
6738
+ warnedNoDm = true;
6739
+ return;
6740
+ }
6741
+ if (outcome.kind === "aborted")
6742
+ return;
6743
+ if (kind === "notify")
6744
+ health.lastTickAt = clock.now();
6745
+ const { ran, failed } = outcome.result;
6746
+ if (failed > 0)
6747
+ host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
6748
+ }
5340
6749
  let discordClient = null;
5341
6750
  const capture = (interaction) => {
5342
6751
  const client = interaction.client;
@@ -5416,11 +6825,13 @@ function createPlugin(host, options = {}) {
5416
6825
  health: delivery,
5417
6826
  clock,
5418
6827
  types,
6828
+ fetch: pageFetch(),
5419
6829
  dm,
5420
6830
  log: host.log,
5421
6831
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
5422
6832
  logins: new LoginLinks(opened),
5423
- sessions: new Sessions(opened)
6833
+ sessions: new Sessions(opened),
6834
+ webEditor: webOrigin !== null
5424
6835
  };
5425
6836
  if (guildId === null)
5426
6837
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
@@ -5440,23 +6851,11 @@ function createPlugin(host, options = {}) {
5440
6851
  ticks: [
5441
6852
  {
5442
6853
  name: "notify",
5443
- async run(signal) {
5444
- if (!store || !claims)
5445
- return;
5446
- const outcome = await runNotifyTick({ store, claims, clock, types, dm, log: host.log, ...delivery ? { health: delivery } : {} }, signal);
5447
- if (outcome.kind === "no-dm") {
5448
- if (!warnedNoDm)
5449
- host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
5450
- warnedNoDm = true;
5451
- return;
5452
- }
5453
- if (outcome.kind === "aborted")
5454
- return;
5455
- health.lastTickAt = clock.now();
5456
- const { ran, failed } = outcome.result;
5457
- if (failed > 0)
5458
- host.log.warn(`notify lane: ${ran} ran, ${failed} failed`);
5459
- }
6854
+ run: (signal) => tick("notify", signal)
6855
+ },
6856
+ {
6857
+ name: "poll",
6858
+ run: (signal) => tick("poll", signal)
5460
6859
  }
5461
6860
  ],
5462
6861
  async http(request, info) {