@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.
- package/README.md +51 -11
- package/dist/plugin.js +1696 -297
- 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 (!
|
|
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
|
|
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
|
-
|
|
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
|
|
2265
|
-
const
|
|
2266
|
-
|
|
2267
|
-
|
|
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
|
|
2275
|
-
|
|
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
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
const
|
|
2314
|
-
const
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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/
|
|
2396
|
-
var
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
return
|
|
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
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
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
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
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
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
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
|
-
|
|
2433
|
-
const
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
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
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
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
|
|
2464
|
-
const
|
|
2465
|
-
if (!
|
|
2466
|
-
return
|
|
2467
|
-
const
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
if (
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
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
|
|
2776
|
-
const
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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
|
-
|
|
2817
|
-
}
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
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
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
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
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(", ")};
|
|
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
|
-
|
|
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="
|
|
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 =
|
|
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
|
|
5161
|
-
|
|
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 >
|
|
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
|
|
5240
|
-
|
|
5241
|
-
if (route === null)
|
|
6611
|
+
const r = route(path);
|
|
6612
|
+
if (r === null)
|
|
5242
6613
|
return htmlResponse(notFoundPage(base), 404);
|
|
5243
|
-
const
|
|
5244
|
-
if (
|
|
5245
|
-
return
|
|
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
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
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
|
-
|
|
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 "
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
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
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
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) {
|