@rackbops/plugin-tracker 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -5
  2. package/dist/plugin.js +743 -116
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -4,8 +4,10 @@ The task tracker for Rackbops Clerk: the host of [`Rackbops/docket`](https://git
4
4
  on a rackbops-discord-bot instance. Plan of record: Rackbops/Tooling
5
5
  `research/city-hall-task-tracker.md` (sections 0, 5.1-5.3, 5.5, 5.8, 5.10). This version is the core
6
6
  (rackbops-bot-plugins#78), the Discord surface of the first slice (#79) -- reminders by slash
7
- command, delivered by DM, answered by button, with history -- and the first slice of the web area
8
- (#80): sign-in by one-time link, my tasks, a task's history, and settings.
7
+ command, delivered by DM, answered by button, with history -- the first slice of the web area
8
+ (#80): sign-in by one-time link, my tasks, a task's history, and settings -- and renewals and
9
+ the price tracker (#81, plan E6): two more types on the notify side, no model, nothing sent to
10
+ city-hall.
9
11
 
10
12
  ## Commands
11
13
 
@@ -17,9 +19,12 @@ as in a server (plan 5.5, item 39).
17
19
  | `/allow user` | a tracker admin | Admits a person: they can now `/register`. |
18
20
  | `/register [hour] [zone]` | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default `America/New_York`). The reply says an admin can see every task. |
19
21
  | `/remind text [when] [repeat]` | a registered person | A reminder by DM. `when` is docket's `parseWhen` grammar (`in 20 minutes`, `tomorrow 9am`, `fri at 17:30`); `repeat` is once (the default), daily, weekly or monthly. A repeating one with no `when` starts today at the preferred hour. |
22
+ | `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
23
+ | `/price url [name] [hours] [drop] [baseline] [near]` | registered | A price (docket's `price`, a `poll` schedule every `hours`, default 12, at most 168). The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of `drop` percent or more (default 10) from the `baseline` -- the last (default), first or highest price seen -- DMs the owner once per crossing. `near` is the words just before the price, for a page with no structured price. At most 20 per person. |
20
24
  | `/tasks` | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones. |
21
25
  | `/task done task` / `/task snooze task [until]` | the task's owner | Answers the task's latest reminder (snooze: an hour, or until `until`). |
22
- | `/task history task` | the owner, an accepted recipient, or an admin | Every run -- due, status, how it was answered, text replies -- and the task's changes. |
26
+ | `/task decide task choice [amount]` | the renewal's owner | Answers the renewal's latest ask -- keep, cancel or renewed -- with the amount actually paid when it changed (a button cannot carry one). Use it instead of the button, not after it: a run is answered once. |
27
+ | `/task history task` | the owner, an accepted recipient, or an admin | Every run -- due, status, how it was answered, text replies -- and the task's changes; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high. |
23
28
  | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. |
24
29
  | `/task resume task` | the task's owner | Resumes a task paused because a recipient could not be DMed, taking that recipient off it. |
25
30
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
@@ -107,7 +112,9 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
107
112
  | Piece | File | Notes |
108
113
  |---|---|---|
109
114
  | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
110
- | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on the host tick `notify` (every 60 s), through a view of the store that skips a paused task's due runs. Registers only `reminder` and `renewal`. The execute lane is not ticked. |
115
+ | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, through a view of the store that skips a paused task's due runs: `notify` runs every type but the page readers, `poll` runs only them (`price`) with the Fetch port, so a slow page never holds up a reminder. Registers `reminder`, `renewal` and `price`. The execute lane is not ticked. |
116
+ | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price`: http or https on the default port, no credentials, every resolved address public (no loopback, private, link-local, CGNAT, multicast or reserved range, IPv4 or IPv6), redirects followed by hand and re-checked (at most 5), 15 s including the name lookup, at most 3 MB kept. The body is then rebuilt in linear time (`src/page.ts`) to just what extraction reads -- JSON-LD, meta tags, and the page with every `<` blanked, so `near` still reads text, attributes and script data -- because docket's extraction patterns take quadratic time on a page of unclosed tags. A read the tick's abort cuts short requeues its run instead of counting a miss. A DNS answer that changes between the check and the read is not caught here. |
117
+ | Renewals, prices | `src/tracked.ts`, `src/series.ts`, `src/page.ts` | `/renewal`, `/price` and `/task decide`; the series lines of `/task history`. The series (docket's `series` table, schema 1) holds a renewal's paid amounts and a price's readings. |
111
118
  | Delivery | `src/notifier.ts`, `src/claims.ts`, `src/buttons.ts` | docket's `Notifier` over `host.dm`, with the buttons. Each (occurrence, person) is claimed in `delivery_claims` before the DM is sent and settled after; a claim never settled is not resent -- it is logged once, as unconfirmed. |
112
119
  | Pause | `src/delivery-health.ts` | The per-person failure count, the pause and the resume. |
113
120
  | People | `src/people.ts`, `src/admissions.ts` | Discord id, time zone, preferred hour, admin flag -- in the tracker's store, never usr; who admitted each person and when they registered. `TRACKER_ADMIN_DISCORD_IDS` only ever grants admin. |
@@ -130,7 +137,8 @@ The `__Host-` prefix, which would stop that, requires `Path=/` and so cannot be
130
137
  - The rest of the web area (E5): editing tasks, the admin view, lifting a decline block, forget-me,
131
138
  and the JSON task API. Until forget-me and the admin view exist, nobody can be taken off the
132
139
  tracker, so a web session ends only by sign-out, expiry, or the membership re-check. Discord OAuth2 as a second sign-in method, if chosen (plan item 41).
133
- - The `Fetch` port (then `price` is registered) and the city-hall Executor adapter (the execute lane).
140
+ - The city-hall Executor adapter (the execute lane).
141
+ - Editing a renewal or a price tracker after it is made (for now: `/task done`, or cancel, and make it again); a free-form pattern for `price` (`near` is the safe subset: a user's regular expression run on a large page could hang the bot).
134
142
  - An optional Discord-role gate (plan 5.5), and showing unconfirmed deliveries to admins anywhere but the log.
135
143
 
136
144
  docket-core, docket-types and `@rackbops/styles` are `devDependencies`: `bun build` bundles them
package/dist/plugin.js CHANGED
@@ -67,6 +67,11 @@ async function visibleHistory(store, actor, taskId) {
67
67
  return null;
68
68
  return store.listTaskEvents(taskId);
69
69
  }
70
+ async function visibleSeries(store, actor, taskId, filter) {
71
+ if (!await visibleTask(store, actor, taskId))
72
+ return null;
73
+ return store.listSeries(taskId, filter);
74
+ }
70
75
 
71
76
  // node_modules/@rackbops/docket-core/dist/capabilities.js
72
77
  var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
@@ -1545,13 +1550,13 @@ var price = defineTaskType({
1545
1550
  ]
1546
1551
  },
1547
1552
  async run(ctx) {
1548
- const { fetch } = ctx.ports;
1549
- if (!fetch)
1553
+ const { fetch: fetch2 } = ctx.ports;
1554
+ if (!fetch2)
1550
1555
  throw new Error("the price type needs the Fetch port; the host configured none");
1551
1556
  const state = stateOf(ctx.state);
1552
1557
  let body;
1553
1558
  try {
1554
- const response = await fetch.get(ctx.config.url);
1559
+ const response = await fetch2.get(ctx.config.url);
1555
1560
  if (response.status !== 200)
1556
1561
  return miss(ctx, state, `HTTP ${response.status}`);
1557
1562
  body = response.body;
@@ -2063,6 +2068,285 @@ function resumedNotice(r) {
2063
2068
  return `I could not DM you ${PAUSE_AFTER} times in a row, so your reminders were paused. ${what} ` + "Check that you accept DMs from members of a server we share, or they will pause again.";
2064
2069
  }
2065
2070
 
2071
+ // plugins/tracker/src/fetch.ts
2072
+ import { lookup } from "dns/promises";
2073
+ import { isIP } from "net";
2074
+
2075
+ // plugins/tracker/src/page.ts
2076
+ var MAX_BLOCKS = 20;
2077
+ var MAX_METAS = 300;
2078
+ var MAX_TAG = 1000;
2079
+ var escapeLt = (s) => s.replaceAll("<", "\\u003c");
2080
+ function jsonBody(body) {
2081
+ const trimmed = body.trim();
2082
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
2083
+ return null;
2084
+ try {
2085
+ return escapeLt(JSON.stringify(JSON.parse(trimmed)));
2086
+ } catch {
2087
+ return null;
2088
+ }
2089
+ }
2090
+ function pageForExtraction(body) {
2091
+ const json = jsonBody(body);
2092
+ if (json !== null)
2093
+ return json;
2094
+ const lower = body.toLowerCase();
2095
+ const blocks = [];
2096
+ const metas = [];
2097
+ const closes = new Map;
2098
+ const nextClose = (closing, from) => {
2099
+ const known = closes.get(closing);
2100
+ if (known !== undefined && (known < 0 || known >= from))
2101
+ return known;
2102
+ const found = lower.indexOf(closing, from);
2103
+ closes.set(closing, found);
2104
+ return found;
2105
+ };
2106
+ let at = 0;
2107
+ while (at < body.length) {
2108
+ let lt = body.indexOf("<", at);
2109
+ if (lt < 0)
2110
+ break;
2111
+ const gt = body.indexOf(">", lt);
2112
+ if (gt < 0)
2113
+ break;
2114
+ lt = body.lastIndexOf("<", gt);
2115
+ const tag = body.slice(lt, gt + 1);
2116
+ const tagLower = lower.slice(lt, gt + 1);
2117
+ at = gt + 1;
2118
+ if (tagLower.startsWith("<script") || tagLower.startsWith("<style")) {
2119
+ const closing = tagLower.startsWith("<script") ? "</script" : "</style";
2120
+ const end = nextClose(closing, at);
2121
+ if (end < 0)
2122
+ continue;
2123
+ if (closing === "</script" && blocks.length < MAX_BLOCKS && /type\s*=\s*["']?application\/ld\+json/.test(tagLower.slice(0, MAX_TAG))) {
2124
+ blocks.push(`<script type="application/ld+json">${escapeLt(body.slice(at, end))}</script>`);
2125
+ }
2126
+ at = end;
2127
+ } else if (tagLower.startsWith("<meta") && tag.length <= MAX_TAG && metas.length < MAX_METAS) {
2128
+ metas.push(tag);
2129
+ }
2130
+ }
2131
+ return [...blocks, ...metas, body.replaceAll("<", " ")].join(`
2132
+ `);
2133
+ }
2134
+
2135
+ // plugins/tracker/src/fetch.ts
2136
+ var TIMEOUT_MS = 15000;
2137
+ var MAX_BYTES = 3 * 1024 * 1024;
2138
+ var MAX_REDIRECTS = 5;
2139
+ var USER_AGENT = "Mozilla/5.0 (compatible; RackbopsClerk/1.0; price tracker)";
2140
+
2141
+ class FetchRefusedError extends Error {
2142
+ name = "FetchRefusedError";
2143
+ }
2144
+ var systemResolve = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
2145
+ function v4Parts(ip) {
2146
+ const parts = ip.split(".").map(Number);
2147
+ return parts.length === 4 && parts.every((p) => Number.isInteger(p) && p >= 0 && p <= 255) ? parts : null;
2148
+ }
2149
+ function isPublicV4(ip) {
2150
+ const p = v4Parts(ip);
2151
+ if (!p)
2152
+ return false;
2153
+ const [a = 0, b = 0, c = 0] = p;
2154
+ if (a === 0 || a === 10 || a === 127 || a >= 224)
2155
+ return false;
2156
+ if (a === 100 && b >= 64 && b <= 127)
2157
+ return false;
2158
+ if (a === 169 && b === 254)
2159
+ return false;
2160
+ if (a === 172 && b >= 16 && b <= 31)
2161
+ return false;
2162
+ if (a === 192 && b === 168)
2163
+ return false;
2164
+ if (a === 192 && b === 0 && (c === 0 || c === 2))
2165
+ return false;
2166
+ if (a === 198 && (b === 18 || b === 19))
2167
+ return false;
2168
+ if (a === 198 && b === 51 && c === 100)
2169
+ return false;
2170
+ if (a === 203 && b === 0 && c === 113)
2171
+ return false;
2172
+ return true;
2173
+ }
2174
+ function v6Groups(ip) {
2175
+ let text = ip.toLowerCase();
2176
+ const zone = text.indexOf("%");
2177
+ if (zone >= 0)
2178
+ text = text.slice(0, zone);
2179
+ const lastColon = text.lastIndexOf(":");
2180
+ if (text.slice(lastColon + 1).includes(".")) {
2181
+ const v4 = v4Parts(text.slice(lastColon + 1));
2182
+ if (!v4)
2183
+ return null;
2184
+ const hex = (hi, lo) => (hi << 8 | lo).toString(16);
2185
+ text = `${text.slice(0, lastColon + 1)}${hex(v4[0] ?? 0, v4[1] ?? 0)}:${hex(v4[2] ?? 0, v4[3] ?? 0)}`;
2186
+ }
2187
+ const halves = text.split("::");
2188
+ if (halves.length > 2)
2189
+ return null;
2190
+ const parse = (s) => s === "" ? [] : s.split(":").map((g) => /^[0-9a-f]{1,4}$/.test(g) ? Number.parseInt(g, 16) : Number.NaN);
2191
+ const head = parse(halves[0] ?? "");
2192
+ const rest = halves.length === 2 ? parse(halves[1] ?? "") : [];
2193
+ const known = head.length + rest.length;
2194
+ if (halves.length === 1 && known !== 8)
2195
+ return null;
2196
+ if (known > 8)
2197
+ return null;
2198
+ const groups = [...head, ...new Array(8 - known).fill(0), ...rest];
2199
+ return groups.every((g) => Number.isInteger(g) && g >= 0 && g <= 65535) ? groups : null;
2200
+ }
2201
+ function isPublicV6(ip) {
2202
+ const g = v6Groups(ip);
2203
+ if (!g)
2204
+ return false;
2205
+ const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = g;
2206
+ const embedded = `${g6 >> 8}.${g6 & 255}.${g7 >> 8}.${g7 & 255}`;
2207
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0) {
2208
+ return g5 === 65535 ? isPublicV4(embedded) : false;
2209
+ }
2210
+ if (g0 === 100 && g1 === 65435 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
2211
+ return isPublicV4(embedded);
2212
+ if ((g0 & 65024) === 64512)
2213
+ return false;
2214
+ if ((g0 & 65472) === 65152)
2215
+ return false;
2216
+ if ((g0 & 65280) === 65280)
2217
+ return false;
2218
+ if (g0 === 8193 && g1 === 3512)
2219
+ return false;
2220
+ if (g0 === 8193 && g1 < 512)
2221
+ return false;
2222
+ if (g0 === 8194)
2223
+ return false;
2224
+ return (g0 & 57344) === 8192;
2225
+ }
2226
+ function isPublicAddress(ip) {
2227
+ const version = isIP(ip);
2228
+ if (version === 4)
2229
+ return isPublicV4(ip);
2230
+ if (version === 6)
2231
+ return isPublicV6(ip);
2232
+ return false;
2233
+ }
2234
+ function urlProblem(raw) {
2235
+ let url;
2236
+ try {
2237
+ url = new URL(raw);
2238
+ } catch {
2239
+ return "That is not a web address.";
2240
+ }
2241
+ if (url.protocol !== "https:" && url.protocol !== "http:")
2242
+ return "Only http and https pages can be tracked.";
2243
+ if (url.username !== "" || url.password !== "")
2244
+ return "A page address with a user name or password in it cannot be tracked.";
2245
+ if (url.port !== "")
2246
+ return "Only pages on the standard web ports can be tracked.";
2247
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2248
+ if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
2249
+ return "Only public web pages can be tracked.";
2250
+ }
2251
+ if (isIP(host) !== 0 && !isPublicAddress(host))
2252
+ return "Only public web pages can be tracked.";
2253
+ return null;
2254
+ }
2255
+ function orAbort(p, signal) {
2256
+ if (signal.aborted)
2257
+ return Promise.reject(signal.reason);
2258
+ return new Promise((resolve, reject) => {
2259
+ const onAbort = () => reject(signal.reason);
2260
+ signal.addEventListener("abort", onAbort, { once: true });
2261
+ p.then((v) => {
2262
+ signal.removeEventListener("abort", onAbort);
2263
+ resolve(v);
2264
+ }, (e) => {
2265
+ signal.removeEventListener("abort", onAbort);
2266
+ reject(e);
2267
+ });
2268
+ });
2269
+ }
2270
+ async function checkHost(url, resolve, signal) {
2271
+ const problem = urlProblem(url.href);
2272
+ if (problem)
2273
+ throw new FetchRefusedError(problem);
2274
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2275
+ let addresses;
2276
+ if (isIP(host) !== 0)
2277
+ addresses = [host];
2278
+ else {
2279
+ try {
2280
+ addresses = await orAbort(resolve(host), signal);
2281
+ } catch {
2282
+ signal.throwIfAborted();
2283
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2284
+ }
2285
+ }
2286
+ if (addresses.length === 0)
2287
+ throw new FetchRefusedError(`the name ${host} did not resolve`);
2288
+ if (!addresses.every(isPublicAddress))
2289
+ throw new FetchRefusedError(`${host} is not on the public internet`);
2290
+ }
2291
+ async function readCapped(response, max) {
2292
+ if (!response.body)
2293
+ return "";
2294
+ const reader = response.body.getReader();
2295
+ const chunks = [];
2296
+ let size = 0;
2297
+ while (size < max) {
2298
+ const { done, value } = await reader.read();
2299
+ if (done)
2300
+ break;
2301
+ const room = max - size;
2302
+ chunks.push(value.byteLength > room ? value.subarray(0, room) : value);
2303
+ size += Math.min(value.byteLength, room);
2304
+ }
2305
+ await reader.cancel().catch(() => {});
2306
+ const all = new Uint8Array(size);
2307
+ let at = 0;
2308
+ for (const c of chunks) {
2309
+ all.set(c, at);
2310
+ at += c.byteLength;
2311
+ }
2312
+ return new TextDecoder().decode(all);
2313
+ }
2314
+ function createPageFetch(o = {}) {
2315
+ const resolve = o.resolve ?? systemResolve;
2316
+ const fetchImpl = o.fetchImpl ?? fetch;
2317
+ const timeoutMs = o.timeoutMs ?? TIMEOUT_MS;
2318
+ const maxBytes = o.maxBytes ?? MAX_BYTES;
2319
+ return {
2320
+ async get(raw, headers = {}) {
2321
+ const signal = o.signal ? AbortSignal.any([o.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
2322
+ let url = new URL(raw);
2323
+ for (let hop = 0;; hop++) {
2324
+ await checkHost(url, resolve, signal);
2325
+ signal.throwIfAborted();
2326
+ const response = await fetchImpl(url.href, {
2327
+ method: "GET",
2328
+ redirect: "manual",
2329
+ signal,
2330
+ headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", ...headers }
2331
+ });
2332
+ const location = response.headers.get("location");
2333
+ if (response.status >= 300 && response.status < 400 && location) {
2334
+ await response.body?.cancel().catch(() => {});
2335
+ if (hop >= MAX_REDIRECTS)
2336
+ throw new FetchRefusedError(`more than ${MAX_REDIRECTS} redirects`);
2337
+ url = new URL(location, url);
2338
+ continue;
2339
+ }
2340
+ const out = {};
2341
+ response.headers.forEach((value, key) => {
2342
+ out[key] = value;
2343
+ });
2344
+ return { status: response.status, body: pageForExtraction(await readCapped(response, maxBytes)), headers: out };
2345
+ }
2346
+ }
2347
+ };
2348
+ }
2349
+
2066
2350
  // plugins/tracker/src/discord-common.ts
2067
2351
  import { MessageFlags } from "discord.js";
2068
2352
  var STARTING = "The tracker is starting up; try again in a minute.";
@@ -2392,95 +2676,6 @@ async function resumeTask(d, user, taskId) {
2392
2676
  return `Resumed \`${task.id}\`${without}.`;
2393
2677
  }
2394
2678
 
2395
- // plugins/tracker/src/history.ts
2396
- var HISTORY_RUNS = 10;
2397
- var HISTORY_CHANGES = 5;
2398
- function replyText(r) {
2399
- if (r.kind === "text") {
2400
- const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
2401
- return `replied "${clip(text, 80)}"`;
2402
- }
2403
- if (r.kind === "decision")
2404
- return `chose ${String(r.payload?.choice ?? "?")}`;
2405
- return r.kind === "opt_out" ? "opted out" : r.kind;
2406
- }
2407
- function runView(o, replies, people, viewer, now) {
2408
- return {
2409
- due: formatInstant(o.dueAt, viewer.timeZone, now),
2410
- status: o.status,
2411
- late: o.late,
2412
- error: o.status === "failed" && o.error ? clip(o.error, 120) : null,
2413
- answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
2414
- };
2415
- }
2416
- function changeView(e, viewer, now) {
2417
- return {
2418
- at: formatInstant(e.at, viewer.timeZone, now),
2419
- kind: e.kind.replaceAll("_", " "),
2420
- detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
2421
- };
2422
- }
2423
- function runLine(v) {
2424
- const late = v.late ? " (late)" : "";
2425
- const error = v.error ? `: ${v.error}` : "";
2426
- const answered = v.answers.length > 0 ? `; ${v.answers.join(", ")}` : "";
2427
- return `- ${v.due} -- ${v.status}${late}${error}${answered}`;
2428
- }
2429
- function changeLine(v) {
2430
- return `- ${v.at} ${v.kind}${v.detail !== null ? `: ${v.detail}` : ""}`;
2431
- }
2432
- async function loadHistory(d, user, taskId) {
2433
- const actor = { userId: user.id, admin: user.admin };
2434
- const task = await visibleTask(d.store, actor, taskId.trim());
2435
- if (!task)
2436
- return null;
2437
- const now = d.clock.now();
2438
- const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
2439
- const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
2440
- const runs = all.filter((o) => !upcoming(o));
2441
- const next = all.find(upcoming);
2442
- const replies = await visibleReplies(d.store, actor, task.id) ?? [];
2443
- const changes = await visibleHistory(d.store, actor, task.id) ?? [];
2444
- const people = new Map;
2445
- for (const r of replies) {
2446
- if (!people.has(r.userId)) {
2447
- const u = await d.store.getUser(r.userId);
2448
- if (u)
2449
- people.set(u.id, u);
2450
- }
2451
- }
2452
- const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
2453
- return {
2454
- task,
2455
- cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
2456
- next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
2457
- runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
2458
- earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
2459
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
2460
- earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
2461
- };
2462
- }
2463
- async function taskHistory(d, user, taskId) {
2464
- const h = await loadHistory(d, user, taskId);
2465
- if (!h)
2466
- return NO_SUCH_TASK;
2467
- const { task } = h;
2468
- const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${h.cadence}`];
2469
- lines.push(h.next ? `Next: ${h.next}` : "Next: nothing scheduled");
2470
- lines.push(h.runs.length === 0 ? "Runs: none yet" : "Runs:");
2471
- if (h.earlierRuns > 0)
2472
- lines.push(`- (${h.earlierRuns} earlier)`);
2473
- for (const v of h.runs)
2474
- lines.push(runLine(v));
2475
- lines.push("Changes:");
2476
- if (h.earlierChanges > 0)
2477
- lines.push(`- (${h.earlierChanges} earlier)`);
2478
- for (const c of h.changes)
2479
- lines.push(changeLine(c));
2480
- return clip(lines.join(`
2481
- `));
2482
- }
2483
-
2484
2679
  // plugins/tracker/src/interactions.ts
2485
2680
  import {
2486
2681
  ActionRowBuilder,
@@ -2862,6 +3057,345 @@ ${body}` : body;
2862
3057
  };
2863
3058
  }
2864
3059
 
3060
+ // plugins/tracker/src/history.ts
3061
+ var HISTORY_RUNS = 10;
3062
+ var HISTORY_CHANGES = 5;
3063
+ function replyText(r) {
3064
+ if (r.kind === "text") {
3065
+ const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
3066
+ return `replied "${clip(text, 80)}"`;
3067
+ }
3068
+ if (r.kind === "decision")
3069
+ return `chose ${String(r.payload?.choice ?? "?")}`;
3070
+ return r.kind === "opt_out" ? "opted out" : r.kind;
3071
+ }
3072
+ function runView(o, replies, people, viewer, now) {
3073
+ return {
3074
+ due: formatInstant(o.dueAt, viewer.timeZone, now),
3075
+ status: o.status,
3076
+ late: o.late,
3077
+ error: o.status === "failed" && o.error ? clip(o.error, 120) : null,
3078
+ answers: replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`)
3079
+ };
3080
+ }
3081
+ function changeView(e, viewer, now) {
3082
+ return {
3083
+ at: formatInstant(e.at, viewer.timeZone, now),
3084
+ kind: e.kind.replaceAll("_", " "),
3085
+ detail: e.kind === "paused" || e.kind === "resumed" ? e.detail : null
3086
+ };
3087
+ }
3088
+ function runLine(v) {
3089
+ const late = v.late ? " (late)" : "";
3090
+ const error = v.error ? `: ${v.error}` : "";
3091
+ const answered = v.answers.length > 0 ? `; ${v.answers.join(", ")}` : "";
3092
+ return `- ${v.due} -- ${v.status}${late}${error}${answered}`;
3093
+ }
3094
+ function changeLine(v) {
3095
+ return `- ${v.at} ${v.kind}${v.detail !== null ? `: ${v.detail}` : ""}`;
3096
+ }
3097
+ async function loadHistory(d, user, taskId) {
3098
+ const actor = { userId: user.id, admin: user.admin };
3099
+ const task = await visibleTask(d.store, actor, taskId.trim());
3100
+ if (!task)
3101
+ return null;
3102
+ const now = d.clock.now();
3103
+ const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
3104
+ const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
3105
+ const runs = all.filter((o) => !upcoming(o));
3106
+ const next = all.find(upcoming);
3107
+ const replies = await visibleReplies(d.store, actor, task.id) ?? [];
3108
+ const changes = await visibleHistory(d.store, actor, task.id) ?? [];
3109
+ const people = new Map;
3110
+ for (const r of replies) {
3111
+ if (!people.has(r.userId)) {
3112
+ const u = await d.store.getUser(r.userId);
3113
+ if (u)
3114
+ people.set(u.id, u);
3115
+ }
3116
+ }
3117
+ const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
3118
+ return {
3119
+ task,
3120
+ cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
3121
+ next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
3122
+ runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now)),
3123
+ earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
3124
+ changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now)),
3125
+ earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES)
3126
+ };
3127
+ }
3128
+ async function taskHistory(d, user, taskId) {
3129
+ const h = await loadHistory(d, user, taskId);
3130
+ if (!h)
3131
+ return NO_SUCH_TASK;
3132
+ const { task } = h;
3133
+ const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${h.cadence}`];
3134
+ lines.push(h.next ? `Next: ${h.next}` : "Next: nothing scheduled");
3135
+ lines.push(h.runs.length === 0 ? "Runs: none yet" : "Runs:");
3136
+ if (h.earlierRuns > 0)
3137
+ lines.push(`- (${h.earlierRuns} earlier)`);
3138
+ for (const v of h.runs)
3139
+ lines.push(runLine(v));
3140
+ lines.push("Changes:");
3141
+ if (h.earlierChanges > 0)
3142
+ lines.push(`- (${h.earlierChanges} earlier)`);
3143
+ for (const c of h.changes)
3144
+ lines.push(changeLine(c));
3145
+ return clip(lines.join(`
3146
+ `));
3147
+ }
3148
+
3149
+ // plugins/tracker/src/series.ts
3150
+ var SERIES_SHOWN = 8;
3151
+ function pointLine(p, viewer, now) {
3152
+ const note = p.note && p.note !== "observed" ? ` (${p.note})` : "";
3153
+ return `- ${formatInstant(p.at, viewer.timeZone, now)} ${money(p.value, p.unit ?? "")}${note}`;
3154
+ }
3155
+ async function seriesLines(d, user, taskId) {
3156
+ const actor = { userId: user.id, admin: user.admin };
3157
+ const task = await visibleTask(d.store, actor, taskId.trim());
3158
+ if (!task || task.type !== "renewal" && task.type !== "price")
3159
+ return [];
3160
+ const points = await visibleSeries(d.store, actor, task.id) ?? [];
3161
+ const now = d.clock.now();
3162
+ const lines = [];
3163
+ if (task.type === "renewal") {
3164
+ const paid = points.filter((p) => p.note === "keep" || p.note === "renewed");
3165
+ if (paid.length === 0)
3166
+ return ["Paid: nothing recorded yet"];
3167
+ const unit = paid.at(-1)?.unit ?? "";
3168
+ const total = paid.reduce((sum, p) => sum + p.value, 0);
3169
+ lines.push(`Paid: ${paid.length} period${paid.length === 1 ? "" : "s"}, ${money(total, unit)} in all`);
3170
+ for (const p of paid.slice(-SERIES_SHOWN))
3171
+ lines.push(pointLine(p, user, now));
3172
+ return lines;
3173
+ }
3174
+ const runs = await visibleOccurrences(d.store, actor, task.id) ?? [];
3175
+ const checked = runs.filter((o) => o.status !== "queued" && o.summary).at(-1);
3176
+ if (checked?.summary)
3177
+ lines.push(`Last check: ${clip(checked.summary, 160)}`);
3178
+ if (points.length === 0) {
3179
+ lines.push("Prices: none read yet");
3180
+ return lines;
3181
+ }
3182
+ const unit = points.at(-1)?.unit ?? "";
3183
+ const values = points.map((p) => p.value);
3184
+ lines.push(`Prices: ${points.length} read, low ${money(Math.min(...values), unit)}, high ${money(Math.max(...values), unit)}`);
3185
+ for (const p of points.slice(-SERIES_SHOWN))
3186
+ lines.push(pointLine(p, user, now));
3187
+ return lines;
3188
+ }
3189
+ async function historyWithSeries(d, user, taskId) {
3190
+ const history = await taskHistory(d, user, taskId);
3191
+ const series = await seriesLines(d, user, taskId);
3192
+ if (series.length === 0)
3193
+ return history;
3194
+ const lines = history.split(`
3195
+ `);
3196
+ const block = series.join(`
3197
+ `);
3198
+ const rest = lines.slice(2).join(`
3199
+ `);
3200
+ const head = lines.slice(0, 2).join(`
3201
+ `);
3202
+ return clip(`${head}
3203
+ ${block}
3204
+ ${clip(rest, Math.max(3, MAX_ANSWER - head.length - block.length - 2))}`);
3205
+ }
3206
+
3207
+ // plugins/tracker/src/tracked.ts
3208
+ var MAX_TITLE2 = 100;
3209
+ var MAX_NOTE = 300;
3210
+ var MAX_NEAR = 100;
3211
+ var MAX_PRICE_TASKS = 20;
3212
+ var DEFAULT_POLL_HOURS = 12;
3213
+ var MAX_POLL_HOURS = 168;
3214
+ var DEFAULT_LEAD_DAYS = 7;
3215
+ var MAX_LEAD_DAYS = 365;
3216
+ var MAX_EVERY = 100;
3217
+ function plural2(n, word) {
3218
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
3219
+ }
3220
+ async function addRenewal(d, user, input) {
3221
+ const type = d.types.renewal;
3222
+ if (!type)
3223
+ return "Renewals are not available on this bot.";
3224
+ const name = input.name.trim();
3225
+ if (name.length === 0)
3226
+ return "Say what renews: a subscription, a domain, a warranty.";
3227
+ if (name.length > MAX_TITLE2)
3228
+ return `That name is longer than ${MAX_TITLE2} characters.`;
3229
+ if (!Number.isFinite(input.amount) || input.amount < 0)
3230
+ return "The amount has to be zero or more.";
3231
+ const currency = input.currency.trim().toUpperCase();
3232
+ if (!/^[A-Z]{3}$/.test(currency))
3233
+ return "The currency is a three-letter code, such as USD or EUR.";
3234
+ const note = input.note?.trim() ?? "";
3235
+ if (note.length > MAX_NOTE)
3236
+ return `The note is longer than ${MAX_NOTE} characters.`;
3237
+ const every = input.every ?? 1;
3238
+ if (!Number.isInteger(every) || every < 1 || every > MAX_EVERY)
3239
+ return `\`every\` is a whole number from 1 to ${MAX_EVERY}.`;
3240
+ const lead = input.lead ?? DEFAULT_LEAD_DAYS;
3241
+ if (!Number.isInteger(lead) || lead < 0 || lead > MAX_LEAD_DAYS)
3242
+ return `\`lead\` is a whole number of days from 0 to ${MAX_LEAD_DAYS}.`;
3243
+ const renews = input.renews.trim();
3244
+ const now = d.clock.now();
3245
+ const days = /^\d{4}-\d{2}-\d{2}$/.test(renews) ? daysUntil(renews, now, user.timeZone) : null;
3246
+ if (days === null)
3247
+ return "Give the date as YYYY-MM-DD, for example 2026-12-01.";
3248
+ if (days < 0)
3249
+ return "That date has passed: give the next renewal or expiry date.";
3250
+ const schedule = { kind: "period", every, unit: input.unit ?? "year", anchor: renews, leadDays: lead };
3251
+ const config = { amount: input.amount, currency, ...note ? { note } : {} };
3252
+ const actor = { userId: user.id, admin: user.admin };
3253
+ let created;
3254
+ try {
3255
+ created = await createTask(d.store, actor, user, { type, title: name, config, schedule }, now);
3256
+ } catch (err) {
3257
+ if (err instanceof ScheduleError)
3258
+ return `That schedule does not work: ${err.message}.`;
3259
+ throw err;
3260
+ }
3261
+ const { task } = created;
3262
+ let next = created.next;
3263
+ const first = nextDue(schedule, new Date(0), { zone: user.timeZone, preferredHour: user.preferredHour });
3264
+ if (first && first.getTime() <= now.getTime()) {
3265
+ next = await d.store.createOccurrence({
3266
+ taskId: task.id,
3267
+ lane: task.lane,
3268
+ dueAt: first.toISOString(),
3269
+ dedupeKey: scheduledKey(task.id, first),
3270
+ at: now.toISOString()
3271
+ });
3272
+ }
3273
+ const cadence = describeSchedule(schedule, user, user.timeZone, now);
3274
+ const when = days === 0 ? "today" : `in ${plural2(days, "day")}`;
3275
+ const ask = !next ? "never (that schedule has no next date)" : Date.parse(next.dueAt) <= now.getTime() ? `in the next minute (it renews ${when})` : formatInstant(next.dueAt, user.timeZone, now);
3276
+ return clip([
3277
+ `Renewal \`${task.id}\` set: ${name}, ${money(input.amount, currency)}, ${cadence}.`,
3278
+ `First ask: ${ask}. Each ask has Keep, Cancel and Renewed buttons; paid a different amount? Answer with \`/task decide\` instead of a button.`
3279
+ ].join(`
3280
+ `));
3281
+ }
3282
+ function nearPattern(near) {
3283
+ const literal = near.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3284
+ return `${literal}[^0-9]{0,40}([0-9][0-9.,]{0,15})`;
3285
+ }
3286
+ async function preview(d, v) {
3287
+ if (!d.fetch)
3288
+ return { ok: false, error: "Price tracking is not available on this bot." };
3289
+ try {
3290
+ const response = await d.fetch.get(v.url);
3291
+ if (response.status !== 200)
3292
+ return { ok: false, error: `That page answered HTTP ${response.status}, so there is no price to track.` };
3293
+ const found = extractPrice(response.body, v.config.pattern);
3294
+ if (!found) {
3295
+ return {
3296
+ ok: false,
3297
+ 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."
3298
+ };
3299
+ }
3300
+ return { ok: true, text: money(found.value, found.currency ?? "") };
3301
+ } catch (err) {
3302
+ if (err instanceof FetchRefusedError)
3303
+ return { ok: false, error: `I cannot read that page: ${err.message}.` };
3304
+ return { ok: false, error: "I could not read that page just now; try again later." };
3305
+ }
3306
+ }
3307
+ async function ownedPriceTasks(d, user) {
3308
+ const active = await d.store.listTasks({ ownerId: user.id, status: "active" });
3309
+ const paused = await d.store.listTasks({ ownerId: user.id, status: "paused" });
3310
+ return [...active, ...paused].filter((t) => t.type === "price").length;
3311
+ }
3312
+ function titleFor(url) {
3313
+ const u = new URL(url);
3314
+ return clip(`${u.hostname}${u.pathname === "/" ? "" : u.pathname}`, MAX_TITLE2);
3315
+ }
3316
+ async function trackPrice(d, user, input) {
3317
+ if (!d.types.price || !d.fetch)
3318
+ return "Price tracking is not available on this bot.";
3319
+ const url = input.url.trim();
3320
+ const problem = urlProblem(url);
3321
+ if (problem)
3322
+ return problem;
3323
+ const hours = input.hours ?? DEFAULT_POLL_HOURS;
3324
+ if (!Number.isInteger(hours) || hours < 1 || hours > MAX_POLL_HOURS)
3325
+ return `\`hours\` is a whole number from 1 to ${MAX_POLL_HOURS}.`;
3326
+ const drop = input.drop ?? DEFAULT_DROP_PERCENT;
3327
+ if (!Number.isFinite(drop) || drop < 1 || drop > 90)
3328
+ return "`drop` is a percentage from 1 to 90.";
3329
+ const baseline = input.baseline ?? DEFAULT_BASELINE;
3330
+ if (!BASELINE_RULES.includes(baseline))
3331
+ return "`baseline` is first, last or peak.";
3332
+ const near = input.near?.trim() ?? "";
3333
+ if (near.length > MAX_NEAR)
3334
+ return `\`near\` is longer than ${MAX_NEAR} characters.`;
3335
+ const name = input.name?.trim() ?? "";
3336
+ if (name.length > MAX_TITLE2)
3337
+ return `That name is longer than ${MAX_TITLE2} characters.`;
3338
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3339
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3340
+ }
3341
+ const v = {
3342
+ url,
3343
+ title: name || titleFor(url),
3344
+ hours,
3345
+ config: { url, dropPercent: drop, baseline, ...near ? { pattern: nearPattern(near) } : {} }
3346
+ };
3347
+ return {
3348
+ outside: () => preview(d, v),
3349
+ finish: (result) => finishPrice(d, user, v, result)
3350
+ };
3351
+ }
3352
+ async function finishPrice(d, user, v, seen) {
3353
+ if (!seen.ok)
3354
+ return seen.error;
3355
+ const type = d.types.price;
3356
+ if (!type)
3357
+ return "Price tracking is not available on this bot.";
3358
+ if (await ownedPriceTasks(d, user) >= MAX_PRICE_TASKS) {
3359
+ return `You already track ${MAX_PRICE_TASKS} prices, the most one person may. Stop one with \`/task done\` first.`;
3360
+ }
3361
+ const now = d.clock.now();
3362
+ const schedule = { kind: "poll", every: v.hours, unit: "hour", start: now.toISOString() };
3363
+ const actor = { userId: user.id, admin: user.admin };
3364
+ const { task } = await createTask(d.store, actor, user, { type, title: v.title, config: v.config, schedule }, now);
3365
+ const rule = v.config.baseline ?? DEFAULT_BASELINE;
3366
+ return clip([
3367
+ `Tracking \`${task.id}\`: ${v.title}. I read ${seen.text} just now.`,
3368
+ `I check every ${plural2(v.hours, "hour")} and DM you when it drops ${v.config.dropPercent}% or more from the ${rule} price seen; the first check, within a minute, DMs the starting price. \`/task done ${task.id}\` stops it.`
3369
+ ].join(`
3370
+ `));
3371
+ }
3372
+ async function decideRenewal(d, user, input) {
3373
+ const task = await visibleTask(d.store, { userId: user.id, admin: false }, input.taskId.trim());
3374
+ if (!task || task.ownerId !== user.id)
3375
+ return NO_SUCH_TASK;
3376
+ if (task.type !== "renewal")
3377
+ return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
3378
+ if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
3379
+ return "The amount has to be zero or more.";
3380
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
3381
+ const latest = fired.at(-1);
3382
+ if (!latest)
3383
+ return "That renewal has no ask waiting for an answer yet.";
3384
+ const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
3385
+ try {
3386
+ await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
3387
+ } catch (err) {
3388
+ if (err instanceof ReplyRefusedError)
3389
+ return err.message;
3390
+ throw err;
3391
+ }
3392
+ if (input.choice === "cancel")
3393
+ return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
3394
+ const currency = task.config.currency ?? "";
3395
+ const paid = input.amount !== undefined ? ` at ${money(input.amount, currency)}` : "";
3396
+ return `Recorded: ${input.choice}${paid}.`;
3397
+ }
3398
+
2865
3399
  // plugins/tracker/src/web/secrets.ts
2866
3400
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
2867
3401
  function randomToken() {
@@ -3006,6 +3540,45 @@ ${body}` : body);
3006
3540
  });
3007
3541
  })
3008
3542
  },
3543
+ {
3544
+ name: "renewal",
3545
+ build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(100)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(3).setMaxLength(3)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(10)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
3546
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3547
+ const every = interaction.options.getInteger("every");
3548
+ const lead = interaction.options.getInteger("lead");
3549
+ const unit = interaction.options.getString("unit");
3550
+ const note = interaction.options.getString("note");
3551
+ return addRenewal(d, user, {
3552
+ name: interaction.options.getString("name", true),
3553
+ amount: interaction.options.getNumber("amount", true),
3554
+ currency: interaction.options.getString("currency", true),
3555
+ renews: interaction.options.getString("renews", true),
3556
+ ...every !== null ? { every } : {},
3557
+ ...lead !== null ? { lead } : {},
3558
+ ...unit !== null ? { unit } : {},
3559
+ ...note !== null ? { note } : {}
3560
+ });
3561
+ })
3562
+ },
3563
+ {
3564
+ name: "price",
3565
+ build: (b) => b.setDescription("Track a product's price: I DM you when it drops").addStringOption((o) => o.setName("url").setDescription("The product page").setRequired(true).setMaxLength(1000)).addStringOption((o) => o.setName("name").setDescription("What to call it (default: the page address)").setMaxLength(100)).addIntegerOption((o) => o.setName("hours").setDescription(`Hours between checks (default ${DEFAULT_POLL_HOURS})`).setMinValue(1).setMaxValue(MAX_POLL_HOURS)).addNumberOption((o) => o.setName("drop").setDescription("Alert on a drop of at least this percent (default 10)").setMinValue(1).setMaxValue(90)).addStringOption((o) => o.setName("baseline").setDescription("Measure the drop from which price seen (default: the last)").addChoices({ name: "the last", value: "last" }, { name: "the first", value: "first" }, { name: "the highest", value: "peak" })).addStringOption((o) => o.setName("near").setDescription("The words just before the price, if I cannot find it on my own").setMaxLength(MAX_NEAR)),
3566
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
3567
+ const name = interaction.options.getString("name");
3568
+ const hours = interaction.options.getInteger("hours");
3569
+ const drop = interaction.options.getNumber("drop");
3570
+ const baseline = interaction.options.getString("baseline");
3571
+ const near = interaction.options.getString("near");
3572
+ return trackPrice(d, user, {
3573
+ url: interaction.options.getString("url", true),
3574
+ ...name !== null ? { name } : {},
3575
+ ...hours !== null ? { hours } : {},
3576
+ ...drop !== null ? { drop } : {},
3577
+ ...baseline !== null ? { baseline } : {},
3578
+ ...near !== null ? { near } : {}
3579
+ });
3580
+ })
3581
+ },
3009
3582
  {
3010
3583
  name: "tasks",
3011
3584
  build: (b) => b.setDescription("List your tasks and when each is next due"),
@@ -3013,7 +3586,7 @@ ${body}` : body);
3013
3586
  },
3014
3587
  {
3015
3588
  name: "task",
3016
- build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3589
+ build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(100))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("resume").setDescription("Resume a task paused because someone could not be DMed, without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(24)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
3017
3590
  handle: (interaction) => {
3018
3591
  const sub = interaction.options.getSubcommand();
3019
3592
  const taskId = interaction.options.getString("task", true);
@@ -3026,8 +3599,16 @@ ${body}` : body);
3026
3599
  const until = interaction.options.getString("until");
3027
3600
  return answerLatest(d, user, { taskId, kind: "snooze", ...until !== null ? { until } : {} });
3028
3601
  }
3602
+ case "decide": {
3603
+ const amount = interaction.options.getNumber("amount");
3604
+ return decideRenewal(d, user, {
3605
+ taskId,
3606
+ choice: interaction.options.getString("choice", true),
3607
+ ...amount !== null ? { amount } : {}
3608
+ });
3609
+ }
3029
3610
  case "history":
3030
- return taskHistory(d, user, taskId);
3611
+ return historyWithSeries(d, user, taskId);
3031
3612
  case "resume":
3032
3613
  return resumeTask(d, user, taskId);
3033
3614
  case "share": {
@@ -3078,7 +3659,28 @@ function healthResponse(health) {
3078
3659
  }
3079
3660
 
3080
3661
  // plugins/tracker/src/notify-lane.ts
3081
- function laneStore(store) {
3662
+ var FETCH_TYPES = new Set(["price"]);
3663
+ function takesType(kind, type) {
3664
+ return kind === "poll" ? FETCH_TYPES.has(type) : !FETCH_TYPES.has(type);
3665
+ }
3666
+ function untilAborted(type, signal) {
3667
+ const run = type.run;
3668
+ if (!run)
3669
+ return type;
3670
+ const stop = () => new ExecutorUnavailableError("the tick was aborted; the run waits for the next one");
3671
+ return {
3672
+ ...type,
3673
+ async run(ctx) {
3674
+ if (signal.aborted)
3675
+ throw stop();
3676
+ const outcome = await run.call(type, ctx);
3677
+ if (signal.aborted)
3678
+ throw stop();
3679
+ return outcome;
3680
+ }
3681
+ };
3682
+ }
3683
+ function laneStore(store, keep = () => true) {
3082
3684
  const view = Object.create(store);
3083
3685
  view.listOccurrences = async (filter = {}) => {
3084
3686
  const found = await store.listOccurrences(filter);
@@ -3086,7 +3688,8 @@ function laneStore(store) {
3086
3688
  return found;
3087
3689
  const kept = [];
3088
3690
  for (const o of found) {
3089
- if ((await store.getTask(o.taskId))?.status === "active")
3691
+ const task = await store.getTask(o.taskId);
3692
+ if (task?.status === "active" && keep(task))
3090
3693
  kept.push(o);
3091
3694
  }
3092
3695
  return kept;
@@ -3098,7 +3701,8 @@ async function runNotifyTick(d, signal) {
3098
3701
  return { kind: "no-dm" };
3099
3702
  if (signal?.aborted)
3100
3703
  return { kind: "aborted" };
3101
- const store = laneStore(d.store);
3704
+ const kind = d.kind ?? "notify";
3705
+ const store = laneStore(d.store, (task) => takesType(kind, task.type));
3102
3706
  const notifier = createDmNotifier({
3103
3707
  store,
3104
3708
  claims: d.claims,
@@ -3108,7 +3712,11 @@ async function runNotifyTick(d, signal) {
3108
3712
  ...signal ? { signal } : {},
3109
3713
  ...d.health ? { health: d.health } : {}
3110
3714
  });
3111
- const lanes = new Lanes({ store, clock: d.clock, types: d.types, notifier });
3715
+ let types = d.types;
3716
+ if (kind === "poll" && signal) {
3717
+ types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
3718
+ }
3719
+ const lanes = new Lanes({ store, clock: d.clock, types, notifier, ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {} });
3112
3720
  const result = await lanes.tickNotify();
3113
3721
  return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3114
3722
  }
@@ -5307,7 +5915,8 @@ var DB_DIR = "tracker";
5307
5915
  var DB_FILE = "tracker.sqlite";
5308
5916
  var TRACKER_TYPES = Object.freeze({
5309
5917
  reminder,
5310
- renewal
5918
+ renewal,
5919
+ price
5311
5920
  });
5312
5921
  var NO_DM = {
5313
5922
  async sendDm() {
@@ -5320,6 +5929,7 @@ function createPlugin(host, options = {}) {
5320
5929
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
5321
5930
  const clock = options.clock ?? { now: () => new Date };
5322
5931
  const types = options.types ?? TRACKER_TYPES;
5932
+ const pageFetch = options.fetch ?? ((signal) => createPageFetch(signal ? { signal } : {}));
5323
5933
  const health = { activatedAt: null, lastTickAt: null, blocked: null };
5324
5934
  let db = null;
5325
5935
  let store = null;
@@ -5337,6 +5947,34 @@ function createPlugin(host, options = {}) {
5337
5947
  web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildId !== null } : null,
5338
5948
  ...options.membership ? { membership: options.membership } : {}
5339
5949
  });
5950
+ async function tick(kind, signal) {
5951
+ if (!store || !claims)
5952
+ return;
5953
+ const outcome = await runNotifyTick({
5954
+ store,
5955
+ claims,
5956
+ clock,
5957
+ types,
5958
+ dm,
5959
+ log: host.log,
5960
+ kind,
5961
+ ...kind === "poll" ? { fetch: pageFetch(signal) } : {},
5962
+ ...delivery ? { health: delivery } : {}
5963
+ }, signal);
5964
+ if (outcome.kind === "no-dm") {
5965
+ if (!warnedNoDm)
5966
+ host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
5967
+ warnedNoDm = true;
5968
+ return;
5969
+ }
5970
+ if (outcome.kind === "aborted")
5971
+ return;
5972
+ if (kind === "notify")
5973
+ health.lastTickAt = clock.now();
5974
+ const { ran, failed } = outcome.result;
5975
+ if (failed > 0)
5976
+ host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
5977
+ }
5340
5978
  let discordClient = null;
5341
5979
  const capture = (interaction) => {
5342
5980
  const client = interaction.client;
@@ -5416,6 +6054,7 @@ function createPlugin(host, options = {}) {
5416
6054
  health: delivery,
5417
6055
  clock,
5418
6056
  types,
6057
+ fetch: pageFetch(),
5419
6058
  dm,
5420
6059
  log: host.log,
5421
6060
  notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
@@ -5440,23 +6079,11 @@ function createPlugin(host, options = {}) {
5440
6079
  ticks: [
5441
6080
  {
5442
6081
  name: "notify",
5443
- async run(signal) {
5444
- if (!store || !claims)
5445
- return;
5446
- const outcome = await runNotifyTick({ store, claims, clock, types, dm, log: host.log, ...delivery ? { health: delivery } : {} }, signal);
5447
- if (outcome.kind === "no-dm") {
5448
- if (!warnedNoDm)
5449
- host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
5450
- warnedNoDm = true;
5451
- return;
5452
- }
5453
- if (outcome.kind === "aborted")
5454
- return;
5455
- health.lastTickAt = clock.now();
5456
- const { ran, failed } = outcome.result;
5457
- if (failed > 0)
5458
- host.log.warn(`notify lane: ${ran} ran, ${failed} failed`);
5459
- }
6082
+ run: (signal) => tick("notify", signal)
6083
+ },
6084
+ {
6085
+ name: "poll",
6086
+ run: (signal) => tick("poll", signal)
5460
6087
  }
5461
6088
  ],
5462
6089
  async http(request, info) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.3.0",
4
- "description": "The task tracker for Rackbops Clerk: reminders by slash command, delivered by DM with done, snooze and reply buttons, on docket's SQLite store (/allow, /register, /remind, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings",
3
+ "version": "0.4.0",
4
+ "description": "The task tracker for Rackbops Clerk: reminders, renewals and price drops by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],
@@ -21,7 +21,7 @@
21
21
  "botPlugin": {
22
22
  "hostApiVersion": 1,
23
23
  "intents": [],
24
- "commands": ["allow", "register", "remind", "tasks", "task", "settings", "web"],
24
+ "commands": ["allow", "register", "remind", "renewal", "price", "tasks", "task", "settings", "web"],
25
25
  "env": [
26
26
  {
27
27
  "key": "TRACKER_ADMIN_DISCORD_IDS",