@rackbops/plugin-tracker 0.0.0 → 0.2.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 +73 -0
  2. package/dist/plugin.js +3631 -0
  3. package/package.json +34 -4
package/dist/plugin.js ADDED
@@ -0,0 +1,3631 @@
1
+ // @bun
2
+ // plugins/tracker/src/index.ts
3
+ import { mkdirSync } from "fs";
4
+ import { join } from "path";
5
+
6
+ // node_modules/@rackbops/docket-core/dist/answer.js
7
+ var RUN_KINDS = new Set(["done", "snooze", "decision"]);
8
+ var NOT_YOURS = "That is not yours to answer.";
9
+ var OVER = "That run is over; answer the latest message instead.";
10
+
11
+ class ReplyRefusedError extends Error {
12
+ name = "ReplyRefusedError";
13
+ }
14
+ async function runRefusal(store, task, occurrenceId, userId) {
15
+ if (task.ownerId !== userId)
16
+ return NOT_YOURS;
17
+ if (occurrenceId === null)
18
+ return "Answer the message for the run you mean.";
19
+ const occurrence = await store.getOccurrence(occurrenceId);
20
+ if (!occurrence || occurrence.taskId !== task.id)
21
+ return "That run no longer exists.";
22
+ if (task.status !== "active")
23
+ return `That task is ${task.status}.`;
24
+ if (occurrence.status === "queued")
25
+ return "That run has not fired yet.";
26
+ if (occurrence.status === "snoozed" || occurrence.status === "skipped")
27
+ return OVER;
28
+ const answered = (await store.listReplies(task.id)).some((r) => r.occurrenceId === occurrence.id && RUN_KINDS.has(r.kind));
29
+ return answered ? "That run has already been answered." : null;
30
+ }
31
+
32
+ // node_modules/@rackbops/docket-core/dist/authz.js
33
+ function canSee(actor, task, recipients) {
34
+ if (actor.admin || task.ownerId === actor.userId)
35
+ return true;
36
+ return recipients.some((r) => r.userId === actor.userId && r.state === "accepted");
37
+ }
38
+ async function visibleTask(store, actor, taskId) {
39
+ const task = await store.getTask(taskId);
40
+ if (!task)
41
+ return null;
42
+ return canSee(actor, task, await store.listRecipients(taskId)) ? task : null;
43
+ }
44
+ async function visibleTasks(store, actor, filter = {}) {
45
+ const tasks = await store.listTasks(filter);
46
+ if (actor.admin)
47
+ return tasks;
48
+ const out = [];
49
+ for (const task of tasks) {
50
+ if (canSee(actor, task, await store.listRecipients(task.id)))
51
+ out.push(task);
52
+ }
53
+ return out;
54
+ }
55
+ async function visibleOccurrences(store, actor, taskId) {
56
+ if (!await visibleTask(store, actor, taskId))
57
+ return null;
58
+ return store.listOccurrences({ taskId });
59
+ }
60
+ async function visibleReplies(store, actor, taskId) {
61
+ if (!await visibleTask(store, actor, taskId))
62
+ return null;
63
+ return store.listReplies(taskId);
64
+ }
65
+ async function visibleHistory(store, actor, taskId) {
66
+ if (!await visibleTask(store, actor, taskId))
67
+ return null;
68
+ return store.listTaskEvents(taskId);
69
+ }
70
+
71
+ // node_modules/@rackbops/docket-core/dist/capabilities.js
72
+ var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
73
+ var CAPABILITY_TIER = {
74
+ notify: 0,
75
+ "graph:write": 1,
76
+ "discord:post": 1,
77
+ "github:issue": 1
78
+ };
79
+ function isCapability(value) {
80
+ return CAPABILITIES.includes(value);
81
+ }
82
+ function unknownCapabilities(declared) {
83
+ return declared.filter((c) => !isCapability(c));
84
+ }
85
+
86
+ // node_modules/@rackbops/docket-core/dist/consent.js
87
+ var DECLINE_BLOCK_MS = 24 * 60 * 60 * 1000;
88
+
89
+ class ConsentError extends Error {
90
+ name = "ConsentError";
91
+ }
92
+ async function activeBlock(store, ownerId, recipientId, now) {
93
+ const blocks = await store.listBlocks(ownerId, recipientId);
94
+ return blocks.find((b) => b.liftedAt === null && (b.expiresAt === null || Date.parse(b.expiresAt) > now.getTime())) ?? null;
95
+ }
96
+ async function invite(store, actor, task, recipientId, now) {
97
+ if (!actor.admin && actor.userId !== task.ownerId)
98
+ return { ok: false, reason: "not_owner" };
99
+ if (recipientId === task.ownerId)
100
+ return { ok: false, reason: "self" };
101
+ const existing = (await store.listRecipients(task.id)).find((r) => r.userId === recipientId);
102
+ if (existing && (existing.state === "invited" || existing.state === "accepted")) {
103
+ return { ok: false, reason: "already_invited" };
104
+ }
105
+ const block = await activeBlock(store, task.ownerId, recipientId, now);
106
+ if (block)
107
+ return { ok: false, reason: "blocked", block };
108
+ const at = now.toISOString();
109
+ const recipient = await store.setRecipient(task.id, recipientId, "invited", at);
110
+ await store.addTaskEvent({
111
+ taskId: task.id,
112
+ actorId: actor.userId,
113
+ kind: "recipient_invited",
114
+ detail: recipientId,
115
+ at
116
+ });
117
+ return { ok: true, recipient };
118
+ }
119
+ async function respondToInvite(store, task, recipientId, answer, now, occurrenceId = null) {
120
+ const existing = (await store.listRecipients(task.id)).find((r) => r.userId === recipientId);
121
+ if (existing?.state !== "invited") {
122
+ throw new ConsentError(`${recipientId} has no open invitation to task ${task.id}`);
123
+ }
124
+ const at = now.toISOString();
125
+ const reply = await store.addReply({
126
+ occurrenceId,
127
+ taskId: task.id,
128
+ userId: recipientId,
129
+ kind: answer,
130
+ payload: null,
131
+ at
132
+ });
133
+ if (answer === "accept") {
134
+ const recipient = await store.setRecipient(task.id, recipientId, "accepted", at);
135
+ await store.addTaskEvent({
136
+ taskId: task.id,
137
+ actorId: recipientId,
138
+ kind: "recipient_accepted",
139
+ detail: recipientId,
140
+ at
141
+ });
142
+ return { recipient, reply, block: null };
143
+ }
144
+ const recipient = await store.setRecipient(task.id, recipientId, "declined", at);
145
+ await store.addTaskEvent({
146
+ taskId: task.id,
147
+ actorId: recipientId,
148
+ kind: "recipient_declined",
149
+ detail: recipientId,
150
+ at
151
+ });
152
+ const earlierDeclines = (await store.listBlocks(task.ownerId, recipientId)).filter((b) => b.declineReplyId !== null);
153
+ const permanent = earlierDeclines.length > 0;
154
+ const block = await store.createBlock({
155
+ ownerId: task.ownerId,
156
+ recipientId,
157
+ expiresAt: permanent ? null : new Date(now.getTime() + DECLINE_BLOCK_MS).toISOString(),
158
+ declineReplyId: reply.id,
159
+ at
160
+ });
161
+ await store.addTaskEvent({
162
+ taskId: task.id,
163
+ actorId: recipientId,
164
+ kind: "blocked",
165
+ detail: permanent ? `${recipientId} permanent` : `${recipientId} 24h`,
166
+ at
167
+ });
168
+ return { recipient, reply, block };
169
+ }
170
+ async function optOut(store, task, recipientId, now, occurrenceId = null) {
171
+ const at = now.toISOString();
172
+ await store.setRecipient(task.id, recipientId, "opted_out", at);
173
+ await store.addTaskEvent({
174
+ taskId: task.id,
175
+ actorId: recipientId,
176
+ kind: "recipient_opted_out",
177
+ detail: recipientId,
178
+ at
179
+ });
180
+ return store.addReply({
181
+ occurrenceId,
182
+ taskId: task.id,
183
+ userId: recipientId,
184
+ kind: "opt_out",
185
+ payload: null,
186
+ at
187
+ });
188
+ }
189
+
190
+ // node_modules/@rackbops/docket-core/dist/lanes.js
191
+ var LANES = ["notify", "execute"];
192
+ function isLane(value) {
193
+ return LANES.includes(value);
194
+ }
195
+
196
+ // node_modules/@rackbops/docket-core/dist/zoned.js
197
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
198
+ var formatters = new Map;
199
+ function formatter(zone) {
200
+ let f = formatters.get(zone);
201
+ if (!f) {
202
+ f = new Intl.DateTimeFormat("en-US", {
203
+ timeZone: zone,
204
+ hourCycle: "h23",
205
+ year: "numeric",
206
+ month: "2-digit",
207
+ day: "2-digit",
208
+ hour: "2-digit",
209
+ minute: "2-digit",
210
+ weekday: "short"
211
+ });
212
+ formatters.set(zone, f);
213
+ }
214
+ return f;
215
+ }
216
+ function isTimeZone(zone) {
217
+ try {
218
+ formatter(zone);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+ function wallClock(instant, zone) {
225
+ const parts = formatter(zone).formatToParts(instant);
226
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
227
+ return {
228
+ year: Number(get("year")),
229
+ month: Number(get("month")),
230
+ day: Number(get("day")),
231
+ hour: Number(get("hour")),
232
+ minute: Number(get("minute")),
233
+ weekday: WEEKDAYS.indexOf(get("weekday"))
234
+ };
235
+ }
236
+ function zoneOffsetMinutes(instant, zone) {
237
+ const w = wallClock(instant, zone);
238
+ const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
239
+ const truncated = Math.floor(instant.getTime() / 60000) * 60000;
240
+ return Math.round((asUtc - truncated) / 60000);
241
+ }
242
+ function reads(instant, zone, d, h, min) {
243
+ const w = wallClock(new Date(instant), zone);
244
+ return w.day === d && w.hour === h && w.minute === min;
245
+ }
246
+ function zonedInstant(year, month, day, hour, minute, zone) {
247
+ const wanted = Date.UTC(year, month - 1, day, hour, minute);
248
+ const offset1 = zoneOffsetMinutes(new Date(wanted), zone);
249
+ const candidate1 = wanted - offset1 * 60000;
250
+ if (reads(candidate1, zone, day, hour, minute))
251
+ return new Date(candidate1);
252
+ const offset2 = zoneOffsetMinutes(new Date(candidate1), zone);
253
+ const candidate2 = wanted - offset2 * 60000;
254
+ if (reads(candidate2, zone, day, hour, minute))
255
+ return new Date(candidate2);
256
+ return new Date(Math.max(candidate1, candidate2));
257
+ }
258
+
259
+ // node_modules/@rackbops/docket-core/dist/schedule.js
260
+ var SCHEDULE_KINDS = ["once", "calendar", "poll", "period"];
261
+ function isScheduleKind(value) {
262
+ return SCHEDULE_KINDS.includes(value);
263
+ }
264
+ var MIN_POLL_MINUTES = 5;
265
+ var MINUTE_MS = 60000;
266
+ var DAY_MS = 86400000;
267
+ var HORIZON_DAYS = 366 * 3;
268
+ var HORIZON_PERIODS = 1e5;
269
+ function parseLocalDate(value) {
270
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
271
+ if (!m)
272
+ return null;
273
+ const year = Number(m[1]);
274
+ const month = Number(m[2]);
275
+ const day = Number(m[3]);
276
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
277
+ return null;
278
+ return { year, month, day };
279
+ }
280
+ function formatLocalDate(d) {
281
+ const mm = String(d.month).padStart(2, "0");
282
+ const dd = String(d.day).padStart(2, "0");
283
+ return `${d.year}-${mm}-${dd}`;
284
+ }
285
+ function daysInMonth(year, month) {
286
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
287
+ }
288
+ function dayNumber(d) {
289
+ return Math.floor(Date.UTC(d.year, d.month - 1, d.day) / DAY_MS);
290
+ }
291
+ function fromDayNumber(n) {
292
+ const dt = new Date(n * DAY_MS);
293
+ return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() };
294
+ }
295
+ function weekdayOf(d) {
296
+ return new Date(Date.UTC(d.year, d.month - 1, d.day)).getUTCDay();
297
+ }
298
+ function addMonths(anchor, months) {
299
+ const index = anchor.year * 12 + (anchor.month - 1) + months;
300
+ const year = Math.floor(index / 12);
301
+ const month = index % 12 + 1;
302
+ return { year, month, day: Math.min(anchor.day, daysInMonth(year, month)) };
303
+ }
304
+ function periodDateAt(schedule, n) {
305
+ const anchor = parseLocalDate(schedule.anchor);
306
+ if (!anchor)
307
+ return null;
308
+ const steps = n * schedule.every;
309
+ switch (schedule.unit) {
310
+ case "day":
311
+ return fromDayNumber(dayNumber(anchor) + steps);
312
+ case "week":
313
+ return fromDayNumber(dayNumber(anchor) + steps * 7);
314
+ case "month":
315
+ return addMonths(anchor, steps);
316
+ case "year":
317
+ return addMonths(anchor, steps * 12);
318
+ }
319
+ }
320
+ function isInstant(value) {
321
+ return !Number.isNaN(Date.parse(value));
322
+ }
323
+ function checkLocalTime(prefix, schedule, problems) {
324
+ if (schedule.hour !== undefined && (!Number.isInteger(schedule.hour) || schedule.hour < 0 || schedule.hour > 23)) {
325
+ problems.push(`${prefix}.hour must be 0 to 23`);
326
+ }
327
+ if (schedule.minute !== undefined && (!Number.isInteger(schedule.minute) || schedule.minute < 0 || schedule.minute > 59)) {
328
+ problems.push(`${prefix}.minute must be 0 to 59`);
329
+ }
330
+ }
331
+ function calendarProblems(schedule) {
332
+ const problems = [];
333
+ if (!Number.isInteger(schedule.every) || schedule.every < 1) {
334
+ problems.push("calendar.every must be a whole number >= 1");
335
+ }
336
+ if (!["day", "week", "month"].includes(schedule.unit))
337
+ problems.push("calendar.unit is unknown");
338
+ if (!parseLocalDate(schedule.start))
339
+ problems.push("calendar.start must be a YYYY-MM-DD date");
340
+ if (schedule.weekdays !== undefined) {
341
+ if (schedule.weekdays.length === 0)
342
+ problems.push("calendar.weekdays must not be empty");
343
+ if (schedule.weekdays.some((w) => !Number.isInteger(w) || w < 0 || w > 6)) {
344
+ problems.push("calendar.weekdays must be 0 (Sunday) to 6 (Saturday)");
345
+ }
346
+ }
347
+ if (schedule.dayOfMonth !== undefined) {
348
+ if (!Number.isInteger(schedule.dayOfMonth) || schedule.dayOfMonth < 1 || schedule.dayOfMonth > 31) {
349
+ problems.push("calendar.dayOfMonth must be 1 to 31");
350
+ }
351
+ }
352
+ checkLocalTime("calendar", schedule, problems);
353
+ return problems;
354
+ }
355
+ function pollIntervalMs(schedule) {
356
+ return schedule.every * (schedule.unit === "hour" ? 60 : 1) * MINUTE_MS;
357
+ }
358
+ function pollProblems(schedule) {
359
+ const problems = [];
360
+ if (!Number.isInteger(schedule.every) || schedule.every < 1) {
361
+ problems.push("poll.every must be a whole number >= 1");
362
+ }
363
+ if (!["minute", "hour"].includes(schedule.unit)) {
364
+ problems.push("poll.unit must be minute or hour");
365
+ } else if (problems.length === 0 && pollIntervalMs(schedule) < MIN_POLL_MINUTES * MINUTE_MS) {
366
+ problems.push(`poll interval must be at least ${MIN_POLL_MINUTES} minutes`);
367
+ }
368
+ if (!isInstant(schedule.start))
369
+ problems.push("poll.start is not an ISO-8601 instant");
370
+ if (schedule.until !== undefined) {
371
+ if (!isInstant(schedule.until))
372
+ problems.push("poll.until is not an ISO-8601 instant");
373
+ else if (isInstant(schedule.start) && Date.parse(schedule.until) <= Date.parse(schedule.start)) {
374
+ problems.push("poll.until must be after poll.start");
375
+ }
376
+ }
377
+ return problems;
378
+ }
379
+ function periodProblems(schedule) {
380
+ const problems = [];
381
+ if (!Number.isInteger(schedule.every) || schedule.every < 1) {
382
+ problems.push("period.every must be a whole number >= 1");
383
+ }
384
+ if (!["day", "week", "month", "year"].includes(schedule.unit)) {
385
+ problems.push("period.unit must be day, week, month or year");
386
+ }
387
+ if (!parseLocalDate(schedule.anchor))
388
+ problems.push("period.anchor must be a YYYY-MM-DD date");
389
+ if (schedule.leadDays !== undefined && (!Number.isInteger(schedule.leadDays) || schedule.leadDays < 0)) {
390
+ problems.push("period.leadDays must be a whole number >= 0");
391
+ }
392
+ checkLocalTime("period", schedule, problems);
393
+ return problems;
394
+ }
395
+ function scheduleProblems(schedule) {
396
+ switch (schedule.kind) {
397
+ case "once":
398
+ return isInstant(schedule.at) ? [] : ["once.at is not an ISO-8601 instant"];
399
+ case "calendar":
400
+ return calendarProblems(schedule);
401
+ case "poll":
402
+ return pollProblems(schedule);
403
+ case "period":
404
+ return periodProblems(schedule);
405
+ }
406
+ }
407
+ function matchesDay(schedule, start, day) {
408
+ const gap = dayNumber(day) - dayNumber(start);
409
+ if (gap < 0)
410
+ return false;
411
+ switch (schedule.unit) {
412
+ case "day":
413
+ return gap % schedule.every === 0;
414
+ case "week": {
415
+ const weekdays = schedule.weekdays ?? [weekdayOf(start)];
416
+ const startOfWeek = dayNumber(start) - weekdayOf(start);
417
+ const weeks = Math.floor((dayNumber(day) - startOfWeek) / 7);
418
+ return weeks % schedule.every === 0 && weekdays.includes(weekdayOf(day));
419
+ }
420
+ case "month": {
421
+ const months = (day.year - start.year) * 12 + (day.month - start.month);
422
+ if (months < 0 || months % schedule.every !== 0)
423
+ return false;
424
+ const wanted = Math.min(schedule.dayOfMonth ?? start.day, daysInMonth(day.year, day.month));
425
+ return day.day === wanted;
426
+ }
427
+ }
428
+ }
429
+ function nextCalendarDue(schedule, after, ctx) {
430
+ const start = parseLocalDate(schedule.start);
431
+ if (!start)
432
+ return null;
433
+ const hour = schedule.hour ?? ctx.preferredHour;
434
+ const minute = schedule.minute ?? 0;
435
+ const local = wallClock(after, ctx.zone);
436
+ const firstDay = Math.max(dayNumber({ year: local.year, month: local.month, day: local.day }), dayNumber(start));
437
+ for (let n = firstDay;n < firstDay + HORIZON_DAYS; n++) {
438
+ const day = fromDayNumber(n);
439
+ if (!matchesDay(schedule, start, day))
440
+ continue;
441
+ const instant = zonedInstant(day.year, day.month, day.day, hour, minute, ctx.zone);
442
+ if (instant.getTime() > after.getTime())
443
+ return instant;
444
+ }
445
+ return null;
446
+ }
447
+ function nextPollDue(schedule, after) {
448
+ const start = Date.parse(schedule.start);
449
+ if (Number.isNaN(start))
450
+ return null;
451
+ const interval = pollIntervalMs(schedule);
452
+ const elapsed = after.getTime() - start;
453
+ const steps = elapsed < 0 ? 0 : Math.floor(elapsed / interval) + 1;
454
+ const due = start + steps * interval;
455
+ if (schedule.until !== undefined && due > Date.parse(schedule.until))
456
+ return null;
457
+ return new Date(due);
458
+ }
459
+ function nextPeriodDue(schedule, after, ctx) {
460
+ const hour = schedule.hour ?? ctx.preferredHour;
461
+ const minute = schedule.minute ?? 0;
462
+ const lead = schedule.leadDays ?? 0;
463
+ for (let n = 0;n < HORIZON_PERIODS; n++) {
464
+ const date = periodDateAt(schedule, n);
465
+ if (!date)
466
+ return null;
467
+ const fire = fromDayNumber(dayNumber(date) - lead);
468
+ const instant = zonedInstant(fire.year, fire.month, fire.day, hour, minute, ctx.zone);
469
+ if (instant.getTime() > after.getTime())
470
+ return instant;
471
+ }
472
+ return null;
473
+ }
474
+ function nextDue(schedule, after, ctx) {
475
+ switch (schedule.kind) {
476
+ case "once": {
477
+ const at = new Date(schedule.at);
478
+ return at.getTime() > after.getTime() ? at : null;
479
+ }
480
+ case "calendar":
481
+ return nextCalendarDue(schedule, after, ctx);
482
+ case "poll":
483
+ return nextPollDue(schedule, after);
484
+ case "period":
485
+ return nextPeriodDue(schedule, after, ctx);
486
+ }
487
+ }
488
+ function periodDate(schedule, due, zone) {
489
+ const local = wallClock(due, zone);
490
+ const fire = { year: local.year, month: local.month, day: local.day };
491
+ return formatLocalDate(fromDayNumber(dayNumber(fire) + (schedule.leadDays ?? 0)));
492
+ }
493
+ function daysUntil(date, now, zone) {
494
+ const target = parseLocalDate(date);
495
+ if (!target)
496
+ return null;
497
+ const local = wallClock(now, zone);
498
+ return dayNumber(target) - dayNumber({ year: local.year, month: local.month, day: local.day });
499
+ }
500
+
501
+ // node_modules/@rackbops/docket-core/dist/contract.js
502
+ class TaskTypeError extends Error {
503
+ name = "TaskTypeError";
504
+ }
505
+ function defineTaskType(def) {
506
+ if (!def.id)
507
+ throw new TaskTypeError("a task type needs an id");
508
+ if (!isLane(def.lane))
509
+ throw new TaskTypeError(`${def.id}: unknown lane ${String(def.lane)}`);
510
+ const unknown = unknownCapabilities(def.capabilities);
511
+ if (unknown.length > 0) {
512
+ throw new TaskTypeError(`${def.id}: declares ungrantable capabilities: ${unknown.join(", ")}`);
513
+ }
514
+ if (def.schedule.length === 0)
515
+ throw new TaskTypeError(`${def.id}: accepts no schedule kind`);
516
+ const badKinds = def.schedule.filter((k) => !isScheduleKind(k));
517
+ if (badKinds.length > 0) {
518
+ throw new TaskTypeError(`${def.id}: unknown schedule kinds: ${badKinds.join(", ")}`);
519
+ }
520
+ if (def.lane === "notify" && !def.run)
521
+ throw new TaskTypeError(`${def.id}: a notify type needs run`);
522
+ if (def.lane === "execute" && (!def.prepare || !def.finish)) {
523
+ throw new TaskTypeError(`${def.id}: an execute type needs prepare and finish`);
524
+ }
525
+ return Object.freeze({ ...def });
526
+ }
527
+
528
+ // node_modules/@rackbops/docket-core/dist/dedupe.js
529
+ function scheduledKey(taskId, dueAt) {
530
+ const iso = typeof dueAt === "string" ? new Date(dueAt).toISOString() : dueAt.toISOString();
531
+ return `sched:${taskId}:${iso}`;
532
+ }
533
+ function snoozeKey(occurrenceId) {
534
+ return `${SNOOZE_PREFIX}${occurrenceId}`;
535
+ }
536
+ var SNOOZE_PREFIX = "snooze:";
537
+
538
+ // node_modules/@rackbops/docket-core/dist/delivery.js
539
+ var NOTIFIER = "notifier";
540
+ async function deliveredTo(store, occurrenceId) {
541
+ const events = await store.listEvents(occurrenceId);
542
+ return new Set(events.filter((e) => e.type === "delivered").map((e) => e.text.split(" ")[0] ?? ""));
543
+ }
544
+ function messageFor(message, occurrence, target, ownerId) {
545
+ const ref = { taskId: occurrence.taskId, occurrenceId: occurrence.id };
546
+ const actions = message.actions ?? [];
547
+ if (target.id === ownerId) {
548
+ return { ...message, actions: actions.filter((a) => a !== "opt_out"), ref };
549
+ }
550
+ const { decisions: _owners, ...rest } = message;
551
+ const kept = actions.filter((a) => !RUN_KINDS.has(a) && a !== "opt_out");
552
+ return { ...rest, actions: [...kept, "opt_out"], ref };
553
+ }
554
+ async function deliver(store, notifier, occurrence, targets, message, now) {
555
+ const task = await store.getTask(occurrence.taskId);
556
+ if (!task)
557
+ throw new Error(`no task ${occurrence.taskId}`);
558
+ const done = await deliveredTo(store, occurrence.id);
559
+ const report = { sent: [], skipped: [] };
560
+ for (const target of targets) {
561
+ if (done.has(target.id)) {
562
+ report.skipped.push(target.id);
563
+ continue;
564
+ }
565
+ const { messageId } = await notifier.sendDm(target.id, messageFor(message, occurrence, target, task.ownerId));
566
+ await store.addEvent({
567
+ occurrenceId: occurrence.id,
568
+ agent: NOTIFIER,
569
+ type: "delivered",
570
+ text: `${target.id} ${messageId}`,
571
+ at: now().toISOString()
572
+ });
573
+ done.add(target.id);
574
+ report.sent.push(target.id);
575
+ }
576
+ return report;
577
+ }
578
+
579
+ // node_modules/@rackbops/docket-core/dist/describe.js
580
+ var WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
581
+ var MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
582
+ function clockTime(hour, minute) {
583
+ return `${hour}:${String(minute).padStart(2, "0")}`;
584
+ }
585
+ function formatInstant(instant, zone, now) {
586
+ const w = wallClock(new Date(instant), zone);
587
+ const year = now && wallClock(now, zone).year !== w.year ? ` ${w.year}` : "";
588
+ return `${WD[w.weekday]} ${MON[w.month - 1]} ${w.day}${year}, ${clockTime(w.hour, w.minute)}`;
589
+ }
590
+ function ordinal(n) {
591
+ const tens = n % 100;
592
+ if (tens >= 11 && tens <= 13)
593
+ return `${n}th`;
594
+ return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
595
+ }
596
+ function every(n, unit, single) {
597
+ return n === 1 ? single : `every ${n} ${unit}s`;
598
+ }
599
+ function list(items) {
600
+ if (items.length <= 1)
601
+ return items.join("");
602
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
603
+ }
604
+ function dateParts(date) {
605
+ const d = new Date(`${date}T00:00:00Z`);
606
+ return { weekday: d.getUTCDay(), day: d.getUTCDate() };
607
+ }
608
+ function describeSchedule(schedule, owner, viewerZone = owner.timeZone, now) {
609
+ const zoneNote = viewerZone === owner.timeZone ? "" : ` (${owner.timeZone} time)`;
610
+ switch (schedule.kind) {
611
+ case "once":
612
+ return `once, ${formatInstant(schedule.at, viewerZone, now)}`;
613
+ case "calendar": {
614
+ const at = `at ${clockTime(schedule.hour ?? owner.preferredHour, schedule.minute ?? 0)}`;
615
+ const start = dateParts(schedule.start);
616
+ if (schedule.unit === "day") {
617
+ return `${every(schedule.every, "day", "daily")} ${at}${zoneNote}`;
618
+ }
619
+ if (schedule.unit === "week") {
620
+ const days = [...schedule.weekdays ?? [start.weekday]].sort((a, b) => a - b);
621
+ const on = list(days.map((d) => WD[d] ?? "?"));
622
+ return `${every(schedule.every, "week", "weekly")} on ${on} ${at}${zoneNote}`;
623
+ }
624
+ const day = ordinal(schedule.dayOfMonth ?? start.day);
625
+ return `${every(schedule.every, "month", "monthly")} on the ${day} ${at}${zoneNote}`;
626
+ }
627
+ case "poll":
628
+ return schedule.every === 1 ? `every ${schedule.unit}` : `every ${schedule.every} ${schedule.unit}s`;
629
+ case "period": {
630
+ const cadence = schedule.every === 1 ? `every ${schedule.unit}` : `every ${schedule.every} ${schedule.unit}s`;
631
+ const days = schedule.leadDays === 1 ? "day" : "days";
632
+ const lead = schedule.leadDays ? `, ${schedule.leadDays} ${days} ahead` : "";
633
+ return `${cadence} from ${schedule.anchor}${lead}`;
634
+ }
635
+ }
636
+ }
637
+
638
+ // node_modules/@rackbops/docket-core/dist/ports.js
639
+ class ExecutorUnavailableError extends Error {
640
+ name = "ExecutorUnavailableError";
641
+ }
642
+
643
+ // node_modules/@rackbops/docket-core/dist/scheduler.js
644
+ var LATE_AFTER_MS = 5 * 60000;
645
+
646
+ class ScheduleError extends Error {
647
+ name = "ScheduleError";
648
+ }
649
+ function isLate(occurrence, now) {
650
+ return now.getTime() - Date.parse(occurrence.dueAt) > LATE_AFTER_MS;
651
+ }
652
+ async function materialize(store, task, owner, now) {
653
+ if (task.status !== "active" || !task.schedule)
654
+ return null;
655
+ const pending = await store.listOccurrences({ taskId: task.id });
656
+ const scheduled = pending.filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
657
+ if (scheduled.some((o) => o.status === "queued" || o.status === "running"))
658
+ return null;
659
+ const after = firstDueAfter(task.schedule, pending.length === 0, now);
660
+ const due = nextDue(task.schedule, after, {
661
+ zone: owner.timeZone,
662
+ preferredHour: owner.preferredHour
663
+ });
664
+ if (!due)
665
+ return null;
666
+ return store.createOccurrence({
667
+ taskId: task.id,
668
+ lane: task.lane,
669
+ dueAt: due.toISOString(),
670
+ dedupeKey: scheduledKey(task.id, due),
671
+ at: now.toISOString()
672
+ });
673
+ }
674
+ function firstDueAfter(schedule, fresh, now) {
675
+ if (schedule.kind === "once")
676
+ return new Date(0);
677
+ if (schedule.kind === "poll" && fresh)
678
+ return new Date(Date.parse(schedule.start) - 1);
679
+ return now;
680
+ }
681
+ async function reschedule(store, task, owner, schedule, actorId, now) {
682
+ if (schedule) {
683
+ const problems = scheduleProblems(schedule);
684
+ if (problems.length > 0)
685
+ throw new ScheduleError(problems.join("; "));
686
+ }
687
+ const at = now.toISOString();
688
+ const removed = await store.deleteQueuedOccurrences(task.id);
689
+ const updated = await store.updateTask(task.id, { schedule, at });
690
+ await store.addTaskEvent({
691
+ taskId: task.id,
692
+ actorId,
693
+ kind: "schedule_changed",
694
+ detail: JSON.stringify(schedule),
695
+ at
696
+ });
697
+ return { task: updated, removed, next: await materialize(store, updated, owner, now) };
698
+ }
699
+
700
+ // node_modules/@rackbops/docket-core/dist/dispatch.js
701
+ var ME = "docket";
702
+ var SERIES_IN_CONTEXT = 100;
703
+
704
+ class Lanes {
705
+ d;
706
+ constructor(d) {
707
+ this.d = d;
708
+ }
709
+ async recover() {
710
+ return this.d.store.requeueRunning();
711
+ }
712
+ async tickNotify() {
713
+ const result = { ran: 0, failed: 0, skipped: 0 };
714
+ for (const occurrence of await this.due("notify")) {
715
+ await this.runOne(occurrence) ? result.ran++ : result.failed++;
716
+ }
717
+ return result;
718
+ }
719
+ async tickExecute() {
720
+ const due = await this.due("execute");
721
+ if (!this.d.executor)
722
+ return { ran: 0, failed: 0, skipped: due.length };
723
+ const result = { ran: 0, failed: 0, skipped: 0 };
724
+ for (const occurrence of due) {
725
+ const outcome = await this.runOne(occurrence);
726
+ if (outcome === null) {
727
+ result.skipped += due.length - result.ran - result.failed;
728
+ break;
729
+ }
730
+ outcome ? result.ran++ : result.failed++;
731
+ }
732
+ return result;
733
+ }
734
+ async reply(input) {
735
+ const { store } = this.d;
736
+ const now = this.d.clock.now();
737
+ const task = await store.getTask(input.taskId);
738
+ if (!task)
739
+ throw new Error(`no task ${input.taskId}`);
740
+ if (input.kind === "accept" || input.kind === "decline") {
741
+ await respondToInvite(store, task, input.userId, input.kind, now, input.occurrenceId);
742
+ return null;
743
+ }
744
+ if (input.kind === "opt_out") {
745
+ await optOut(store, task, input.userId, now, input.occurrenceId);
746
+ return null;
747
+ }
748
+ if (RUN_KINDS.has(input.kind)) {
749
+ const refusal = await runRefusal(store, task, input.occurrenceId, input.userId);
750
+ if (refusal)
751
+ throw new ReplyRefusedError(refusal);
752
+ }
753
+ const reply = await store.addReply({ ...input, at: now.toISOString() });
754
+ if (task.status !== "active")
755
+ return null;
756
+ const loaded = await this.load(task);
757
+ if ("error" in loaded || !loaded.type.onReply)
758
+ return null;
759
+ const occurrence = input.occurrenceId ? await store.getOccurrence(input.occurrenceId) : null;
760
+ if (!occurrence)
761
+ return null;
762
+ const ctx = await this.context(loaded, occurrence, now);
763
+ const outcome = await loaded.type.onReply({ ...ctx, reply });
764
+ await this.apply(task, outcome, now);
765
+ if (outcome.complete)
766
+ await this.complete(task, input.userId, now);
767
+ else if (outcome.snoozeUntil) {
768
+ await this.snooze(task, occurrence, outcome.snoozeUntil, reply, now);
769
+ }
770
+ return outcome;
771
+ }
772
+ async due(lane) {
773
+ return this.d.store.listOccurrences({
774
+ lane,
775
+ status: "queued",
776
+ dueBefore: this.d.clock.now().toISOString()
777
+ });
778
+ }
779
+ async load(task) {
780
+ const type = this.d.types[task.type];
781
+ if (!type)
782
+ return { error: `no task type ${task.type}` };
783
+ const owner = await this.d.store.getUser(task.ownerId);
784
+ if (!owner)
785
+ return { error: `no owner ${task.ownerId}` };
786
+ const recipients = [];
787
+ for (const r of await this.d.store.listRecipients(task.id)) {
788
+ if (r.state !== "accepted")
789
+ continue;
790
+ const user = await this.d.store.getUser(r.userId);
791
+ if (user)
792
+ recipients.push(user);
793
+ }
794
+ return { task, type, owner, recipients };
795
+ }
796
+ async context(loaded, occurrence, now) {
797
+ const originalDueAt = await this.originalDueAt(occurrence);
798
+ return {
799
+ task: loaded.task,
800
+ occurrence,
801
+ ...originalDueAt ? { originalDueAt } : {},
802
+ owner: loaded.owner,
803
+ recipients: loaded.recipients,
804
+ config: loaded.task.config,
805
+ state: loaded.task.state,
806
+ now,
807
+ ports: this.d.fetch ? { fetch: this.d.fetch } : {},
808
+ history: {
809
+ events: await this.d.store.listEvents(occurrence.id),
810
+ replies: await this.d.store.listReplies(loaded.task.id),
811
+ series: await this.d.store.listSeries(loaded.task.id, { limit: SERIES_IN_CONTEXT })
812
+ }
813
+ };
814
+ }
815
+ async originalDueAt(occurrence) {
816
+ let current = occurrence;
817
+ let found = null;
818
+ for (let hops = 0;hops < 100 && current.dedupeKey.startsWith(SNOOZE_PREFIX); hops++) {
819
+ const from = await this.d.store.getOccurrence(current.dedupeKey.slice(SNOOZE_PREFIX.length));
820
+ if (!from)
821
+ break;
822
+ found = from.dueAt;
823
+ current = from;
824
+ }
825
+ return found;
826
+ }
827
+ async apply(task, outcome, now) {
828
+ const { store } = this.d;
829
+ if (outcome.state !== undefined) {
830
+ await store.updateTask(task.id, { state: outcome.state, at: now.toISOString() });
831
+ }
832
+ for (const point of outcome.series ?? []) {
833
+ await store.addSeriesPoint({
834
+ taskId: task.id,
835
+ at: point.at ?? now.toISOString(),
836
+ value: point.value,
837
+ unit: point.unit ?? null,
838
+ note: point.note ?? null
839
+ });
840
+ }
841
+ }
842
+ async complete(task, actorId, now) {
843
+ const { store } = this.d;
844
+ const at = now.toISOString();
845
+ await store.deleteQueuedOccurrences(task.id);
846
+ await store.updateTask(task.id, { status: "done", at });
847
+ await store.addTaskEvent({ taskId: task.id, actorId, kind: "completed", detail: "", at });
848
+ }
849
+ async runOne(occurrence) {
850
+ const { store, clock } = this.d;
851
+ const task = await store.getTask(occurrence.taskId);
852
+ if (!task)
853
+ return this.fail(occurrence, `no task ${occurrence.taskId}`);
854
+ const loaded = await this.load(task);
855
+ if ("error" in loaded)
856
+ return this.fail(occurrence, loaded.error);
857
+ const now = clock.now();
858
+ const late = isLate(occurrence, now);
859
+ await store.updateOccurrence(occurrence.id, {
860
+ status: "running",
861
+ startedAt: now.toISOString(),
862
+ late,
863
+ error: null
864
+ });
865
+ await this.event(occurrence, "status", late ? "started late" : "started");
866
+ try {
867
+ const ctx = await this.context(loaded, occurrence, now);
868
+ const { outcome, costUsd } = await this.execute(loaded.type, ctx, occurrence);
869
+ await this.apply(task, outcome, clock.now());
870
+ if (outcome.notify) {
871
+ await deliver(store, this.d.notifier, occurrence, [loaded.owner, ...loaded.recipients], outcome.notify, () => clock.now());
872
+ }
873
+ const snoozed = (await store.getOccurrence(occurrence.id))?.status === "snoozed";
874
+ await store.updateOccurrence(occurrence.id, {
875
+ status: snoozed ? "snoozed" : "done",
876
+ finishedAt: clock.now().toISOString(),
877
+ summary: outcome.summary ?? null,
878
+ costUsd
879
+ });
880
+ if (outcome.complete)
881
+ await this.complete(task, null, clock.now());
882
+ else
883
+ await this.next(task, loaded.owner);
884
+ return true;
885
+ } catch (err) {
886
+ if (err instanceof ExecutorUnavailableError) {
887
+ await this.event(occurrence, "status", `waiting: ${err.message}`);
888
+ await store.updateOccurrence(occurrence.id, { status: "queued" });
889
+ return null;
890
+ }
891
+ await this.fail(occurrence, err instanceof Error ? err.message : String(err));
892
+ await this.next(task, loaded.owner);
893
+ return false;
894
+ }
895
+ }
896
+ async next(task, owner) {
897
+ const current = await this.d.store.getTask(task.id);
898
+ if (current)
899
+ await materialize(this.d.store, current, owner, this.d.clock.now());
900
+ }
901
+ async execute(type, ctx, occurrence) {
902
+ if (type.lane === "notify") {
903
+ if (!type.run)
904
+ throw new Error(`${type.id} has no run`);
905
+ return { outcome: await type.run(ctx), costUsd: null };
906
+ }
907
+ if (!type.prepare || !type.finish)
908
+ throw new Error(`${type.id} has no prepare/finish`);
909
+ if (!this.d.executor)
910
+ throw new ExecutorUnavailableError("no executor configured");
911
+ const spec = await type.prepare(ctx);
912
+ const result = await this.d.executor.run(spec, occurrence.id);
913
+ const outcome = await type.finish(ctx, result);
914
+ return { outcome, costUsd: result.totalCostUsd ?? null };
915
+ }
916
+ async snooze(task, occurrence, until, reply, now) {
917
+ const { store } = this.d;
918
+ await store.updateOccurrence(occurrence.id, { status: "snoozed" });
919
+ await this.event(occurrence, "status", `snoozed until ${until.toISOString()} by ${reply.userId}`);
920
+ await store.createOccurrence({
921
+ taskId: task.id,
922
+ lane: task.lane,
923
+ dueAt: until.toISOString(),
924
+ dedupeKey: snoozeKey(occurrence.id),
925
+ at: now.toISOString()
926
+ });
927
+ }
928
+ async fail(occurrence, message) {
929
+ await this.event(occurrence, "error", message);
930
+ const snoozed = (await this.d.store.getOccurrence(occurrence.id))?.status === "snoozed";
931
+ await this.d.store.updateOccurrence(occurrence.id, {
932
+ status: snoozed ? "snoozed" : "failed",
933
+ finishedAt: this.d.clock.now().toISOString(),
934
+ error: message
935
+ });
936
+ return false;
937
+ }
938
+ async event(occurrence, type, text) {
939
+ await this.d.store.addEvent({
940
+ occurrenceId: occurrence.id,
941
+ agent: ME,
942
+ type,
943
+ text,
944
+ at: this.d.clock.now().toISOString()
945
+ });
946
+ }
947
+ }
948
+
949
+ // node_modules/@rackbops/docket-core/dist/messages.js
950
+ var ADMIN_DISCLOSURE = "An admin of this tracker can see every task and its history, including yours.";
951
+ function name(user) {
952
+ return user.displayName ?? "Someone";
953
+ }
954
+ function inviteMessage(task, owner, recipient, now) {
955
+ const cadence = task.schedule ? `, which runs ${describeSchedule(task.schedule, owner, recipient.timeZone, now)}` : "";
956
+ return {
957
+ text: [
958
+ `${name(owner)} wants you to be notified about "${task.title}"${cadence}.`,
959
+ "You would only receive its results; you can stop them from any message.",
960
+ ADMIN_DISCLOSURE,
961
+ "Do you accept these notifications?"
962
+ ].join(`
963
+ `),
964
+ actions: ["accept", "decline"],
965
+ ref: { taskId: task.id, occurrenceId: null }
966
+ };
967
+ }
968
+ async function taskList(store, actor) {
969
+ const tasks = await visibleTasks(store, { ...actor, admin: false }, { status: "active" });
970
+ const entries = [];
971
+ for (const task of tasks) {
972
+ const queued = await store.listOccurrences({ taskId: task.id, status: "queued" });
973
+ const from = task.ownerId === actor.userId ? null : await store.getUser(task.ownerId);
974
+ entries.push({ task, next: queued[0] ?? null, from });
975
+ }
976
+ return entries.sort((a, b) => (a.next?.dueAt ?? "\uFFFF").localeCompare(b.next?.dueAt ?? "\uFFFF") || a.task.title.localeCompare(b.task.title));
977
+ }
978
+ function formatTaskList(entries, viewer, now) {
979
+ if (entries.length === 0)
980
+ return "You have no active tasks.";
981
+ return entries.map(({ task, next, from }) => {
982
+ const when = next ? `next ${formatInstant(next.dueAt, viewer.timeZone, now)}` : "nothing due";
983
+ const schedule = task.schedule?.kind === "once" ? null : task.schedule;
984
+ const cadence = schedule ? `, ${describeSchedule(schedule, from ?? viewer, viewer.timeZone, now)}` : "";
985
+ const owner = from ? ` (from ${name(from)})` : "";
986
+ return `\`${task.id}\` ${task.title}${owner} -- ${when}${cadence}`;
987
+ }).join(`
988
+ `);
989
+ }
990
+
991
+ // node_modules/@rackbops/docket-core/dist/refs.js
992
+ var MAX_REF_LENGTH = 80;
993
+ var CODE = {
994
+ accept: "a",
995
+ decline: "x",
996
+ opt_out: "q",
997
+ done: "d",
998
+ snooze: "s",
999
+ decision: "c"
1000
+ };
1001
+ var KIND = Object.fromEntries(Object.entries(CODE).map(([k, c]) => [c, k]));
1002
+
1003
+ class ReplyRefError extends Error {
1004
+ name = "ReplyRefError";
1005
+ }
1006
+ function encodeReplyRef(ref) {
1007
+ const [scope, id] = ref.occurrenceId === null ? ["t", ref.taskId] : ["o", ref.occurrenceId];
1008
+ if (id.includes("."))
1009
+ throw new ReplyRefError(`a Store id with a dot cannot be encoded: ${id}`);
1010
+ const base = `${CODE[ref.kind]}.${scope}.${id}`;
1011
+ if (ref.kind === "decision" && !ref.choice) {
1012
+ throw new ReplyRefError("a decision needs its choice");
1013
+ }
1014
+ const encoded = ref.kind === "decision" ? `${base}.${ref.choice}` : base;
1015
+ if (encoded.length > MAX_REF_LENGTH) {
1016
+ throw new ReplyRefError(`a reference over ${MAX_REF_LENGTH} characters: ${encoded}`);
1017
+ }
1018
+ return encoded;
1019
+ }
1020
+ function decodeReplyRef(encoded) {
1021
+ const m = /^([a-z])\.([ot])\.([^.]+)(?:\.(.+))?$/.exec(encoded);
1022
+ const kind = m ? KIND[m[1] ?? ""] : undefined;
1023
+ if (!m || !kind || m[3] === undefined)
1024
+ return null;
1025
+ const scope = m[2] === "o" ? "o" : "t";
1026
+ if (kind === "decision") {
1027
+ return m[4] === undefined ? null : { kind, scope, id: m[3], choice: m[4] };
1028
+ }
1029
+ return m[4] === undefined ? { kind, scope, id: m[3] } : null;
1030
+ }
1031
+ async function replyForRef(store, encoded, userId) {
1032
+ const ref = decodeReplyRef(encoded);
1033
+ if (!ref)
1034
+ return { ok: false, error: "That button is not one of mine." };
1035
+ const run = RUN_KINDS.has(ref.kind);
1036
+ if (run && ref.scope !== "o")
1037
+ return { ok: false, error: "That button is not one of mine." };
1038
+ let taskId = ref.id;
1039
+ let occurrenceId = null;
1040
+ if (ref.scope === "o") {
1041
+ const occurrence = await store.getOccurrence(ref.id);
1042
+ if (!occurrence)
1043
+ return { ok: false, error: "That run no longer exists." };
1044
+ taskId = occurrence.taskId;
1045
+ occurrenceId = occurrence.id;
1046
+ }
1047
+ const task = await store.getTask(taskId);
1048
+ if (!task)
1049
+ return { ok: false, error: "That task no longer exists." };
1050
+ if (run) {
1051
+ const refusal = await runRefusal(store, task, occurrenceId, userId);
1052
+ if (refusal)
1053
+ return { ok: false, error: refusal };
1054
+ } else {
1055
+ const mine = (await store.listRecipients(task.id)).find((r) => r.userId === userId);
1056
+ const state = ref.kind === "opt_out" ? "accepted" : "invited";
1057
+ if (mine?.state !== state)
1058
+ return { ok: false, error: NOT_YOURS };
1059
+ }
1060
+ return {
1061
+ ok: true,
1062
+ input: {
1063
+ taskId: task.id,
1064
+ occurrenceId,
1065
+ userId,
1066
+ kind: ref.kind,
1067
+ payload: ref.kind === "decision" ? { choice: ref.choice } : null
1068
+ }
1069
+ };
1070
+ }
1071
+ var LABELS = {
1072
+ accept: "Accept",
1073
+ decline: "Decline",
1074
+ opt_out: "Stop sending me this",
1075
+ done: "Done",
1076
+ snooze: "Snooze 1h"
1077
+ };
1078
+ function replyButtons(message) {
1079
+ const ref = message.ref;
1080
+ if (!ref)
1081
+ return [];
1082
+ const buttons = [];
1083
+ const add = (button) => {
1084
+ if (!buttons.some((b) => b.ref === button.ref))
1085
+ buttons.push(button);
1086
+ };
1087
+ for (const kind of message.actions ?? []) {
1088
+ if (kind === "text")
1089
+ continue;
1090
+ if (kind === "decision") {
1091
+ for (const choice of message.decisions ?? []) {
1092
+ add({ kind, label: choice, ref: encodeReplyRef({ ...ref, kind, choice }) });
1093
+ }
1094
+ continue;
1095
+ }
1096
+ add({ kind, label: LABELS[kind], ref: encodeReplyRef({ ...ref, kind }) });
1097
+ }
1098
+ return buttons;
1099
+ }
1100
+
1101
+ // node_modules/@rackbops/docket-core/dist/tasks.js
1102
+ class TaskError extends Error {
1103
+ name = "TaskError";
1104
+ }
1105
+ function defaultCapabilities(type) {
1106
+ return type.capabilities.filter((c) => CAPABILITY_TIER[c] === 0);
1107
+ }
1108
+ async function createTask(store, actor, owner, input, now) {
1109
+ if (!actor.admin && actor.userId !== owner.id) {
1110
+ throw new TaskError("only the owner or an admin creates a task for a person");
1111
+ }
1112
+ if (input.schedule) {
1113
+ if (!input.type.schedule.includes(input.schedule.kind)) {
1114
+ throw new ScheduleError(`${input.type.id} does not accept a ${input.schedule.kind} schedule`);
1115
+ }
1116
+ const problems = scheduleProblems(input.schedule);
1117
+ if (problems.length > 0)
1118
+ throw new ScheduleError(problems.join("; "));
1119
+ }
1120
+ const at = now.toISOString();
1121
+ const task = await store.createTask({
1122
+ ownerId: owner.id,
1123
+ type: input.type.id,
1124
+ title: input.title,
1125
+ config: input.config,
1126
+ schedule: input.schedule,
1127
+ lane: input.type.lane,
1128
+ capabilities: defaultCapabilities(input.type),
1129
+ at
1130
+ });
1131
+ await store.addTaskEvent({
1132
+ taskId: task.id,
1133
+ actorId: actor.userId,
1134
+ kind: "created",
1135
+ detail: input.type.id,
1136
+ at
1137
+ });
1138
+ return { task, next: await materialize(store, task, owner, now) };
1139
+ }
1140
+
1141
+ // node_modules/@rackbops/docket-core/dist/when.js
1142
+ var UNITS = {
1143
+ m: 60000,
1144
+ min: 60000,
1145
+ mins: 60000,
1146
+ minute: 60000,
1147
+ minutes: 60000,
1148
+ h: 3600000,
1149
+ hr: 3600000,
1150
+ hrs: 3600000,
1151
+ hour: 3600000,
1152
+ hours: 3600000,
1153
+ d: 86400000,
1154
+ day: 86400000,
1155
+ days: 86400000,
1156
+ w: 604800000,
1157
+ week: 604800000,
1158
+ weeks: 604800000
1159
+ };
1160
+ var WEEKDAYS2 = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
1161
+ var ISO_RE = /^\d{4}-\d{2}-\d{2}t\d{2}:\d{2}(:\d{2}(\.\d+)?)?(z|[+-]\d{2}:?\d{2})$/;
1162
+ var RELATIVE_RE = /^in (\d+|an?) ?([a-z]+)$/;
1163
+ var DAY = String.raw`today|tomorrow|tmrw|[a-z]{3,9}|\d{4}-\d{2}-\d{2}|\d{1,2}/\d{1,2}`;
1164
+ var TIME = String.raw`noon|midnight|\d{1,2}(?::\d{2})? ?(?:am|pm)?`;
1165
+ var DAY_TIME_RE = new RegExp(`^(?:on )?(${DAY})(?: (?:at )?(${TIME}))?$`);
1166
+ var TIME_DAY_RE = new RegExp(`^(?:at )?(${TIME})(?: (?:on )?(${DAY}))?$`);
1167
+ var MAX_AHEAD_YEARS = 5;
1168
+ var MAX_AHEAD_MS = MAX_AHEAD_YEARS * 365.25 * 86400000;
1169
+ function knownZone(zone) {
1170
+ try {
1171
+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
1172
+ return true;
1173
+ } catch {
1174
+ return false;
1175
+ }
1176
+ }
1177
+ var HINT = `Try "in 2 hours", "9am", "tomorrow 9am", "friday 17:30" or "2026-10-01 9:00".`;
1178
+ function fail(text, why) {
1179
+ return { ok: false, error: `${why ?? `I couldn't read "${text}" as a time.`} ${HINT}` };
1180
+ }
1181
+ function daysIn(year, month) {
1182
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
1183
+ }
1184
+ function addDays(date, days) {
1185
+ const d = new Date(Date.UTC(date.year, date.month - 1, date.day + days));
1186
+ return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
1187
+ }
1188
+ function validDate(date) {
1189
+ return date.month >= 1 && date.month <= 12 && date.day >= 1 && date.day <= daysIn(date.year, date.month);
1190
+ }
1191
+ function parseTime(word) {
1192
+ if (word === "noon")
1193
+ return { hour: 12, minute: 0 };
1194
+ if (word === "midnight")
1195
+ return { hour: 0, minute: 0 };
1196
+ const m = /^(\d{1,2})(?::(\d{2}))? ?(am|pm)?$/.exec(word);
1197
+ if (!m)
1198
+ return null;
1199
+ let hour = Number(m[1]);
1200
+ const minute = m[2] === undefined ? 0 : Number(m[2]);
1201
+ const meridiem = m[3];
1202
+ if (minute > 59)
1203
+ return null;
1204
+ if (meridiem) {
1205
+ if (hour < 1 || hour > 12)
1206
+ return null;
1207
+ hour = hour % 12 + (meridiem === "pm" ? 12 : 0);
1208
+ } else if (hour > 23) {
1209
+ return null;
1210
+ }
1211
+ return { hour, minute };
1212
+ }
1213
+ function weekday(word) {
1214
+ if (word.length < 3)
1215
+ return -1;
1216
+ return WEEKDAYS2.findIndex((d) => d.startsWith(word));
1217
+ }
1218
+ function parseWhen(text, now, options) {
1219
+ const result = resolve(text, now, options);
1220
+ if (result.ok && result.at.getTime() - now.getTime() > MAX_AHEAD_MS) {
1221
+ return fail(text, `${text} is more than ${MAX_AHEAD_YEARS} years away.`);
1222
+ }
1223
+ return result;
1224
+ }
1225
+ function resolve(text, now, options) {
1226
+ const words = text.trim().toLowerCase().replace(/\s+/g, " ").replace(/,/g, "");
1227
+ if (words === "")
1228
+ return fail(text, "Say when.");
1229
+ if (ISO_RE.test(words)) {
1230
+ const [year, month, day] = words.slice(0, 10).split("-").map(Number);
1231
+ if (!validDate({ year: year ?? 0, month: month ?? 0, day: day ?? 0 }))
1232
+ return fail(text, `${words.slice(0, 10)} is not a date.`);
1233
+ const at = new Date(Date.parse(words.toUpperCase()));
1234
+ if (Number.isNaN(at.getTime()))
1235
+ return fail(text);
1236
+ return at > now ? { ok: true, at } : fail(text, `${text} has already passed.`);
1237
+ }
1238
+ const rel = RELATIVE_RE.exec(words);
1239
+ if (rel) {
1240
+ const n = rel[1] === "a" || rel[1] === "an" ? 1 : Number(rel[1]);
1241
+ const unit = UNITS[rel[2] ?? ""];
1242
+ if (unit === undefined || n < 1)
1243
+ return fail(text);
1244
+ if (n * unit > MAX_AHEAD_MS)
1245
+ return fail(text, `${text} is more than ${MAX_AHEAD_YEARS} years away.`);
1246
+ return { ok: true, at: new Date(now.getTime() + n * unit) };
1247
+ }
1248
+ if (!knownZone(options.zone))
1249
+ return fail(text, `I don't know the time zone ${options.zone}.`);
1250
+ let dayWord;
1251
+ let timeWord;
1252
+ const timeFirst = TIME_DAY_RE.exec(words);
1253
+ const dayFirst = DAY_TIME_RE.exec(words);
1254
+ if (timeFirst && parseTime(timeFirst[1] ?? "")) {
1255
+ timeWord = timeFirst[1];
1256
+ dayWord = timeFirst[2];
1257
+ } else if (dayFirst) {
1258
+ dayWord = dayFirst[1];
1259
+ timeWord = dayFirst[2];
1260
+ } else {
1261
+ return fail(text);
1262
+ }
1263
+ const time = timeWord === undefined ? { hour: options.defaultHour, minute: 0 } : parseTime(timeWord);
1264
+ if (!time)
1265
+ return fail(text);
1266
+ const clock = wallClock(now, options.zone);
1267
+ const today = { year: clock.year, month: clock.month, day: clock.day };
1268
+ const at = (date) => zonedInstant(date.year, date.month, date.day, time.hour, time.minute, options.zone);
1269
+ if (dayWord === undefined) {
1270
+ const first = at(today);
1271
+ return { ok: true, at: first > now ? first : at(addDays(today, 1)) };
1272
+ }
1273
+ if (dayWord === "today") {
1274
+ const when = at(today);
1275
+ return when > now ? { ok: true, at: when } : fail(text, `${text} has already passed.`);
1276
+ }
1277
+ if (dayWord === "tomorrow" || dayWord === "tmrw")
1278
+ return { ok: true, at: at(addDays(today, 1)) };
1279
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayWord);
1280
+ if (iso) {
1281
+ const date = { year: Number(iso[1]), month: Number(iso[2]), day: Number(iso[3]) };
1282
+ if (!validDate(date))
1283
+ return fail(text, `${dayWord} is not a date.`);
1284
+ const when = at(date);
1285
+ return when > now ? { ok: true, at: when } : fail(text, `${text} has already passed.`);
1286
+ }
1287
+ const md = /^(\d{1,2})\/(\d{1,2})$/.exec(dayWord);
1288
+ if (md) {
1289
+ const month = Number(md[1]);
1290
+ const day = Number(md[2]);
1291
+ if (month === today.month && day === today.day) {
1292
+ const when = at(today);
1293
+ return when > now ? { ok: true, at: when } : fail(text, `${text} has already passed.`);
1294
+ }
1295
+ for (let year = today.year;year <= today.year + 4; year++) {
1296
+ const date = { year, month, day };
1297
+ if (validDate(date) && at(date) > now)
1298
+ return { ok: true, at: at(date) };
1299
+ }
1300
+ return fail(text, `${dayWord} is not a date.`);
1301
+ }
1302
+ const wd = weekday(dayWord);
1303
+ if (wd === -1)
1304
+ return fail(text);
1305
+ const ahead = (wd - clock.weekday + 7) % 7;
1306
+ const sameDay = at(addDays(today, ahead));
1307
+ return { ok: true, at: sameDay > now ? sameDay : at(addDays(today, ahead + 7)) };
1308
+ }
1309
+
1310
+ // node_modules/@rackbops/docket-types/dist/extract.js
1311
+ function parsePrice(text) {
1312
+ const m = /-?\d[\d.,\s]*/.exec(text.replace(/\u00A0/g, " "));
1313
+ if (!m)
1314
+ return null;
1315
+ let digits = m[0].replace(/\s+/g, "");
1316
+ const negative = digits.startsWith("-");
1317
+ if (negative)
1318
+ digits = digits.slice(1);
1319
+ const decimal = /[.,](\d{1,2})$/.exec(digits);
1320
+ let whole = digits;
1321
+ let fraction = "";
1322
+ if (decimal) {
1323
+ whole = digits.slice(0, decimal.index);
1324
+ fraction = decimal[1] ?? "";
1325
+ }
1326
+ whole = whole.replace(/[.,]/g, "");
1327
+ if (whole === "" && fraction === "")
1328
+ return null;
1329
+ const value = Number(`${whole || "0"}.${fraction || "0"}`);
1330
+ if (!Number.isFinite(value))
1331
+ return null;
1332
+ return negative ? -value : value;
1333
+ }
1334
+ var CURRENCY = /^[A-Z]{3}$/;
1335
+ function asCurrency(value) {
1336
+ return typeof value === "string" && CURRENCY.test(value.trim().toUpperCase()) ? value.trim().toUpperCase() : undefined;
1337
+ }
1338
+ function asPrice(value) {
1339
+ if (typeof value === "number")
1340
+ return Number.isFinite(value) ? value : null;
1341
+ if (typeof value === "string")
1342
+ return parsePrice(value);
1343
+ return null;
1344
+ }
1345
+ function isRecord(value) {
1346
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1347
+ }
1348
+ function priceFromJson(value, depth = 0) {
1349
+ if (depth > 12)
1350
+ return null;
1351
+ if (Array.isArray(value)) {
1352
+ for (const item of value) {
1353
+ const found = priceFromJson(item, depth + 1);
1354
+ if (found)
1355
+ return found;
1356
+ }
1357
+ return null;
1358
+ }
1359
+ if (!isRecord(value))
1360
+ return null;
1361
+ for (const key of ["price", "lowPrice"]) {
1362
+ const price = asPrice(value[key]);
1363
+ if (price !== null) {
1364
+ const currency = asCurrency(value.priceCurrency ?? value.currency);
1365
+ return currency ? { value: price, currency } : { value: price };
1366
+ }
1367
+ }
1368
+ const spec = value.priceSpecification;
1369
+ if (spec) {
1370
+ const found = priceFromJson(spec, depth + 1);
1371
+ if (found)
1372
+ return found;
1373
+ }
1374
+ if (value.offers) {
1375
+ const found = priceFromJson(value.offers, depth + 1);
1376
+ if (found)
1377
+ return found;
1378
+ }
1379
+ for (const [key, child] of Object.entries(value)) {
1380
+ if (key === "offers" || key === "priceSpecification")
1381
+ continue;
1382
+ const found = priceFromJson(child, depth + 1);
1383
+ if (found)
1384
+ return found;
1385
+ }
1386
+ return null;
1387
+ }
1388
+ var JSON_LD = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
1389
+ function jsonLdBlocks(html) {
1390
+ const blocks = [];
1391
+ for (const m of html.matchAll(JSON_LD)) {
1392
+ try {
1393
+ blocks.push(JSON.parse(m[1] ?? ""));
1394
+ } catch {}
1395
+ }
1396
+ return blocks;
1397
+ }
1398
+ var META_KEYS = ["product:price:amount", "og:price:amount", "price"];
1399
+ var META_CURRENCY_KEYS = ["product:price:currency", "og:price:currency", "priceCurrency"];
1400
+ function metaContent(html, key) {
1401
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1402
+ const tag = new RegExp(`<meta[^>]*(?:property|name|itemprop)\\s*=\\s*["']${escaped}["'][^>]*>`, "i").exec(html);
1403
+ if (!tag)
1404
+ return null;
1405
+ const content = /content\s*=\s*["']([^"']*)["']/i.exec(tag[0]);
1406
+ return content?.[1] ?? null;
1407
+ }
1408
+ function priceFromMeta(html) {
1409
+ for (const key of META_KEYS) {
1410
+ const raw = metaContent(html, key);
1411
+ const value = raw === null ? null : parsePrice(raw);
1412
+ if (value === null)
1413
+ continue;
1414
+ for (const ck of META_CURRENCY_KEYS) {
1415
+ const currency = asCurrency(metaContent(html, ck));
1416
+ if (currency)
1417
+ return { value, currency };
1418
+ }
1419
+ return { value };
1420
+ }
1421
+ return null;
1422
+ }
1423
+ function priceFromPattern(body, pattern) {
1424
+ let re;
1425
+ try {
1426
+ re = new RegExp(pattern, "i");
1427
+ } catch {
1428
+ return null;
1429
+ }
1430
+ const m = re.exec(body);
1431
+ const captured = m?.[1] ?? m?.[0];
1432
+ if (captured === undefined)
1433
+ return null;
1434
+ const value = parsePrice(captured);
1435
+ return value === null ? null : { value };
1436
+ }
1437
+ function extractPrice(body, pattern) {
1438
+ if (pattern)
1439
+ return priceFromPattern(body, pattern);
1440
+ for (const block of jsonLdBlocks(body)) {
1441
+ const found = priceFromJson(block);
1442
+ if (found)
1443
+ return found;
1444
+ }
1445
+ const meta = priceFromMeta(body);
1446
+ if (meta)
1447
+ return meta;
1448
+ const trimmed = body.trim();
1449
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1450
+ try {
1451
+ return priceFromJson(JSON.parse(trimmed));
1452
+ } catch {
1453
+ return null;
1454
+ }
1455
+ }
1456
+ return null;
1457
+ }
1458
+
1459
+ // node_modules/@rackbops/docket-types/dist/money.js
1460
+ function money(amount, currency) {
1461
+ return `${amount.toFixed(2)} ${currency}`.trim();
1462
+ }
1463
+
1464
+ // node_modules/@rackbops/docket-types/dist/price.js
1465
+ var BASELINE_RULES = ["first", "last", "peak"];
1466
+ var DEFAULT_DROP_PERCENT = 10;
1467
+ var DEFAULT_BASELINE = "last";
1468
+ var MISSES_BEFORE_TELLING = 3;
1469
+ var FRESH = {
1470
+ first: null,
1471
+ last: null,
1472
+ peak: null,
1473
+ below: false,
1474
+ misses: 0,
1475
+ alerts: 0
1476
+ };
1477
+ function stateOf(value) {
1478
+ const s = value;
1479
+ if (!s || typeof s !== "object" || typeof s.misses !== "number")
1480
+ return { ...FRESH };
1481
+ return {
1482
+ first: s.first ?? null,
1483
+ last: s.last ?? null,
1484
+ peak: s.peak ?? null,
1485
+ below: s.below ?? false,
1486
+ misses: s.misses,
1487
+ alerts: s.alerts ?? 0
1488
+ };
1489
+ }
1490
+ function dropPercent(config) {
1491
+ const d = config.dropPercent;
1492
+ return typeof d === "number" && Number.isFinite(d) && d > 0 && d < 100 ? d : DEFAULT_DROP_PERCENT;
1493
+ }
1494
+ function baselineRule(config) {
1495
+ return config.baseline && BASELINE_RULES.includes(config.baseline) ? config.baseline : DEFAULT_BASELINE;
1496
+ }
1497
+ function reference(state, rule) {
1498
+ switch (rule) {
1499
+ case "first":
1500
+ return state.first;
1501
+ case "last":
1502
+ return state.last;
1503
+ case "peak":
1504
+ return state.peak;
1505
+ }
1506
+ }
1507
+ function miss(ctx, state, reason) {
1508
+ const misses = state.misses + 1;
1509
+ const outcome = { state: { ...state, misses }, summary: `no price: ${reason}` };
1510
+ if (misses === MISSES_BEFORE_TELLING) {
1511
+ outcome.notify = {
1512
+ text: `${ctx.task.title}: no price read from ${ctx.config.url} ${misses} polls in a row ` + `(${reason}). Check the page or the pattern; reply done to stop tracking.`,
1513
+ actions: ["done"]
1514
+ };
1515
+ }
1516
+ return outcome;
1517
+ }
1518
+ var price = defineTaskType({
1519
+ id: "price",
1520
+ lane: "notify",
1521
+ capabilities: ["notify"],
1522
+ schedule: ["poll"],
1523
+ intake: {
1524
+ options: [
1525
+ { name: "url", description: "The product page", required: true, kind: "string" },
1526
+ { name: "every", description: "Hours between checks", required: true, kind: "integer" },
1527
+ {
1528
+ name: "drop",
1529
+ description: "Alert on a drop of at least this percent; default 10",
1530
+ required: false,
1531
+ kind: "number"
1532
+ },
1533
+ {
1534
+ name: "baseline",
1535
+ description: "Measure the drop from the first, last or peak price seen; default last",
1536
+ required: false,
1537
+ kind: "string"
1538
+ },
1539
+ {
1540
+ name: "pattern",
1541
+ description: "A regular expression whose first group is the price, if the page needs one",
1542
+ required: false,
1543
+ kind: "string"
1544
+ }
1545
+ ]
1546
+ },
1547
+ async run(ctx) {
1548
+ const { fetch } = ctx.ports;
1549
+ if (!fetch)
1550
+ throw new Error("the price type needs the Fetch port; the host configured none");
1551
+ const state = stateOf(ctx.state);
1552
+ let body;
1553
+ try {
1554
+ const response = await fetch.get(ctx.config.url);
1555
+ if (response.status !== 200)
1556
+ return miss(ctx, state, `HTTP ${response.status}`);
1557
+ body = response.body;
1558
+ } catch (err) {
1559
+ return miss(ctx, state, `fetch failed: ${err instanceof Error ? err.message : String(err)}`);
1560
+ }
1561
+ const found = extractPrice(body, ctx.config.pattern);
1562
+ if (!found)
1563
+ return miss(ctx, state, "no price in the page");
1564
+ const seen = found.value;
1565
+ const currency = found.currency ?? ctx.config.currency ?? "";
1566
+ const point = currency ? { value: seen, unit: currency, note: "observed" } : { value: seen, note: "observed" };
1567
+ const rule = baselineRule(ctx.config);
1568
+ const drop = dropPercent(ctx.config);
1569
+ const ref = reference(state, rule);
1570
+ if (ref === null) {
1571
+ return {
1572
+ state: { first: seen, last: seen, peak: seen, below: false, misses: 0, alerts: 0 },
1573
+ series: [point],
1574
+ notify: {
1575
+ text: `Now tracking ${ctx.task.title} at ${money(seen, currency)}. I will say when it ` + `drops ${drop}% or more from the ${rule} price seen. ${ctx.config.url}`,
1576
+ actions: ["done"]
1577
+ },
1578
+ summary: `baseline ${money(seen, currency)}`
1579
+ };
1580
+ }
1581
+ const line = ref * (1 - drop / 100);
1582
+ const below = seen <= line + 0.000000001;
1583
+ const crossing = below && !state.below;
1584
+ const next = {
1585
+ first: state.first ?? seen,
1586
+ last: seen,
1587
+ peak: Math.max(state.peak ?? seen, seen),
1588
+ below,
1589
+ misses: 0,
1590
+ alerts: state.alerts + (crossing ? 1 : 0)
1591
+ };
1592
+ if (!crossing) {
1593
+ return {
1594
+ state: next,
1595
+ series: [point],
1596
+ summary: `${money(seen, currency)} (${rule} ${money(ref, currency)})`
1597
+ };
1598
+ }
1599
+ const pct = Math.round((ref - seen) / ref * 100);
1600
+ return {
1601
+ state: next,
1602
+ series: [point],
1603
+ notify: {
1604
+ text: `${ctx.task.title}: ${money(seen, currency)}, down ${pct}% from ` + `${money(ref, currency)} (${rule} seen). ${ctx.config.url}`,
1605
+ actions: ["done"]
1606
+ },
1607
+ summary: `alert: ${money(seen, currency)}, down ${pct}% from ${money(ref, currency)}`
1608
+ };
1609
+ },
1610
+ async onReply(ctx) {
1611
+ if (ctx.reply.kind === "done")
1612
+ return { complete: true, summary: "stopped tracking" };
1613
+ return {};
1614
+ }
1615
+ });
1616
+
1617
+ // node_modules/@rackbops/docket-types/dist/reminder.js
1618
+ var DEFAULT_SNOOZE_MS = 60 * 60000;
1619
+ function snoozeUntil(payload, now) {
1620
+ if (typeof payload === "object" && payload !== null) {
1621
+ const p = payload;
1622
+ if (typeof p.until === "string" && !Number.isNaN(Date.parse(p.until)))
1623
+ return new Date(p.until);
1624
+ if (typeof p.minutes === "number" && Number.isFinite(p.minutes) && p.minutes > 0) {
1625
+ return new Date(now.getTime() + p.minutes * 60000);
1626
+ }
1627
+ }
1628
+ return new Date(now.getTime() + DEFAULT_SNOOZE_MS);
1629
+ }
1630
+ var reminder = defineTaskType({
1631
+ id: "reminder",
1632
+ lane: "notify",
1633
+ capabilities: ["notify"],
1634
+ schedule: ["once", "calendar"],
1635
+ intake: {
1636
+ options: [
1637
+ { name: "text", description: "What to be reminded of", required: true, kind: "string" },
1638
+ {
1639
+ name: "at",
1640
+ description: "When, for a one-off reminder (an instant; the bot parses the person's words)",
1641
+ required: false,
1642
+ kind: "datetime"
1643
+ }
1644
+ ]
1645
+ },
1646
+ async run(ctx) {
1647
+ return {
1648
+ notify: { text: ctx.config.text, actions: ["done", "snooze"] },
1649
+ summary: ctx.config.text
1650
+ };
1651
+ },
1652
+ async onReply(ctx) {
1653
+ if (ctx.reply.kind === "snooze") {
1654
+ const until = snoozeUntil(ctx.reply.payload, ctx.now);
1655
+ return { snoozeUntil: until, summary: `snoozed until ${until.toISOString()}` };
1656
+ }
1657
+ if (ctx.reply.kind === "done")
1658
+ return { summary: "done" };
1659
+ return {};
1660
+ }
1661
+ });
1662
+
1663
+ // node_modules/@rackbops/docket-types/dist/renewal.js
1664
+ var RENEWAL_DECISIONS = ["keep", "cancel", "renewed"];
1665
+ function isRenewalDecision(value) {
1666
+ return RENEWAL_DECISIONS.includes(value);
1667
+ }
1668
+ function stateOf2(ctx) {
1669
+ const s = ctx.state;
1670
+ if (s && typeof s.amount === "number" && Number.isFinite(s.amount)) {
1671
+ return {
1672
+ amount: s.amount,
1673
+ decision: s.decision ?? null,
1674
+ decidedAt: s.decidedAt ?? null,
1675
+ periodDate: s.periodDate ?? null
1676
+ };
1677
+ }
1678
+ return { amount: ctx.config.amount, decision: null, decidedAt: null, periodDate: null };
1679
+ }
1680
+ function decisionOf(payload) {
1681
+ if (typeof payload === "string") {
1682
+ return { choice: isRenewalDecision(payload) ? payload : null, amount: null };
1683
+ }
1684
+ if (typeof payload === "object" && payload !== null) {
1685
+ const p = payload;
1686
+ const choice = typeof p.choice === "string" && isRenewalDecision(p.choice) ? p.choice : null;
1687
+ const amount = typeof p.amount === "number" && Number.isFinite(p.amount) && p.amount >= 0 ? p.amount : null;
1688
+ return { choice, amount };
1689
+ }
1690
+ return { choice: null, amount: null };
1691
+ }
1692
+ function periodDateOf(ctx) {
1693
+ const due = new Date(ctx.originalDueAt ?? ctx.occurrence.dueAt);
1694
+ const zone = ctx.owner.timeZone;
1695
+ if (ctx.task.schedule?.kind === "period")
1696
+ return periodDate(ctx.task.schedule, due, zone);
1697
+ const w = wallClock(due, zone);
1698
+ return `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
1699
+ }
1700
+ function plural(n, word) {
1701
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
1702
+ }
1703
+ function when(date, ctx) {
1704
+ const days = daysUntil(date, ctx.now, ctx.owner.timeZone);
1705
+ if (days === null || days > 0) {
1706
+ return days === null ? `on ${date}` : `on ${date}, in ${plural(days, "day")}`;
1707
+ }
1708
+ return days === 0 ? `today, ${date}` : `on ${date}, ${plural(-days, "day")} ago`;
1709
+ }
1710
+ var renewal = defineTaskType({
1711
+ id: "renewal",
1712
+ lane: "notify",
1713
+ capabilities: ["notify"],
1714
+ schedule: ["period", "once"],
1715
+ intake: {
1716
+ options: [
1717
+ { name: "amount", description: "What one period costs", required: true, kind: "number" },
1718
+ { name: "currency", description: "Currency code, e.g. USD", required: true, kind: "string" },
1719
+ {
1720
+ name: "renews",
1721
+ description: "The next renewal or expiry date, YYYY-MM-DD",
1722
+ required: true,
1723
+ kind: "string"
1724
+ },
1725
+ {
1726
+ name: "every",
1727
+ description: "Periods between renewals (with unit); default 1",
1728
+ required: false,
1729
+ kind: "integer"
1730
+ },
1731
+ {
1732
+ name: "unit",
1733
+ description: "day, week, month or year; default year",
1734
+ required: false,
1735
+ kind: "string"
1736
+ },
1737
+ {
1738
+ name: "lead",
1739
+ description: "Days before the date to ask; default 7",
1740
+ required: false,
1741
+ kind: "integer"
1742
+ },
1743
+ { name: "note", description: "Carried on every reminder", required: false, kind: "string" }
1744
+ ]
1745
+ },
1746
+ async run(ctx) {
1747
+ const state = stateOf2(ctx);
1748
+ const date = periodDateOf(ctx);
1749
+ const cost = money(state.amount, ctx.config.currency);
1750
+ const lines = [
1751
+ `${ctx.task.title} renews ${when(date, ctx)}: ${cost}. Keep it, cancel it, or mark it renewed.`
1752
+ ];
1753
+ if (ctx.config.note)
1754
+ lines.push(ctx.config.note);
1755
+ const paid = ctx.history.series.filter((p) => p.note === "keep" || p.note === "renewed");
1756
+ if (paid.length > 0) {
1757
+ const total = paid.reduce((sum, p) => sum + p.value, 0);
1758
+ lines.push(`So far: ${plural(paid.length, "period")}, ${money(total, ctx.config.currency)}.`);
1759
+ }
1760
+ if (state.decision && state.periodDate) {
1761
+ lines.push(`Last time (${state.periodDate}): ${state.decision}.`);
1762
+ }
1763
+ return {
1764
+ notify: {
1765
+ text: lines.join(`
1766
+ `),
1767
+ actions: ["decision", "snooze"],
1768
+ decisions: [...RENEWAL_DECISIONS]
1769
+ },
1770
+ summary: `asked: renews ${date} for ${cost}`
1771
+ };
1772
+ },
1773
+ async onReply(ctx) {
1774
+ if (ctx.reply.kind === "snooze") {
1775
+ const until = snoozeUntil(ctx.reply.payload, ctx.now);
1776
+ return { snoozeUntil: until, summary: `snoozed until ${until.toISOString()}` };
1777
+ }
1778
+ if (ctx.reply.kind !== "decision")
1779
+ return {};
1780
+ const { choice, amount } = decisionOf(ctx.reply.payload);
1781
+ if (!choice)
1782
+ return { summary: `decision not understood; say ${RENEWAL_DECISIONS.join(", ")}` };
1783
+ const previous = stateOf2(ctx);
1784
+ const date = periodDateOf(ctx);
1785
+ const state = {
1786
+ amount: amount ?? previous.amount,
1787
+ decision: choice,
1788
+ decidedAt: ctx.now.toISOString(),
1789
+ periodDate: date
1790
+ };
1791
+ if (choice === "cancel")
1792
+ return { state, complete: true, summary: `cancelled before ${date}` };
1793
+ const point = {
1794
+ value: state.amount,
1795
+ unit: ctx.config.currency,
1796
+ note: choice
1797
+ };
1798
+ return {
1799
+ state,
1800
+ series: [point],
1801
+ summary: `${choice} for ${date} at ${money(state.amount, ctx.config.currency)}`
1802
+ };
1803
+ }
1804
+ });
1805
+ // node_modules/@rackbops/docket-types/dist/index.js
1806
+ var TASK_TYPES = Object.freeze({
1807
+ reminder,
1808
+ renewal,
1809
+ price
1810
+ });
1811
+
1812
+ // plugins/tracker/src/access.ts
1813
+ var NOT_MEMBER = "This tracker is only for members of its server.";
1814
+ var MEMBERSHIP_UNKNOWN = "I could not check that you are a member of this tracker's server. Try again in a minute.";
1815
+ var NOT_ADMITTED = "You are not on this tracker's list yet: ask an admin to `/allow` you.";
1816
+ var NOT_REGISTERED = "Run `/register` first: it sets when your reminders reach you.";
1817
+ var NOT_ADMIN = "Only a tracker admin can do that.";
1818
+ function decideAccess(input) {
1819
+ if (input.membership === "not-member")
1820
+ return NOT_MEMBER;
1821
+ if (input.membership === "unknown")
1822
+ return MEMBERSHIP_UNKNOWN;
1823
+ if (!input.person)
1824
+ return NOT_ADMITTED;
1825
+ if (input.need === "admin")
1826
+ return input.person.admin ? null : NOT_ADMIN;
1827
+ if (input.need === "registered" && !input.registered)
1828
+ return NOT_REGISTERED;
1829
+ return null;
1830
+ }
1831
+ function parseGuildId(raw) {
1832
+ if (raw === undefined || raw.trim() === "")
1833
+ return null;
1834
+ const id = raw.trim();
1835
+ if (!/^[0-9]{17,20}$/.test(id))
1836
+ throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
1837
+ return id;
1838
+ }
1839
+
1840
+ // plugins/tracker/src/admissions.ts
1841
+ class Admissions {
1842
+ db;
1843
+ constructor(db) {
1844
+ this.db = db;
1845
+ }
1846
+ get(userId) {
1847
+ const r = this.db.query("SELECT * FROM admissions WHERE user_id = ?").get(userId);
1848
+ if (!r)
1849
+ return { userId, admittedBy: null, admittedAt: null, registeredAt: null };
1850
+ return { userId, admittedBy: r.admitted_by, admittedAt: r.admitted_at, registeredAt: r.registered_at };
1851
+ }
1852
+ record(userId, admittedBy, at) {
1853
+ this.db.query("INSERT INTO admissions (user_id, admitted_by, admitted_at) VALUES (?, ?, ?) ON CONFLICT (user_id) DO NOTHING").run(userId, admittedBy, at);
1854
+ }
1855
+ markRegistered(userId, at) {
1856
+ this.db.query(`INSERT INTO admissions (user_id, admitted_by, admitted_at, registered_at) VALUES (?, NULL, ?, ?)
1857
+ ON CONFLICT (user_id) DO UPDATE SET registered_at = COALESCE(admissions.registered_at, excluded.registered_at)`).run(userId, at, at);
1858
+ }
1859
+ isRegistered(userId) {
1860
+ return this.get(userId).registeredAt !== null;
1861
+ }
1862
+ }
1863
+
1864
+ // plugins/tracker/src/claims.ts
1865
+ function toClaim(r) {
1866
+ const s = (v) => v === null || v === undefined ? null : String(v);
1867
+ return {
1868
+ occurrenceId: String(r.occurrence_id),
1869
+ userId: String(r.user_id),
1870
+ discordId: String(r.discord_id),
1871
+ status: String(r.status),
1872
+ messageId: s(r.message_id),
1873
+ channelId: s(r.channel_id),
1874
+ error: s(r.error),
1875
+ claimedAt: String(r.claimed_at),
1876
+ settledAt: s(r.settled_at),
1877
+ reportedAt: s(r.reported_at)
1878
+ };
1879
+ }
1880
+
1881
+ class ClaimStore {
1882
+ db;
1883
+ constructor(db) {
1884
+ this.db = db;
1885
+ }
1886
+ get(occurrenceId, userId) {
1887
+ const r = this.db.query("SELECT * FROM delivery_claims WHERE occurrence_id = ? AND user_id = ?").get(occurrenceId, userId);
1888
+ return r ? toClaim(r) : null;
1889
+ }
1890
+ claim(occurrenceId, userId, discordId, at) {
1891
+ const { changes } = this.db.query(`INSERT INTO delivery_claims (occurrence_id, user_id, discord_id, status, claimed_at)
1892
+ VALUES (?, ?, ?, 'claimed', ?)
1893
+ ON CONFLICT (occurrence_id, user_id) DO UPDATE SET
1894
+ discord_id = excluded.discord_id, status = 'claimed', claimed_at = excluded.claimed_at,
1895
+ message_id = NULL, channel_id = NULL, error = NULL, settled_at = NULL, reported_at = NULL
1896
+ WHERE delivery_claims.status = 'failed'`).run(occurrenceId, userId, discordId, at);
1897
+ return changes === 1;
1898
+ }
1899
+ settle(occurrenceId, userId, status, at, detail = {}) {
1900
+ this.db.query(`UPDATE delivery_claims SET status = ?, message_id = ?, channel_id = ?, error = ?, settled_at = ?
1901
+ WHERE occurrence_id = ? AND user_id = ?`).run(status, detail.messageId ?? null, detail.channelId ?? null, detail.error ?? null, at, occurrenceId, userId);
1902
+ }
1903
+ markReported(occurrenceId, userId, at) {
1904
+ this.db.query("UPDATE delivery_claims SET reported_at = ? WHERE occurrence_id = ? AND user_id = ?").run(at, occurrenceId, userId);
1905
+ }
1906
+ listUnsettled() {
1907
+ return this.db.query("SELECT * FROM delivery_claims WHERE status IN ('claimed', 'unconfirmed') ORDER BY claimed_at").all().map(toClaim);
1908
+ }
1909
+ }
1910
+
1911
+ // plugins/tracker/src/delivery-health.ts
1912
+ var PAUSE_AFTER = 3;
1913
+ var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
1914
+ function decidePause(failures, alreadyPaused) {
1915
+ return !alreadyPaused && failures >= PAUSE_AFTER;
1916
+ }
1917
+ function name2(u, fallback) {
1918
+ return u?.displayName ?? fallback;
1919
+ }
1920
+ function recipientPausedText(task, recipient, recipientId) {
1921
+ return `Your task \`${task.id}\` (${task.title}) is paused: I could not DM ${name2(recipient, recipientId)} ${PAUSE_AFTER} times in a row. ` + `It resumes when they next use the tracker, or run \`/task resume ${task.id}\` to go on without them.`;
1922
+ }
1923
+
1924
+ class DeliveryHealth {
1925
+ db;
1926
+ store;
1927
+ options;
1928
+ chain = Promise.resolve();
1929
+ constructor(db, store, options = {}) {
1930
+ this.db = db;
1931
+ this.store = store;
1932
+ this.options = options;
1933
+ }
1934
+ locked(fn) {
1935
+ const next = this.chain.then(fn, fn);
1936
+ this.chain = next.catch(() => {});
1937
+ return next;
1938
+ }
1939
+ get(userId) {
1940
+ const r = this.db.query("SELECT * FROM delivery_health WHERE user_id = ?").get(userId);
1941
+ if (!r)
1942
+ return null;
1943
+ return { userId: r.user_id, failures: Number(r.failures), lastError: r.last_error, lastFailedAt: r.last_failed_at, pausedAt: r.paused_at };
1944
+ }
1945
+ isPaused(userId) {
1946
+ return this.get(userId)?.pausedAt != null;
1947
+ }
1948
+ pausesFor(taskId) {
1949
+ return this.db.query("SELECT user_id FROM delivery_pauses WHERE task_id = ? ORDER BY at, user_id").all(taskId).map((r) => r.user_id);
1950
+ }
1951
+ recordSuccess(userId) {
1952
+ this.db.query("UPDATE delivery_health SET failures = 0 WHERE user_id = ? AND paused_at IS NULL").run(userId);
1953
+ }
1954
+ async recordFailure(userId, error, at) {
1955
+ const notices = [];
1956
+ const result = await this.locked(async () => {
1957
+ this.db.query(`INSERT INTO delivery_health (user_id, failures, last_error, last_failed_at) VALUES (?, 1, ?, ?)
1958
+ ON CONFLICT (user_id) DO UPDATE SET failures = delivery_health.failures + 1,
1959
+ last_error = excluded.last_error, last_failed_at = excluded.last_failed_at`).run(userId, error, at);
1960
+ const row = this.get(userId);
1961
+ const pauses = decidePause(row.failures, row.pausedAt !== null);
1962
+ if (!pauses && row.pausedAt === null)
1963
+ return { failures: row.failures, paused: false };
1964
+ if (pauses)
1965
+ this.db.query("UPDATE delivery_health SET paused_at = ? WHERE user_id = ?").run(at, userId);
1966
+ await this.pauseAffected(userId, at, notices);
1967
+ return { failures: row.failures, paused: pauses };
1968
+ });
1969
+ for (const n of notices)
1970
+ await this.tellOwner(n);
1971
+ return result;
1972
+ }
1973
+ async affected(userId) {
1974
+ const found = [];
1975
+ for (const task of await this.store.listTasks()) {
1976
+ if (task.ownerId === userId)
1977
+ found.push({ task, asRecipient: false });
1978
+ else if ((await this.store.listRecipients(task.id)).some((r) => r.userId === userId && r.state === "accepted")) {
1979
+ found.push({ task, asRecipient: true });
1980
+ }
1981
+ }
1982
+ return found;
1983
+ }
1984
+ async pauseAffected(userId, at, notices) {
1985
+ const recipient = await this.store.getUser(userId);
1986
+ for (const { task, asRecipient } of await this.affected(userId)) {
1987
+ if (task.status !== "active" && this.pausesFor(task.id).length === 0)
1988
+ continue;
1989
+ const { changes } = this.db.query("INSERT INTO delivery_pauses (task_id, user_id, at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING").run(task.id, userId, at);
1990
+ if (changes === 0 || task.status !== "active")
1991
+ continue;
1992
+ await this.store.updateTask(task.id, { status: "paused", at });
1993
+ const detail = asRecipient ? `delivery to ${name2(recipient, userId)} paused: ${PAUSE_AFTER} DMs in a row could not be delivered` : PAUSE_DETAIL;
1994
+ await this.store.addTaskEvent({ taskId: task.id, actorId: null, kind: "paused", detail, at });
1995
+ if (asRecipient) {
1996
+ const owner = await this.store.getUser(task.ownerId);
1997
+ if (owner)
1998
+ notices.push({ owner, task, text: recipientPausedText(task, recipient, userId) });
1999
+ }
2000
+ }
2001
+ }
2002
+ async tellOwner({ owner, task, text }) {
2003
+ if (!this.options.tellOwner)
2004
+ return;
2005
+ try {
2006
+ await this.options.tellOwner(owner, text);
2007
+ } catch (err) {
2008
+ this.options.log?.warn(`could not tell ${owner.id} that ${task.id} paused: ${err instanceof Error ? err.message : String(err)}`);
2009
+ }
2010
+ }
2011
+ async release(taskId, actorId, detail, now) {
2012
+ if (this.pausesFor(taskId).length > 0)
2013
+ return false;
2014
+ const task = await this.store.getTask(taskId);
2015
+ if (!task || task.status !== "paused")
2016
+ return false;
2017
+ const at = now.toISOString();
2018
+ const active = await this.store.updateTask(taskId, { status: "active", at });
2019
+ await this.store.addTaskEvent({ taskId, actorId, kind: "resumed", detail, at });
2020
+ const owner = await this.store.getUser(task.ownerId);
2021
+ if (owner)
2022
+ await materialize(this.store, active, owner, now);
2023
+ return true;
2024
+ }
2025
+ resume(userId, now) {
2026
+ return this.locked(async () => {
2027
+ const row = this.get(userId);
2028
+ if (!row?.pausedAt)
2029
+ return null;
2030
+ const tasks = this.pausedTasksOf(userId);
2031
+ this.db.transaction(() => {
2032
+ this.db.query("UPDATE delivery_health SET failures = 0, paused_at = NULL WHERE user_id = ?").run(userId);
2033
+ this.db.query("DELETE FROM delivery_pauses WHERE user_id = ?").run(userId);
2034
+ })();
2035
+ const resumed = [];
2036
+ for (const id of tasks)
2037
+ if (await this.release(id, userId, "delivery resumed", now))
2038
+ resumed.push(id);
2039
+ return { pausedAt: row.pausedAt, tasks: resumed };
2040
+ });
2041
+ }
2042
+ pausedTasksOf(userId) {
2043
+ return this.db.query("SELECT task_id FROM delivery_pauses WHERE user_id = ? ORDER BY at, task_id").all(userId).map((r) => r.task_id);
2044
+ }
2045
+ resumeTask(task, ownerId, now) {
2046
+ return this.locked(async () => {
2047
+ const held = this.pausesFor(task.id).filter((u) => u !== ownerId);
2048
+ if (held.length === 0 && this.pausesFor(task.id).length === 0)
2049
+ return null;
2050
+ const at = now.toISOString();
2051
+ for (const userId of held) {
2052
+ this.db.query("DELETE FROM delivery_pauses WHERE task_id = ? AND user_id = ?").run(task.id, userId);
2053
+ await this.store.removeRecipient(task.id, userId);
2054
+ await this.store.addTaskEvent({ taskId: task.id, actorId: ownerId, kind: "recipient_removed", detail: `${userId}: DMs could not be delivered`, at });
2055
+ }
2056
+ await this.release(task.id, ownerId, "resumed by the owner", now);
2057
+ return held;
2058
+ });
2059
+ }
2060
+ }
2061
+ function resumedNotice(r) {
2062
+ const what = r.tasks.length === 0 ? "Nothing else was waiting on you." : `${r.tasks.length} task(s) are back on.`;
2063
+ 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
+ }
2065
+
2066
+ // plugins/tracker/src/people.ts
2067
+ var DEFAULT_TIME_ZONE = "America/New_York";
2068
+ var DEFAULT_PREFERRED_HOUR = 9;
2069
+ var SNOWFLAKE = /^[0-9]{17,20}$/;
2070
+
2071
+ class PeopleError extends Error {
2072
+ name = "PeopleError";
2073
+ }
2074
+ function parseAdminIds(raw) {
2075
+ if (raw === undefined || raw.trim() === "")
2076
+ return [];
2077
+ const ids = raw.split(",").map((s) => s.trim());
2078
+ for (const id of ids) {
2079
+ if (!SNOWFLAKE.test(id))
2080
+ throw new PeopleError(`TRACKER_ADMIN_DISCORD_IDS: "${id}" is not a Discord user id`);
2081
+ }
2082
+ return [...new Set(ids)];
2083
+ }
2084
+ async function admit(store, discordId, now, options = {}) {
2085
+ if (!SNOWFLAKE.test(discordId))
2086
+ throw new PeopleError(`"${discordId}" is not a Discord user id`);
2087
+ const existing = await store.findUserByDiscordId(discordId);
2088
+ if (existing)
2089
+ return existing;
2090
+ try {
2091
+ return await store.createUser({
2092
+ discordId,
2093
+ timeZone: DEFAULT_TIME_ZONE,
2094
+ preferredHour: DEFAULT_PREFERRED_HOUR,
2095
+ admin: options.admin ?? false,
2096
+ at: now.toISOString()
2097
+ });
2098
+ } catch (err) {
2099
+ const winner = await store.findUserByDiscordId(discordId);
2100
+ if (winner)
2101
+ return winner;
2102
+ throw err;
2103
+ }
2104
+ }
2105
+ async function seedAdmins(store, discordIds, now) {
2106
+ const granted = [];
2107
+ for (const discordId of discordIds) {
2108
+ const existing = await store.findUserByDiscordId(discordId);
2109
+ if (existing?.admin)
2110
+ continue;
2111
+ if (existing)
2112
+ await store.updateUser(existing.id, { admin: true });
2113
+ else
2114
+ await admit(store, discordId, now, { admin: true });
2115
+ granted.push(discordId);
2116
+ }
2117
+ return granted;
2118
+ }
2119
+ async function setPreferences(store, userId, prefs) {
2120
+ if (prefs.timeZone !== undefined && !isTimeZone(prefs.timeZone)) {
2121
+ throw new PeopleError(`"${prefs.timeZone}" is not a time zone (use an IANA name such as America/New_York)`);
2122
+ }
2123
+ if (prefs.preferredHour !== undefined && !(Number.isInteger(prefs.preferredHour) && prefs.preferredHour >= 0 && prefs.preferredHour <= 23)) {
2124
+ throw new PeopleError(`preferred hour must be a whole hour from 0 to 23, not ${prefs.preferredHour}`);
2125
+ }
2126
+ return store.updateUser(userId, {
2127
+ ...prefs.timeZone !== undefined ? { timeZone: prefs.timeZone } : {},
2128
+ ...prefs.preferredHour !== undefined ? { preferredHour: prefs.preferredHour } : {}
2129
+ });
2130
+ }
2131
+
2132
+ // plugins/tracker/src/actions.ts
2133
+ var MAX_ANSWER = 2000;
2134
+ var MAX_REMINDER_TEXT = 1500;
2135
+ var MAX_TITLE = 100;
2136
+ var NO_SUCH_TASK = "You have no task with that id. `/tasks` lists yours.";
2137
+ function clip(text, max = MAX_ANSWER) {
2138
+ return text.length <= max ? text : `${text.slice(0, max - 3)}...`;
2139
+ }
2140
+ async function enter(d, discordId, membership, need) {
2141
+ const person = await d.store.findUserByDiscordId(discordId);
2142
+ const registered = person ? d.admissions.isRegistered(person.id) : false;
2143
+ const refusal = decideAccess({ membership, person, registered, need });
2144
+ if (refusal !== null || !person)
2145
+ return { ok: false, content: refusal ?? "" };
2146
+ const resumed = await d.health.resume(person.id, d.clock.now());
2147
+ return { ok: true, user: person, notice: resumed ? resumedNotice(resumed) : null };
2148
+ }
2149
+ async function allowPerson(d, admin, target) {
2150
+ if (target.bot)
2151
+ return "A bot cannot use the tracker.";
2152
+ if (target.membership === "not-member")
2153
+ return `<@${target.discordId}> is not a member of this tracker's server.`;
2154
+ if (target.membership === "unknown")
2155
+ return "I could not check that they are a member of this tracker's server. Try again in a minute.";
2156
+ const before = await d.store.findUserByDiscordId(target.discordId);
2157
+ const now = d.clock.now();
2158
+ const user = await admit(d.store, target.discordId, now);
2159
+ d.admissions.record(user.id, admin.id, now.toISOString());
2160
+ if (before)
2161
+ return `<@${target.discordId}> is already on the list.`;
2162
+ return `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
2163
+ }
2164
+ function registeredText(user, first) {
2165
+ return [
2166
+ first ? "You are registered." : "Your settings are updated.",
2167
+ `Reminders reach you by DM. One without a time of day arrives at ${String(user.preferredHour).padStart(2, "0")}:00, ${user.timeZone} time.`,
2168
+ `Your tasks are private to you and anyone you choose to share them with. ${ADMIN_DISCLOSURE}`,
2169
+ `Keep DMs from this server open: after ${PAUSE_AFTER} DMs in a row that cannot be delivered, your reminders pause until you next use a command.`
2170
+ ].join(`
2171
+ `);
2172
+ }
2173
+ async function registerPerson(d, user, input) {
2174
+ let updated;
2175
+ try {
2176
+ updated = await setPreferences(d.store, user.id, {
2177
+ ...input.hour !== undefined ? { preferredHour: input.hour } : {},
2178
+ ...input.zone !== undefined ? { timeZone: input.zone } : {}
2179
+ });
2180
+ } catch (err) {
2181
+ if (err instanceof PeopleError)
2182
+ return err.message;
2183
+ throw err;
2184
+ }
2185
+ updated = await d.store.updateUser(user.id, { displayName: clip(input.displayName, 100) });
2186
+ const first = !d.admissions.isRegistered(user.id);
2187
+ d.admissions.markRegistered(user.id, d.clock.now().toISOString());
2188
+ const zoneChanged = updated.timeZone !== user.timeZone;
2189
+ if (zoneChanged || updated.preferredHour !== user.preferredHour)
2190
+ await rescheduleOwned(d, updated, zoneChanged);
2191
+ return registeredText(updated, first);
2192
+ }
2193
+ async function setHour(d, user, hour) {
2194
+ if (hour === user.preferredHour)
2195
+ return `Your preferred hour is already ${String(hour).padStart(2, "0")}:00, ${user.timeZone} time.`;
2196
+ let updated;
2197
+ try {
2198
+ updated = await setPreferences(d.store, user.id, { preferredHour: hour });
2199
+ } catch (err) {
2200
+ if (err instanceof PeopleError)
2201
+ return err.message;
2202
+ throw err;
2203
+ }
2204
+ const moved = await rescheduleOwned(d, updated, false);
2205
+ const note = moved > 0 ? ` ${moved} recurring reminder(s) moved to it.` : "";
2206
+ return `Your preferred hour is now ${String(hour).padStart(2, "0")}:00, ${updated.timeZone} time.${note}`;
2207
+ }
2208
+ async function rescheduleOwned(d, owner, zoneChanged) {
2209
+ let moved = 0;
2210
+ for (const task of await d.store.listTasks({ ownerId: owner.id, status: "active" })) {
2211
+ const s = task.schedule;
2212
+ if (!s || s.kind !== "calendar" && s.kind !== "period")
2213
+ continue;
2214
+ if (!zoneChanged && s.hour !== undefined)
2215
+ continue;
2216
+ const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2217
+ const now = d.clock.now();
2218
+ await reschedule(d.store, task, owner, s, owner.id, now);
2219
+ for (const o of snoozes) {
2220
+ await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2221
+ }
2222
+ moved++;
2223
+ }
2224
+ return moved;
2225
+ }
2226
+ function reminderSchedule(when, repeat, zone, now) {
2227
+ if (repeat === "none") {
2228
+ if (!when)
2229
+ return 'Say when: for example "in 20 minutes", "tomorrow 9am" or "friday at 17:30".';
2230
+ return { kind: "once", at: when.toISOString() };
2231
+ }
2232
+ const w = wallClock(when ?? now, zone);
2233
+ const start = `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`;
2234
+ const base = { kind: "calendar", every: 1, unit: repeat, start };
2235
+ if (!when)
2236
+ return base;
2237
+ return {
2238
+ ...base,
2239
+ hour: w.hour,
2240
+ minute: w.minute,
2241
+ ...repeat === "week" ? { weekdays: [w.weekday] } : {},
2242
+ ...repeat === "month" ? { dayOfMonth: w.day } : {}
2243
+ };
2244
+ }
2245
+ async function remind(d, user, input) {
2246
+ const type = d.types.reminder;
2247
+ if (!type)
2248
+ return "Reminders are not available on this bot.";
2249
+ const text = input.text.trim();
2250
+ if (text.length === 0)
2251
+ return "Say what to remind you of.";
2252
+ if (text.length > MAX_REMINDER_TEXT)
2253
+ return `That reminder is longer than ${MAX_REMINDER_TEXT} characters.`;
2254
+ const now = d.clock.now();
2255
+ let when = null;
2256
+ if (input.when !== undefined && input.when.trim() !== "") {
2257
+ const parsed = parseWhen(input.when, now, { zone: user.timeZone, defaultHour: user.preferredHour });
2258
+ if (!parsed.ok)
2259
+ return parsed.error;
2260
+ when = parsed.at;
2261
+ }
2262
+ const schedule = reminderSchedule(when, input.repeat, user.timeZone, now);
2263
+ if (typeof schedule === "string")
2264
+ return schedule;
2265
+ const actor = { userId: user.id, admin: user.admin };
2266
+ const { task, next } = await createTask(d.store, actor, user, { type, title: clip(text, MAX_TITLE), config: { text }, schedule }, now);
2267
+ const at = next ? formatInstant(next.dueAt, user.timeZone, now) : "never (that time has no next run)";
2268
+ const cadence = schedule.kind === "once" ? "" : `, ${describeSchedule(schedule, user, user.timeZone, now)}`;
2269
+ return clip(`Reminder \`${task.id}\` set: ${text}
2270
+ Next: ${at}${cadence}.`);
2271
+ }
2272
+ async function listTasks(d, user) {
2273
+ const now = d.clock.now();
2274
+ const lines = [formatTaskList(await taskList(d.store, { userId: user.id, admin: user.admin }), user, now)];
2275
+ for (const task of await d.store.listTasks({ ownerId: user.id, status: "paused" })) {
2276
+ const held = d.health.pausesFor(task.id).filter((u) => u !== user.id);
2277
+ if (held.length === 0) {
2278
+ lines.push(`Paused: \`${task.id}\` ${task.title}`);
2279
+ continue;
2280
+ }
2281
+ const who = [];
2282
+ for (const id of held)
2283
+ who.push((await d.store.getUser(id))?.displayName ?? id);
2284
+ lines.push(`Paused: \`${task.id}\` ${task.title} -- I could not DM ${who.join(", ")}; \`/task resume ${task.id}\` goes on without them.`);
2285
+ }
2286
+ return clip(lines.join(`
2287
+ `));
2288
+ }
2289
+ function replyLanes(d) {
2290
+ return new Lanes({ store: d.store, clock: d.clock, types: d.types, notifier: d.notifier });
2291
+ }
2292
+ async function ownTask(d, user, taskId) {
2293
+ const task = await visibleTask(d.store, { userId: user.id, admin: false }, taskId.trim());
2294
+ return task && task.ownerId === user.id ? task : null;
2295
+ }
2296
+ async function answerLatest(d, user, input) {
2297
+ const task = await ownTask(d, user, input.taskId);
2298
+ if (!task)
2299
+ return NO_SUCH_TASK;
2300
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
2301
+ const latest = fired.at(-1);
2302
+ if (!latest)
2303
+ return "That task has not reminded you yet, so there is nothing to answer.";
2304
+ let payload = null;
2305
+ if (input.kind === "snooze" && input.until !== undefined && input.until.trim() !== "") {
2306
+ const parsed = parseWhen(input.until, d.clock.now(), { zone: user.timeZone, defaultHour: user.preferredHour });
2307
+ if (!parsed.ok)
2308
+ return parsed.error;
2309
+ payload = { until: parsed.at.toISOString() };
2310
+ }
2311
+ try {
2312
+ const outcome = await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: input.kind, payload });
2313
+ return answeredText(input.kind, outcome?.snoozeUntil ?? null, user, d.clock.now());
2314
+ } catch (err) {
2315
+ if (err instanceof ReplyRefusedError)
2316
+ return err.message;
2317
+ throw err;
2318
+ }
2319
+ }
2320
+ function answeredText(kind, snoozeUntil, user, now) {
2321
+ if (kind === "snooze" && snoozeUntil)
2322
+ return `Snoozed until ${formatInstant(snoozeUntil, user.timeZone, now)}.`;
2323
+ if (kind === "done")
2324
+ return "Marked done.";
2325
+ return "Recorded.";
2326
+ }
2327
+ async function resumeTask(d, user, taskId) {
2328
+ const task = await ownTask(d, user, taskId);
2329
+ if (!task)
2330
+ return NO_SUCH_TASK;
2331
+ const removed = await d.health.resumeTask(task, user.id, d.clock.now());
2332
+ if (removed === null)
2333
+ return task.status === "paused" ? "That task was not paused by failed DMs." : `That task is ${task.status}, not paused.`;
2334
+ const names = [];
2335
+ for (const id of removed)
2336
+ names.push((await d.store.getUser(id))?.displayName ?? id);
2337
+ const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2338
+ return `Resumed \`${task.id}\`${without}.`;
2339
+ }
2340
+
2341
+ // plugins/tracker/src/discord-common.ts
2342
+ import { MessageFlags } from "discord.js";
2343
+ var STARTING = "The tracker is starting up; try again in a minute.";
2344
+ var FAILED = "Something went wrong on my side; try again in a minute.";
2345
+ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
2346
+ var NO_MENTIONS = { allowedMentions: { parse: [] } };
2347
+ var UNKNOWN_MEMBER = 10007;
2348
+ var UNKNOWN_USER = 10013;
2349
+ async function lookupMembership(interaction, guildId, discordId, log) {
2350
+ if (guildId === null)
2351
+ return "not-checked";
2352
+ if (interaction.guildId === guildId && interaction.user.id === discordId)
2353
+ return "member";
2354
+ try {
2355
+ const guild = await interaction.client.guilds.fetch(guildId);
2356
+ await guild.members.fetch({ user: discordId });
2357
+ return "member";
2358
+ } catch (err) {
2359
+ const code = err.code;
2360
+ if (code === UNKNOWN_MEMBER || code === UNKNOWN_USER)
2361
+ return "not-member";
2362
+ log.warn(`could not check membership of ${guildId}: ${err instanceof Error ? err.message : String(err)}`);
2363
+ return "unknown";
2364
+ }
2365
+ }
2366
+ function serial() {
2367
+ let chain = Promise.resolve();
2368
+ return (fn) => {
2369
+ const next = chain.then(fn, fn);
2370
+ chain = next.catch(() => {});
2371
+ return next;
2372
+ };
2373
+ }
2374
+
2375
+ // plugins/tracker/src/history.ts
2376
+ var HISTORY_RUNS = 10;
2377
+ var HISTORY_CHANGES = 5;
2378
+ function replyText(r) {
2379
+ if (r.kind === "text") {
2380
+ const text = typeof r.payload === "object" && r.payload !== null ? String(r.payload.text ?? "") : "";
2381
+ return `replied "${clip(text, 80)}"`;
2382
+ }
2383
+ if (r.kind === "decision")
2384
+ return `chose ${String(r.payload?.choice ?? "?")}`;
2385
+ return r.kind === "opt_out" ? "opted out" : r.kind;
2386
+ }
2387
+ function formatRun(o, replies, people, viewer, now) {
2388
+ const late = o.late ? " (late)" : "";
2389
+ const error = o.status === "failed" && o.error ? `: ${clip(o.error, 120)}` : "";
2390
+ const answers = replies.filter((r) => r.occurrenceId === o.id).map((r) => `${people.get(r.userId)?.displayName ?? r.userId} ${replyText(r)}`);
2391
+ const answered = answers.length > 0 ? `; ${answers.join(", ")}` : "";
2392
+ return `- ${formatInstant(o.dueAt, viewer.timeZone, now)} -- ${o.status}${late}${error}${answered}`;
2393
+ }
2394
+ function formatChange(e, viewer, now) {
2395
+ const detail = e.kind === "paused" || e.kind === "resumed" ? `: ${e.detail}` : "";
2396
+ return `- ${formatInstant(e.at, viewer.timeZone, now)} ${e.kind.replaceAll("_", " ")}${detail}`;
2397
+ }
2398
+ async function taskHistory(d, user, taskId) {
2399
+ const actor = { userId: user.id, admin: user.admin };
2400
+ const id = taskId.trim();
2401
+ const task = await visibleTask(d.store, actor, id);
2402
+ if (!task)
2403
+ return NO_SUCH_TASK;
2404
+ const now = d.clock.now();
2405
+ const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
2406
+ const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
2407
+ const runs = all.filter((o) => !upcoming(o));
2408
+ const next = all.find(upcoming);
2409
+ const replies = await visibleReplies(d.store, actor, task.id) ?? [];
2410
+ const changes = await visibleHistory(d.store, actor, task.id) ?? [];
2411
+ const people = new Map;
2412
+ for (const r of replies) {
2413
+ if (!people.has(r.userId)) {
2414
+ const u = await d.store.getUser(r.userId);
2415
+ if (u)
2416
+ people.set(u.id, u);
2417
+ }
2418
+ }
2419
+ const owner = task.ownerId === user.id ? user : await d.store.getUser(task.ownerId);
2420
+ const cadence = task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule";
2421
+ const lines = [`\`${task.id}\` ${task.title} -- ${task.type}, ${task.status}, ${cadence}`];
2422
+ lines.push(next ? `Next: ${formatInstant(next.dueAt, user.timeZone, now)}` : "Next: nothing scheduled");
2423
+ lines.push(runs.length === 0 ? "Runs: none yet" : "Runs:");
2424
+ if (runs.length > HISTORY_RUNS)
2425
+ lines.push(`- (${runs.length - HISTORY_RUNS} earlier)`);
2426
+ for (const o of runs.slice(-HISTORY_RUNS))
2427
+ lines.push(formatRun(o, replies, people, user, now));
2428
+ lines.push("Changes:");
2429
+ if (changes.length > HISTORY_CHANGES)
2430
+ lines.push(`- (${changes.length - HISTORY_CHANGES} earlier)`);
2431
+ for (const e of changes.slice(-HISTORY_CHANGES))
2432
+ lines.push(formatChange(e, user, now));
2433
+ return clip(lines.join(`
2434
+ `));
2435
+ }
2436
+
2437
+ // plugins/tracker/src/interactions.ts
2438
+ import {
2439
+ ActionRowBuilder,
2440
+ ButtonBuilder,
2441
+ ButtonStyle,
2442
+ ModalBuilder,
2443
+ TextInputBuilder,
2444
+ TextInputStyle
2445
+ } from "discord.js";
2446
+
2447
+ // plugins/tracker/src/buttons.ts
2448
+ var PREFIX = "tracker:";
2449
+ var REPLY_CODE = "r";
2450
+ var MODAL_CODE = "m";
2451
+ var REPLY_FIELD = "text";
2452
+ var MAX_REPLY_TEXT = 1000;
2453
+ var MAX_BUTTONS = 25;
2454
+ var STYLE = {
2455
+ done: "success",
2456
+ snooze: "secondary",
2457
+ accept: "success",
2458
+ decline: "danger",
2459
+ opt_out: "secondary",
2460
+ decision: "primary"
2461
+ };
2462
+ function replyButtonId(occurrenceId) {
2463
+ return `${PREFIX}${REPLY_CODE}.o.${occurrenceId}`;
2464
+ }
2465
+ function modalId(occurrenceId) {
2466
+ return `${PREFIX}${MODAL_CODE}.o.${occurrenceId}`;
2467
+ }
2468
+ function parseCustomId(customId) {
2469
+ if (!customId.startsWith(PREFIX))
2470
+ return null;
2471
+ const rest = customId.slice(PREFIX.length);
2472
+ const own = /^([rm])\.o\.([^.]+)$/.exec(rest);
2473
+ if (own)
2474
+ return own[1] === REPLY_CODE ? { kind: "reply-button", occurrenceId: own[2] } : { kind: "reply-modal", occurrenceId: own[2] };
2475
+ return rest.length > 0 ? { kind: "ref", ref: rest } : null;
2476
+ }
2477
+ function hostButtons(message) {
2478
+ const buttons = replyButtons(message).map((b) => ({
2479
+ customId: `${PREFIX}${b.ref}`,
2480
+ label: b.label.slice(0, 80),
2481
+ style: STYLE[b.kind]
2482
+ }));
2483
+ const occurrenceId = message.ref?.occurrenceId ?? null;
2484
+ if (occurrenceId === null)
2485
+ return buttons.slice(0, MAX_BUTTONS);
2486
+ return [...buttons.slice(0, MAX_BUTTONS - 1), { customId: replyButtonId(occurrenceId), label: "Reply", style: "secondary" }];
2487
+ }
2488
+
2489
+ // plugins/tracker/src/notifier.ts
2490
+ var HOST_CANNOT_MESSAGE = "recipient cannot be messaged";
2491
+ var MAX_CONTENT = 2000;
2492
+ var UNCONFIRMED_MESSAGE_ID = "unconfirmed";
2493
+ var HOST_SNOWFLAKE = /^[0-9]{5,25}$/;
2494
+ var URL_OR_WRAPPED = /<(https?:\/\/[^\s<>]+)>|(https?:\/\/[^\s<>]+)/g;
2495
+ function wrapBareUrls(content) {
2496
+ return content.replace(URL_OR_WRAPPED, (whole, wrapped, bare) => wrapped !== undefined ? whole : `<${bare}>`);
2497
+ }
2498
+ function isPreSendError(err) {
2499
+ if (typeof err === "object" && err !== null && err.code === 10013)
2500
+ return true;
2501
+ const message = err instanceof Error ? err.message : String(err);
2502
+ return message === "userId is not a valid id" || message === "message must be an object" || message === "content must be a string" || message === "content is empty" || message === "interactive buttons are not supported yet" || message === "Unknown User" || message.startsWith("content is longer than");
2503
+ }
2504
+
2505
+ class RecipientUnreachableError extends Error {
2506
+ userId;
2507
+ name = "RecipientUnreachableError";
2508
+ constructor(userId) {
2509
+ super(`recipient ${userId} cannot be messaged (DMs closed, or the bot is blocked)`);
2510
+ this.userId = userId;
2511
+ }
2512
+ }
2513
+
2514
+ class UndeliverableError extends Error {
2515
+ name = "UndeliverableError";
2516
+ }
2517
+ var HOST_BUTTON_REFUSAL = /^(interactive buttons are not supported|buttons? (label|customId|customIds|style|must|is not valid|and links need|need this plugin))/;
2518
+ function isButtonRefusal(err) {
2519
+ if (!(err instanceof Error) || err.code !== undefined)
2520
+ return false;
2521
+ return HOST_BUTTON_REFUSAL.test(err.message);
2522
+ }
2523
+ function toHostMessage(message) {
2524
+ if (message.text.trim().length === 0)
2525
+ return null;
2526
+ let text = message.text;
2527
+ let keep = text.length;
2528
+ while (wrapBareUrls(text).length > MAX_CONTENT) {
2529
+ keep -= Math.max(1, wrapBareUrls(text).length - MAX_CONTENT);
2530
+ text = `${message.text.slice(0, Math.max(0, keep - 3))}...`;
2531
+ }
2532
+ return { content: text };
2533
+ }
2534
+ function createDmNotifier(d) {
2535
+ return {
2536
+ async sendDm(userId, message) {
2537
+ const user = await d.store.getUser(userId);
2538
+ if (!user)
2539
+ throw new UndeliverableError(`no user ${userId}`);
2540
+ const discordId = user.discordId;
2541
+ if (!discordId || !HOST_SNOWFLAKE.test(discordId))
2542
+ throw new UndeliverableError(`user ${userId} has no valid Discord id`);
2543
+ const hostMessage = toHostMessage(message);
2544
+ if (!hostMessage)
2545
+ throw new UndeliverableError("the message has no text");
2546
+ const occurrenceId = message.ref?.occurrenceId ?? null;
2547
+ if (occurrenceId !== null) {
2548
+ const existing = d.claims.get(occurrenceId, userId);
2549
+ if (existing?.status === "sent")
2550
+ return { messageId: existing.messageId || UNCONFIRMED_MESSAGE_ID };
2551
+ if (existing && existing.status !== "failed") {
2552
+ if (existing.status === "claimed") {
2553
+ d.claims.settle(occurrenceId, userId, "unconfirmed", d.clock.now().toISOString(), { error: "claimed, never settled" });
2554
+ }
2555
+ reportUnconfirmed(d.claims, d.log, existing, d.clock.now().toISOString());
2556
+ return { messageId: UNCONFIRMED_MESSAGE_ID };
2557
+ }
2558
+ }
2559
+ if (d.health?.isPaused(userId))
2560
+ throw new ExecutorUnavailableError(`delivery to ${userId} is paused`);
2561
+ if (d.signal?.aborted)
2562
+ throw new ExecutorUnavailableError("the tick was aborted before this send");
2563
+ if (occurrenceId !== null) {
2564
+ const at = d.clock.now().toISOString();
2565
+ if (!d.claims.claim(occurrenceId, userId, discordId, at)) {
2566
+ return { messageId: UNCONFIRMED_MESSAGE_ID };
2567
+ }
2568
+ }
2569
+ try {
2570
+ const buttons = hostButtons(message);
2571
+ let delivery;
2572
+ try {
2573
+ delivery = await d.dm(discordId, buttons.length > 0 ? { ...hostMessage, buttons } : hostMessage);
2574
+ } catch (err) {
2575
+ if (buttons.length === 0 || !isButtonRefusal(err))
2576
+ throw err;
2577
+ d.log.warn(`DM sent without buttons: the host refused them (${err instanceof Error ? err.message : String(err)})`);
2578
+ delivery = await d.dm(discordId, hostMessage);
2579
+ }
2580
+ d.health?.recordSuccess(userId);
2581
+ if (occurrenceId !== null) {
2582
+ d.claims.settle(occurrenceId, userId, "sent", d.clock.now().toISOString(), {
2583
+ messageId: delivery.messageId,
2584
+ channelId: delivery.channelId
2585
+ });
2586
+ }
2587
+ return { messageId: delivery.messageId };
2588
+ } catch (err) {
2589
+ const reason = err instanceof Error ? err.message : String(err);
2590
+ const unreachable = reason === HOST_CANNOT_MESSAGE;
2591
+ const nothingSent = unreachable || isPreSendError(err);
2592
+ if (occurrenceId !== null) {
2593
+ d.claims.settle(occurrenceId, userId, nothingSent ? "failed" : "unconfirmed", d.clock.now().toISOString(), { error: reason });
2594
+ }
2595
+ if (unreachable) {
2596
+ const counted = await d.health?.recordFailure(userId, reason, d.clock.now().toISOString());
2597
+ if (counted?.paused)
2598
+ d.log.warn(`paused delivery to ${userId}: ${counted.failures} DMs in a row could not be delivered`);
2599
+ throw new RecipientUnreachableError(userId);
2600
+ }
2601
+ throw err;
2602
+ }
2603
+ }
2604
+ };
2605
+ }
2606
+ function reportUnconfirmed(claims, log, claim, at) {
2607
+ if (claim.reportedAt !== null)
2608
+ return;
2609
+ const what = claim.status === "claimed" ? `claimed ${claim.claimedAt}, never settled` : `unconfirmed since ${claim.claimedAt} (${claim.error ?? "no detail"})`;
2610
+ log.warn(`delivery of ${claim.occurrenceId} to ${claim.userId} was ${what}; it will not be resent`);
2611
+ claims.markReported(claim.occurrenceId, claim.userId, at);
2612
+ }
2613
+
2614
+ // plugins/tracker/src/press.ts
2615
+ var CANNOT_SHARE = "can't be shared with: they have to be on this tracker's list, registered, a member of its server, and reachable by DM.";
2616
+ async function prepareShare(d, owner, taskId, target) {
2617
+ const task = await visibleTask(d.store, { userId: owner.id, admin: false }, taskId.trim());
2618
+ if (!task || task.ownerId !== owner.id)
2619
+ return NO_SUCH_TASK;
2620
+ if (task.status !== "active")
2621
+ return `That task is ${task.status}.`;
2622
+ const person = await d.store.findUserByDiscordId(target.discordId);
2623
+ const refused = !person || !d.admissions.isRegistered(person.id) || target.membership === "not-member" || target.membership === "unknown" || d.health.isPaused(person.id);
2624
+ if (refused)
2625
+ return `<@${target.discordId}> ${CANNOT_SHARE}`;
2626
+ const now = d.clock.now();
2627
+ const result = await invite(d.store, { userId: owner.id, admin: false }, task, person.id, now);
2628
+ if (!result.ok) {
2629
+ if (result.reason === "blocked") {
2630
+ const until = result.block?.expiresAt;
2631
+ return until ? `<@${target.discordId}> declined an earlier invitation from you; you can invite them again after ${new Date(until).toISOString().slice(0, 16).replace("T", " ")} UTC.` : `<@${target.discordId}> declined your invitations twice; only an admin can lift that.`;
2632
+ }
2633
+ return `<@${target.discordId}> ${CANNOT_SHARE}`;
2634
+ }
2635
+ return { taskId: task.id, targetId: person.id, targetDiscordId: target.discordId, message: inviteMessage(task, owner, person, now) };
2636
+ }
2637
+ async function sendShare(d, p) {
2638
+ try {
2639
+ await d.notifier.sendDm(p.targetId, p.message);
2640
+ return null;
2641
+ } catch (err) {
2642
+ return err ?? new Error("failed");
2643
+ }
2644
+ }
2645
+ async function finishShare(d, p, err) {
2646
+ if (err === null)
2647
+ return `Invited <@${p.targetDiscordId}> to \`${p.taskId}\`. They get a DM to accept or decline.`;
2648
+ await d.store.removeRecipient(p.taskId, p.targetId);
2649
+ await d.store.addTaskEvent({
2650
+ taskId: p.taskId,
2651
+ actorId: null,
2652
+ kind: "recipient_removed",
2653
+ detail: `${p.targetId}: the invitation could not be delivered`,
2654
+ at: d.clock.now().toISOString()
2655
+ });
2656
+ if (err instanceof RecipientUnreachableError) {
2657
+ return `I could not DM <@${p.targetDiscordId}> (their DMs are closed, or they blocked the bot), so the invitation is withdrawn.`;
2658
+ }
2659
+ d.log.error(`invitation DM for ${p.taskId} to ${p.targetId} failed`, err);
2660
+ return "The invitation could not be sent; try again in a minute.";
2661
+ }
2662
+ var CONSENT_TEXT = {
2663
+ accept: "Accepted: you will get this task's results, and each one has a button to stop them.",
2664
+ decline: "Declined. You will not get this task's results.",
2665
+ opt_out: "You will not get this task's messages any more."
2666
+ };
2667
+ async function press(d, user, ref) {
2668
+ const verdict = await replyForRef(d.store, ref, user.id);
2669
+ if (!verdict.ok)
2670
+ return { ok: false, error: verdict.error };
2671
+ const { input } = verdict;
2672
+ try {
2673
+ const outcome = await replyLanes(d).reply(input);
2674
+ const consent = CONSENT_TEXT[input.kind];
2675
+ if (consent)
2676
+ return { ok: true, status: consent, keepReplyFor: null };
2677
+ const status = input.kind === "decision" ? `Recorded: ${String(input.payload?.choice ?? "")}.` : answeredText(input.kind, outcome?.snoozeUntil ?? null, user, d.clock.now());
2678
+ return { ok: true, status, keepReplyFor: input.occurrenceId };
2679
+ } catch (err) {
2680
+ if (err instanceof ReplyRefusedError)
2681
+ return { ok: false, error: err.message };
2682
+ throw err;
2683
+ }
2684
+ }
2685
+ async function replyRefusal(d, user, occurrenceId) {
2686
+ const occurrence = await d.store.getOccurrence(occurrenceId);
2687
+ if (!occurrence)
2688
+ return "That run no longer exists.";
2689
+ const task = await d.store.getTask(occurrence.taskId);
2690
+ if (!task)
2691
+ return "That task no longer exists.";
2692
+ if (task.ownerId === user.id)
2693
+ return null;
2694
+ const mine = (await d.store.listRecipients(task.id)).find((r) => r.userId === user.id);
2695
+ return mine?.state === "accepted" ? null : "That is not yours to reply to.";
2696
+ }
2697
+ async function submitReply(d, user, occurrenceId, text) {
2698
+ const refusal = await replyRefusal(d, user, occurrenceId);
2699
+ if (refusal)
2700
+ return refusal;
2701
+ const trimmed = text.trim();
2702
+ if (trimmed.length === 0)
2703
+ return "That reply was empty.";
2704
+ const occurrence = await d.store.getOccurrence(occurrenceId);
2705
+ if (!occurrence)
2706
+ return "That run no longer exists.";
2707
+ await replyLanes(d).reply({
2708
+ taskId: occurrence.taskId,
2709
+ occurrenceId,
2710
+ userId: user.id,
2711
+ kind: "text",
2712
+ payload: { text: clip(trimmed, MAX_REPLY_TEXT) }
2713
+ });
2714
+ return "Reply kept with the task's history.";
2715
+ }
2716
+ function reducesContact(ref) {
2717
+ const kind = decodeReplyRef(ref)?.kind;
2718
+ return kind === "decline" || kind === "opt_out";
2719
+ }
2720
+
2721
+ // plugins/tracker/src/interactions.ts
2722
+ function replyRow(occurrenceId) {
2723
+ return new ActionRowBuilder().addComponents(new ButtonBuilder().setCustomId(replyButtonId(occurrenceId)).setLabel("Reply").setStyle(ButtonStyle.Secondary));
2724
+ }
2725
+ function replyModal(occurrenceId) {
2726
+ 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)));
2727
+ }
2728
+ function createInteractionHandler(c) {
2729
+ const membershipOf = (i) => c.membershipOf(i, i.user.id);
2730
+ async function onButton(interaction, d, ref) {
2731
+ await interaction.deferUpdate();
2732
+ const gated = !reducesContact(ref);
2733
+ const membership = gated ? await membershipOf(interaction) : "not-checked";
2734
+ const result = await c.queue(async () => {
2735
+ if (!gated) {
2736
+ const person = await d.store.findUserByDiscordId(interaction.user.id);
2737
+ if (!person)
2738
+ return { notice: null, pressed: { ok: false, error: "That is not yours to answer." } };
2739
+ return { notice: null, pressed: await press(d, person, ref) };
2740
+ }
2741
+ const entry = await enter(d, interaction.user.id, membership, "registered");
2742
+ if (!entry.ok)
2743
+ return { notice: null, pressed: { ok: false, error: entry.content } };
2744
+ return { notice: entry.notice, pressed: await press(d, entry.user, ref) };
2745
+ });
2746
+ if (result.pressed.ok) {
2747
+ const { status, keepReplyFor } = result.pressed;
2748
+ await interaction.editReply({
2749
+ content: clip(`${interaction.message.content}
2750
+
2751
+ -- ${status}`),
2752
+ components: keepReplyFor ? [replyRow(keepReplyFor)] : [],
2753
+ ...NO_MENTIONS
2754
+ });
2755
+ } else {
2756
+ await interaction.followUp({ content: result.pressed.error, ...EPHEMERAL });
2757
+ }
2758
+ if (result.notice)
2759
+ await interaction.followUp({ content: result.notice, ...EPHEMERAL });
2760
+ }
2761
+ async function onReplyButton(interaction, d, occurrenceId) {
2762
+ const person = await d.store.findUserByDiscordId(interaction.user.id);
2763
+ const registered = person ? d.admissions.isRegistered(person.id) : false;
2764
+ const refusal = decideAccess({ membership: "not-checked", person, registered, need: "registered" }) ?? (person ? await replyRefusal(d, person, occurrenceId) : NOT_ADMITTED);
2765
+ if (refusal) {
2766
+ await interaction.reply({ content: refusal, ...EPHEMERAL });
2767
+ return;
2768
+ }
2769
+ await interaction.showModal(replyModal(occurrenceId));
2770
+ }
2771
+ async function onReplyModal(interaction, d, occurrenceId) {
2772
+ await interaction.deferReply(EPHEMERAL);
2773
+ const membership = await membershipOf(interaction);
2774
+ const text = interaction.fields.getTextInputValue(REPLY_FIELD);
2775
+ const content = await c.queue(async () => {
2776
+ const entry = await enter(d, interaction.user.id, membership, "registered");
2777
+ if (!entry.ok)
2778
+ return entry.content;
2779
+ const body = await submitReply(d, entry.user, occurrenceId, text);
2780
+ return entry.notice ? `${entry.notice}
2781
+
2782
+ ${body}` : body;
2783
+ });
2784
+ await interaction.editReply({ content: clip(content), ...NO_MENTIONS });
2785
+ }
2786
+ async function failed(interaction) {
2787
+ try {
2788
+ if (interaction.deferred || interaction.replied)
2789
+ await interaction.followUp({ content: FAILED, ...EPHEMERAL });
2790
+ else
2791
+ await interaction.reply({ content: FAILED, ...EPHEMERAL });
2792
+ } catch (err) {
2793
+ c.log.warn(`could not tell the presser of ${interaction.customId} that it failed: ${err instanceof Error ? err.message : String(err)}`);
2794
+ }
2795
+ }
2796
+ return async (interaction) => {
2797
+ const parsed = parseCustomId(interaction.customId);
2798
+ const d = c.deps();
2799
+ if (!parsed || !d) {
2800
+ await interaction.reply({ content: d ? "That button is not one of mine." : STARTING, ...EPHEMERAL });
2801
+ return;
2802
+ }
2803
+ try {
2804
+ if (parsed.kind === "reply-modal" && interaction.isModalSubmit())
2805
+ return await onReplyModal(interaction, d, parsed.occurrenceId);
2806
+ if (interaction.isMessageComponent() && parsed.kind === "reply-button")
2807
+ return await onReplyButton(interaction, d, parsed.occurrenceId);
2808
+ if (interaction.isMessageComponent() && parsed.kind === "ref")
2809
+ return await onButton(interaction, d, parsed.ref);
2810
+ await interaction.reply({ content: "That is not one of mine.", ...EPHEMERAL });
2811
+ } catch (err) {
2812
+ c.log.error(`interaction ${interaction.customId} failed`, err);
2813
+ await failed(interaction);
2814
+ }
2815
+ };
2816
+ }
2817
+
2818
+ // plugins/tracker/src/discord.ts
2819
+ function displayName(user) {
2820
+ return user.globalName ?? user.username;
2821
+ }
2822
+ function createSurface(w) {
2823
+ const ctx = {
2824
+ deps: w.deps,
2825
+ queue: serial(),
2826
+ membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildId, discordId, w.log),
2827
+ log: w.log
2828
+ };
2829
+ const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
2830
+ async function run(interaction, need, act, pre = async () => {
2831
+ return;
2832
+ }) {
2833
+ await interaction.deferReply(EPHEMERAL);
2834
+ let content;
2835
+ try {
2836
+ const d = w.deps();
2837
+ if (!d)
2838
+ content = STARTING;
2839
+ else {
2840
+ const membership = await membershipOf(interaction, interaction.user.id);
2841
+ const before = await pre();
2842
+ const first = await ctx.queue(async () => {
2843
+ const entry = await enter(d, interaction.user.id, membership, need);
2844
+ if (!entry.ok)
2845
+ return { notice: null, step: entry.content };
2846
+ return { notice: entry.notice, step: await act(d, entry.user, before) };
2847
+ });
2848
+ let body;
2849
+ if (typeof first.step === "string")
2850
+ body = first.step;
2851
+ else {
2852
+ const { outside, finish } = first.step;
2853
+ const result = await outside();
2854
+ body = await ctx.queue(() => finish(result));
2855
+ }
2856
+ content = clip(first.notice ? `${first.notice}
2857
+
2858
+ ${body}` : body);
2859
+ }
2860
+ } catch (err) {
2861
+ w.log.error(`/${interaction.commandName} failed`, err);
2862
+ content = FAILED;
2863
+ }
2864
+ await interaction.editReply({ content, ...NO_MENTIONS });
2865
+ }
2866
+ const commands = [
2867
+ {
2868
+ name: "allow",
2869
+ build: (b) => b.setDescription("Admin: let a person use the tracker").addUserOption((o) => o.setName("user").setDescription("Who to let in").setRequired(true)),
2870
+ handle: (interaction) => {
2871
+ const target = interaction.options.getUser("user", true);
2872
+ return run(interaction, "admin", (d, admin, membership) => allowPerson(d, admin, { discordId: target.id, bot: target.bot, membership }), () => membershipOf(interaction, target.id));
2873
+ }
2874
+ },
2875
+ {
2876
+ name: "register",
2877
+ build: (b) => b.setDescription("Sign up for reminders, or change your hour and time zone").addIntegerOption((o) => o.setName("hour").setDescription("Hour (0-23) a reminder without a time arrives; default 9").setMinValue(0).setMaxValue(23)).addStringOption((o) => o.setName("zone").setDescription("Your time zone, e.g. America/New_York (the default)").setMaxLength(64)),
2878
+ handle: (interaction) => run(interaction, "admitted", (d, user) => {
2879
+ const hour = interaction.options.getInteger("hour");
2880
+ const zone = interaction.options.getString("zone");
2881
+ return registerPerson(d, user, {
2882
+ displayName: displayName(interaction.user),
2883
+ ...hour !== null ? { hour } : {},
2884
+ ...zone !== null ? { zone: zone.trim() } : {}
2885
+ });
2886
+ })
2887
+ },
2888
+ {
2889
+ name: "remind",
2890
+ build: (b) => b.setDescription("Set a reminder, sent to you by DM").addStringOption((o) => o.setName("text").setDescription("What to remind you of").setRequired(true).setMaxLength(MAX_REMINDER_TEXT)).addStringOption((o) => o.setName("when").setDescription('When: "in 20 minutes", "tomorrow 9am", "fri at 17:30"').setMaxLength(100)).addStringOption((o) => o.setName("repeat").setDescription("Repeat it (default: once)").addChoices({ name: "once", value: "none" }, { name: "daily", value: "day" }, { name: "weekly", value: "week" }, { name: "monthly", value: "month" })),
2891
+ handle: (interaction) => run(interaction, "registered", (d, user) => {
2892
+ const when = interaction.options.getString("when");
2893
+ return remind(d, user, {
2894
+ text: interaction.options.getString("text", true),
2895
+ ...when !== null ? { when } : {},
2896
+ repeat: interaction.options.getString("repeat") ?? "none"
2897
+ });
2898
+ })
2899
+ },
2900
+ {
2901
+ name: "tasks",
2902
+ build: (b) => b.setDescription("List your tasks and when each is next due"),
2903
+ handle: (interaction) => run(interaction, "registered", (d, user) => listTasks(d, user))
2904
+ },
2905
+ {
2906
+ name: "task",
2907
+ 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))),
2908
+ handle: (interaction) => {
2909
+ const sub = interaction.options.getSubcommand();
2910
+ const taskId = interaction.options.getString("task", true);
2911
+ const shareWith = sub === "share" ? interaction.options.getUser("user", true) : null;
2912
+ return run(interaction, "registered", async (d, user, membership) => {
2913
+ switch (sub) {
2914
+ case "done":
2915
+ return answerLatest(d, user, { taskId, kind: "done" });
2916
+ case "snooze": {
2917
+ const until = interaction.options.getString("until");
2918
+ return answerLatest(d, user, { taskId, kind: "snooze", ...until !== null ? { until } : {} });
2919
+ }
2920
+ case "history":
2921
+ return taskHistory(d, user, taskId);
2922
+ case "resume":
2923
+ return resumeTask(d, user, taskId);
2924
+ case "share": {
2925
+ const pending = await prepareShare(d, user, taskId, { discordId: shareWith?.id ?? "", membership: membership ?? "unknown" });
2926
+ if (typeof pending === "string")
2927
+ return pending;
2928
+ return { outside: () => sendShare(d, pending), finish: (err) => finishShare(d, pending, err) };
2929
+ }
2930
+ default:
2931
+ return "Unknown subcommand.";
2932
+ }
2933
+ }, async () => shareWith ? membershipOf(interaction, shareWith.id) : null);
2934
+ }
2935
+ },
2936
+ {
2937
+ name: "settings",
2938
+ build: (b) => b.setDescription("Your tracker settings").addSubcommand((s) => s.setName("hour").setDescription("The hour (0-23) a reminder without a time reaches you").addIntegerOption((o) => o.setName("hour").setDescription("0-23, in your time zone").setRequired(true).setMinValue(0).setMaxValue(23))),
2939
+ handle: (interaction) => run(interaction, "registered", (d, user) => setHour(d, user, interaction.options.getInteger("hour", true)))
2940
+ }
2941
+ ];
2942
+ return { commands, interactions: createInteractionHandler(ctx) };
2943
+ }
2944
+
2945
+ // plugins/tracker/src/health.ts
2946
+ var STALE_AFTER_MS = 3 * 60000;
2947
+ function decideHealth(state, now) {
2948
+ const lastTickAt = state.lastTickAt?.toISOString() ?? null;
2949
+ if (state.activatedAt === null)
2950
+ return { status: 503, body: { status: "inactive", lastTickAt } };
2951
+ if (state.blocked !== null)
2952
+ return { status: 503, body: { status: "blocked", lastTickAt, detail: state.blocked } };
2953
+ const since = state.lastTickAt ?? state.activatedAt;
2954
+ const fresh = now.getTime() - since.getTime() <= STALE_AFTER_MS;
2955
+ if (state.lastTickAt === null)
2956
+ return fresh ? { status: 200, body: { status: "starting", lastTickAt } } : { status: 503, body: { status: "stale", lastTickAt } };
2957
+ return fresh ? { status: 200, body: { status: "ok", lastTickAt } } : { status: 503, body: { status: "stale", lastTickAt } };
2958
+ }
2959
+ function healthResponse(health) {
2960
+ return new Response(JSON.stringify(health.body), {
2961
+ status: health.status,
2962
+ headers: { "content-type": "application/json", "cache-control": "no-store" }
2963
+ });
2964
+ }
2965
+
2966
+ // plugins/tracker/src/notify-lane.ts
2967
+ function laneStore(store) {
2968
+ const view = Object.create(store);
2969
+ view.listOccurrences = async (filter = {}) => {
2970
+ const found = await store.listOccurrences(filter);
2971
+ if (filter.status !== "queued" || filter.dueBefore === undefined)
2972
+ return found;
2973
+ const kept = [];
2974
+ for (const o of found) {
2975
+ if ((await store.getTask(o.taskId))?.status === "active")
2976
+ kept.push(o);
2977
+ }
2978
+ return kept;
2979
+ };
2980
+ return view;
2981
+ }
2982
+ async function runNotifyTick(d, signal) {
2983
+ if (typeof d.dm !== "function")
2984
+ return { kind: "no-dm" };
2985
+ if (signal?.aborted)
2986
+ return { kind: "aborted" };
2987
+ const store = laneStore(d.store);
2988
+ const notifier = createDmNotifier({
2989
+ store,
2990
+ claims: d.claims,
2991
+ dm: d.dm,
2992
+ clock: d.clock,
2993
+ log: d.log,
2994
+ ...signal ? { signal } : {},
2995
+ ...d.health ? { health: d.health } : {}
2996
+ });
2997
+ const lanes = new Lanes({ store, clock: d.clock, types: d.types, notifier });
2998
+ const result = await lanes.tickNotify();
2999
+ return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3000
+ }
3001
+
3002
+ // plugins/tracker/src/schema.ts
3003
+ import { Database } from "bun:sqlite";
3004
+ var MIGRATIONS = [
3005
+ `
3006
+ CREATE TABLE users (
3007
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3008
+ discord_id TEXT,
3009
+ usr_subject TEXT,
3010
+ display_name TEXT,
3011
+ time_zone TEXT NOT NULL,
3012
+ preferred_hour INTEGER NOT NULL,
3013
+ admin INTEGER NOT NULL,
3014
+ created_at TEXT NOT NULL
3015
+ );
3016
+ CREATE UNIQUE INDEX users_discord_id ON users (discord_id) WHERE discord_id IS NOT NULL;
3017
+ CREATE INDEX users_usr_subject ON users (usr_subject);
3018
+
3019
+ CREATE TABLE tasks (
3020
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3021
+ owner_id TEXT NOT NULL,
3022
+ type TEXT NOT NULL,
3023
+ title TEXT NOT NULL,
3024
+ config TEXT NOT NULL,
3025
+ state TEXT NOT NULL,
3026
+ schedule TEXT NOT NULL,
3027
+ lane TEXT NOT NULL,
3028
+ capabilities TEXT NOT NULL,
3029
+ status TEXT NOT NULL,
3030
+ created_at TEXT NOT NULL,
3031
+ updated_at TEXT NOT NULL
3032
+ );
3033
+ CREATE INDEX tasks_owner ON tasks (owner_id);
3034
+
3035
+ CREATE TABLE task_recipients (
3036
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3037
+ task_id TEXT NOT NULL,
3038
+ user_id TEXT NOT NULL,
3039
+ state TEXT NOT NULL,
3040
+ at TEXT NOT NULL,
3041
+ UNIQUE (task_id, user_id)
3042
+ );
3043
+
3044
+ CREATE TABLE invite_blocks (
3045
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3046
+ owner_id TEXT NOT NULL,
3047
+ recipient_id TEXT NOT NULL,
3048
+ expires_at TEXT,
3049
+ decline_reply_id TEXT,
3050
+ lifted_by TEXT,
3051
+ lifted_at TEXT,
3052
+ created_at TEXT NOT NULL
3053
+ );
3054
+ CREATE INDEX invite_blocks_pair ON invite_blocks (owner_id, recipient_id);
3055
+
3056
+ CREATE TABLE occurrences (
3057
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3058
+ task_id TEXT NOT NULL,
3059
+ lane TEXT NOT NULL,
3060
+ due_at TEXT NOT NULL,
3061
+ started_at TEXT,
3062
+ finished_at TEXT,
3063
+ status TEXT NOT NULL,
3064
+ late INTEGER NOT NULL,
3065
+ dedupe_key TEXT NOT NULL UNIQUE,
3066
+ summary TEXT,
3067
+ cost_usd REAL,
3068
+ error TEXT,
3069
+ created_at TEXT NOT NULL
3070
+ );
3071
+ CREATE INDEX occurrences_due ON occurrences (status, lane, due_at);
3072
+ CREATE INDEX occurrences_task ON occurrences (task_id);
3073
+
3074
+ CREATE TABLE events (
3075
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3076
+ occurrence_id TEXT NOT NULL,
3077
+ agent TEXT NOT NULL,
3078
+ type TEXT NOT NULL,
3079
+ text TEXT NOT NULL,
3080
+ at TEXT NOT NULL
3081
+ );
3082
+ CREATE INDEX events_occurrence ON events (occurrence_id, seq);
3083
+
3084
+ CREATE TABLE task_events (
3085
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3086
+ task_id TEXT NOT NULL,
3087
+ actor_id TEXT,
3088
+ kind TEXT NOT NULL,
3089
+ detail TEXT NOT NULL,
3090
+ at TEXT NOT NULL
3091
+ );
3092
+ CREATE INDEX task_events_task ON task_events (task_id, seq);
3093
+
3094
+ CREATE TABLE replies (
3095
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3096
+ occurrence_id TEXT,
3097
+ task_id TEXT NOT NULL,
3098
+ user_id TEXT NOT NULL,
3099
+ kind TEXT NOT NULL,
3100
+ payload TEXT NOT NULL,
3101
+ at TEXT NOT NULL
3102
+ );
3103
+ CREATE INDEX replies_task ON replies (task_id, seq);
3104
+
3105
+ CREATE TABLE series (
3106
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
3107
+ task_id TEXT NOT NULL,
3108
+ at TEXT NOT NULL,
3109
+ value REAL NOT NULL,
3110
+ unit TEXT,
3111
+ note TEXT
3112
+ );
3113
+ CREATE INDEX series_task ON series (task_id, at);
3114
+
3115
+ CREATE TABLE delivery_claims (
3116
+ occurrence_id TEXT NOT NULL,
3117
+ user_id TEXT NOT NULL,
3118
+ discord_id TEXT NOT NULL,
3119
+ status TEXT NOT NULL,
3120
+ message_id TEXT,
3121
+ channel_id TEXT,
3122
+ error TEXT,
3123
+ claimed_at TEXT NOT NULL,
3124
+ settled_at TEXT,
3125
+ reported_at TEXT,
3126
+ PRIMARY KEY (occurrence_id, user_id)
3127
+ );
3128
+ `,
3129
+ `
3130
+ CREATE TABLE admissions (
3131
+ user_id TEXT PRIMARY KEY,
3132
+ admitted_by TEXT,
3133
+ admitted_at TEXT NOT NULL,
3134
+ registered_at TEXT
3135
+ );
3136
+ INSERT INTO admissions (user_id, admitted_by, admitted_at, registered_at)
3137
+ SELECT 'u' || seq, NULL, created_at, NULL FROM users;
3138
+
3139
+ CREATE TABLE delivery_health (
3140
+ user_id TEXT PRIMARY KEY,
3141
+ failures INTEGER NOT NULL,
3142
+ last_error TEXT,
3143
+ last_failed_at TEXT,
3144
+ paused_at TEXT
3145
+ );
3146
+
3147
+ CREATE TABLE delivery_pauses (
3148
+ task_id TEXT NOT NULL,
3149
+ user_id TEXT NOT NULL,
3150
+ at TEXT NOT NULL,
3151
+ PRIMARY KEY (task_id, user_id)
3152
+ );
3153
+ `
3154
+ ];
3155
+ function migrate(db) {
3156
+ const current = db.query("PRAGMA user_version").get().user_version;
3157
+ if (current > MIGRATIONS.length) {
3158
+ throw new Error(`tracker database is at schema ${current}, newer than this plugin knows (${MIGRATIONS.length})`);
3159
+ }
3160
+ for (let version = current;version < MIGRATIONS.length; version++) {
3161
+ db.transaction(() => {
3162
+ db.exec(MIGRATIONS[version]);
3163
+ db.exec(`PRAGMA user_version = ${version + 1}`);
3164
+ })();
3165
+ }
3166
+ }
3167
+ function openDatabase(path) {
3168
+ const db = new Database(path, { create: true });
3169
+ if (path !== ":memory:")
3170
+ db.exec("PRAGMA journal_mode = WAL");
3171
+ db.exec("PRAGMA busy_timeout = 5000");
3172
+ migrate(db);
3173
+ return db;
3174
+ }
3175
+
3176
+ // plugins/tracker/src/store.ts
3177
+ var DEFAULT_TIME_ZONE2 = "UTC";
3178
+ var DEFAULT_PREFERRED_HOUR2 = 9;
3179
+ function idOf(prefix, seq) {
3180
+ return `${prefix}${Number(seq)}`;
3181
+ }
3182
+ function seqOf(prefix, id) {
3183
+ if (!id.startsWith(prefix))
3184
+ return null;
3185
+ const rest = id.slice(prefix.length);
3186
+ return /^[1-9][0-9]*$/.test(rest) ? Number(rest) : null;
3187
+ }
3188
+ function json(value) {
3189
+ return JSON.stringify(value ?? null);
3190
+ }
3191
+ function parse(text) {
3192
+ return JSON.parse(String(text));
3193
+ }
3194
+ function str(value) {
3195
+ return value === null || value === undefined ? null : String(value);
3196
+ }
3197
+ function toUser(r) {
3198
+ return {
3199
+ id: idOf("u", r.seq),
3200
+ discordId: str(r.discord_id),
3201
+ usrSubject: str(r.usr_subject),
3202
+ displayName: str(r.display_name),
3203
+ timeZone: String(r.time_zone),
3204
+ preferredHour: Number(r.preferred_hour),
3205
+ admin: Number(r.admin) === 1,
3206
+ createdAt: String(r.created_at)
3207
+ };
3208
+ }
3209
+ function toTask(r) {
3210
+ return {
3211
+ id: idOf("t", r.seq),
3212
+ ownerId: String(r.owner_id),
3213
+ type: String(r.type),
3214
+ title: String(r.title),
3215
+ config: parse(r.config),
3216
+ state: parse(r.state),
3217
+ schedule: parse(r.schedule),
3218
+ lane: String(r.lane),
3219
+ capabilities: parse(r.capabilities),
3220
+ status: String(r.status),
3221
+ createdAt: String(r.created_at),
3222
+ updatedAt: String(r.updated_at)
3223
+ };
3224
+ }
3225
+ function toRecipient(r) {
3226
+ return { taskId: String(r.task_id), userId: String(r.user_id), state: String(r.state), at: String(r.at) };
3227
+ }
3228
+ function toBlock(r) {
3229
+ return {
3230
+ id: idOf("b", r.seq),
3231
+ ownerId: String(r.owner_id),
3232
+ recipientId: String(r.recipient_id),
3233
+ expiresAt: str(r.expires_at),
3234
+ declineReplyId: str(r.decline_reply_id),
3235
+ liftedBy: str(r.lifted_by),
3236
+ liftedAt: str(r.lifted_at),
3237
+ createdAt: String(r.created_at)
3238
+ };
3239
+ }
3240
+ function toOccurrence(r) {
3241
+ return {
3242
+ id: idOf("o", r.seq),
3243
+ taskId: String(r.task_id),
3244
+ lane: String(r.lane),
3245
+ dueAt: String(r.due_at),
3246
+ startedAt: str(r.started_at),
3247
+ finishedAt: str(r.finished_at),
3248
+ status: String(r.status),
3249
+ late: Number(r.late) === 1,
3250
+ dedupeKey: String(r.dedupe_key),
3251
+ summary: str(r.summary),
3252
+ costUsd: r.cost_usd === null || r.cost_usd === undefined ? null : Number(r.cost_usd),
3253
+ error: str(r.error),
3254
+ createdAt: String(r.created_at)
3255
+ };
3256
+ }
3257
+ function toEvent(r) {
3258
+ return {
3259
+ id: idOf("e", r.seq),
3260
+ occurrenceId: String(r.occurrence_id),
3261
+ agent: String(r.agent),
3262
+ type: String(r.type),
3263
+ text: String(r.text),
3264
+ at: String(r.at)
3265
+ };
3266
+ }
3267
+ function toTaskEvent(r) {
3268
+ return {
3269
+ id: idOf("h", r.seq),
3270
+ taskId: String(r.task_id),
3271
+ actorId: str(r.actor_id),
3272
+ kind: String(r.kind),
3273
+ detail: String(r.detail),
3274
+ at: String(r.at)
3275
+ };
3276
+ }
3277
+ function toReply(r) {
3278
+ return {
3279
+ id: idOf("r", r.seq),
3280
+ occurrenceId: str(r.occurrence_id),
3281
+ taskId: String(r.task_id),
3282
+ userId: String(r.user_id),
3283
+ kind: String(r.kind),
3284
+ payload: parse(r.payload),
3285
+ at: String(r.at)
3286
+ };
3287
+ }
3288
+ function toPoint(r) {
3289
+ return { id: idOf("s", r.seq), taskId: String(r.task_id), at: String(r.at), value: Number(r.value), unit: str(r.unit), note: str(r.note) };
3290
+ }
3291
+
3292
+ class SqliteStore {
3293
+ db;
3294
+ constructor(db) {
3295
+ this.db = db;
3296
+ }
3297
+ one(sql, ...params) {
3298
+ return this.db.query(sql).get(...params) ?? null;
3299
+ }
3300
+ all(sql, ...params) {
3301
+ return this.db.query(sql).all(...params);
3302
+ }
3303
+ run(sql, ...params) {
3304
+ return this.db.query(sql).run(...params);
3305
+ }
3306
+ byId(table, prefix, id) {
3307
+ const seq = seqOf(prefix, id);
3308
+ return seq === null ? null : this.one(`SELECT * FROM ${table} WHERE seq = ?`, seq);
3309
+ }
3310
+ mustGet(table, prefix, id, what) {
3311
+ const row = this.byId(table, prefix, id);
3312
+ if (!row)
3313
+ throw new Error(`no ${what} ${id}`);
3314
+ return row;
3315
+ }
3316
+ async getUser(id) {
3317
+ const r = this.byId("users", "u", id);
3318
+ return r ? toUser(r) : null;
3319
+ }
3320
+ async findUserByDiscordId(discordId) {
3321
+ const r = this.one("SELECT * FROM users WHERE discord_id = ? ORDER BY seq LIMIT 1", discordId);
3322
+ return r ? toUser(r) : null;
3323
+ }
3324
+ async findUserBySubject(usrSubject) {
3325
+ const r = this.one("SELECT * FROM users WHERE usr_subject = ? ORDER BY seq LIMIT 1", usrSubject);
3326
+ return r ? toUser(r) : null;
3327
+ }
3328
+ async createUser(u) {
3329
+ const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id, usr_subject, display_name, time_zone, preferred_hour, admin, created_at)
3330
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, u.discordId ?? null, u.usrSubject ?? null, u.displayName ?? null, u.timeZone ?? DEFAULT_TIME_ZONE2, u.preferredHour ?? DEFAULT_PREFERRED_HOUR2, u.admin ? 1 : 0, u.at);
3331
+ return toUser(this.mustGet("users", "u", idOf("u", lastInsertRowid), "user"));
3332
+ }
3333
+ async updateUser(id, patch) {
3334
+ const cur = toUser(this.mustGet("users", "u", id, "user"));
3335
+ const next = {
3336
+ ...cur,
3337
+ ...patch.discordId !== undefined ? { discordId: patch.discordId } : {},
3338
+ ...patch.usrSubject !== undefined ? { usrSubject: patch.usrSubject } : {},
3339
+ ...patch.displayName !== undefined ? { displayName: patch.displayName } : {},
3340
+ ...patch.timeZone !== undefined ? { timeZone: patch.timeZone } : {},
3341
+ ...patch.preferredHour !== undefined ? { preferredHour: patch.preferredHour } : {},
3342
+ ...patch.admin !== undefined ? { admin: patch.admin } : {}
3343
+ };
3344
+ this.run(`UPDATE users SET discord_id = ?, usr_subject = ?, display_name = ?, time_zone = ?, preferred_hour = ?, admin = ?
3345
+ WHERE seq = ?`, next.discordId, next.usrSubject, next.displayName, next.timeZone, next.preferredHour, next.admin ? 1 : 0, seqOf("u", id));
3346
+ return toUser(this.mustGet("users", "u", id, "user"));
3347
+ }
3348
+ async createTask(t) {
3349
+ const { lastInsertRowid } = this.run(`INSERT INTO tasks (owner_id, type, title, config, state, schedule, lane, capabilities, status, created_at, updated_at)
3350
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, t.ownerId, t.type, t.title, json(t.config), json(t.state), json(t.schedule), t.lane, json(t.capabilities), t.at, t.at);
3351
+ return toTask(this.mustGet("tasks", "t", idOf("t", lastInsertRowid), "task"));
3352
+ }
3353
+ async getTask(id) {
3354
+ const r = this.byId("tasks", "t", id);
3355
+ return r ? toTask(r) : null;
3356
+ }
3357
+ async listTasks(filter = {}) {
3358
+ const where = [];
3359
+ const params = [];
3360
+ if (filter.ownerId !== undefined)
3361
+ where.push("owner_id = ?"), params.push(filter.ownerId);
3362
+ if (filter.status !== undefined)
3363
+ where.push("status = ?"), params.push(filter.status);
3364
+ if (filter.type !== undefined)
3365
+ where.push("type = ?"), params.push(filter.type);
3366
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
3367
+ return this.all(`SELECT * FROM tasks ${clause} ORDER BY seq`, ...params).map(toTask);
3368
+ }
3369
+ async updateTask(id, patch) {
3370
+ const cur = toTask(this.mustGet("tasks", "t", id, "task"));
3371
+ const next = {
3372
+ ...cur,
3373
+ updatedAt: patch.at,
3374
+ ...patch.title !== undefined ? { title: patch.title } : {},
3375
+ ...patch.config !== undefined ? { config: patch.config } : {},
3376
+ ...patch.state !== undefined ? { state: patch.state } : {},
3377
+ ...patch.schedule !== undefined ? { schedule: patch.schedule } : {},
3378
+ ...patch.capabilities !== undefined ? { capabilities: patch.capabilities } : {},
3379
+ ...patch.status !== undefined ? { status: patch.status } : {}
3380
+ };
3381
+ this.run(`UPDATE tasks SET title = ?, config = ?, state = ?, schedule = ?, capabilities = ?, status = ?, updated_at = ?
3382
+ WHERE seq = ?`, next.title, json(next.config), json(next.state), json(next.schedule), json(next.capabilities), next.status, next.updatedAt, seqOf("t", id));
3383
+ return toTask(this.mustGet("tasks", "t", id, "task"));
3384
+ }
3385
+ async listRecipients(taskId) {
3386
+ return this.all("SELECT * FROM task_recipients WHERE task_id = ? ORDER BY seq", taskId).map(toRecipient);
3387
+ }
3388
+ async setRecipient(taskId, userId, state, at) {
3389
+ this.run(`INSERT INTO task_recipients (task_id, user_id, state, at) VALUES (?, ?, ?, ?)
3390
+ ON CONFLICT (task_id, user_id) DO UPDATE SET state = excluded.state, at = excluded.at`, taskId, userId, state, at);
3391
+ return { taskId, userId, state, at };
3392
+ }
3393
+ async removeRecipient(taskId, userId) {
3394
+ this.run("DELETE FROM task_recipients WHERE task_id = ? AND user_id = ?", taskId, userId);
3395
+ }
3396
+ async listBlocks(ownerId, recipientId) {
3397
+ return this.all("SELECT * FROM invite_blocks WHERE owner_id = ? AND recipient_id = ? ORDER BY seq", ownerId, recipientId).map(toBlock);
3398
+ }
3399
+ async getBlock(id) {
3400
+ const r = this.byId("invite_blocks", "b", id);
3401
+ return r ? toBlock(r) : null;
3402
+ }
3403
+ async createBlock(b) {
3404
+ const { lastInsertRowid } = this.run("INSERT INTO invite_blocks (owner_id, recipient_id, expires_at, decline_reply_id, created_at) VALUES (?, ?, ?, ?, ?)", b.ownerId, b.recipientId, b.expiresAt, b.declineReplyId, b.at);
3405
+ return toBlock(this.mustGet("invite_blocks", "b", idOf("b", lastInsertRowid), "block"));
3406
+ }
3407
+ async liftBlock(id, adminId, at) {
3408
+ this.mustGet("invite_blocks", "b", id, "block");
3409
+ this.run("UPDATE invite_blocks SET lifted_by = ?, lifted_at = ? WHERE seq = ?", adminId, at, seqOf("b", id));
3410
+ return toBlock(this.mustGet("invite_blocks", "b", id, "block"));
3411
+ }
3412
+ async createOccurrence(o) {
3413
+ const { changes, lastInsertRowid } = this.run(`INSERT INTO occurrences (task_id, lane, due_at, status, late, dedupe_key, created_at)
3414
+ VALUES (?, ?, ?, 'queued', 0, ?, ?)
3415
+ ON CONFLICT (dedupe_key) DO NOTHING`, o.taskId, o.lane, o.dueAt, o.dedupeKey, o.at);
3416
+ if (changes === 0)
3417
+ return null;
3418
+ return toOccurrence(this.mustGet("occurrences", "o", idOf("o", lastInsertRowid), "occurrence"));
3419
+ }
3420
+ async getOccurrence(id) {
3421
+ const r = this.byId("occurrences", "o", id);
3422
+ return r ? toOccurrence(r) : null;
3423
+ }
3424
+ async listOccurrences(filter = {}) {
3425
+ const where = [];
3426
+ const params = [];
3427
+ if (filter.taskId !== undefined)
3428
+ where.push("task_id = ?"), params.push(filter.taskId);
3429
+ if (filter.lane !== undefined)
3430
+ where.push("lane = ?"), params.push(filter.lane);
3431
+ if (filter.status !== undefined)
3432
+ where.push("status = ?"), params.push(filter.status);
3433
+ if (filter.dueBefore !== undefined)
3434
+ where.push("due_at <= ?"), params.push(filter.dueBefore);
3435
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
3436
+ return this.all(`SELECT * FROM occurrences ${clause} ORDER BY due_at, created_at, seq`, ...params).map(toOccurrence);
3437
+ }
3438
+ async updateOccurrence(id, patch) {
3439
+ const cur = toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
3440
+ const next = {
3441
+ ...cur,
3442
+ ...patch.status !== undefined ? { status: patch.status } : {},
3443
+ ...patch.startedAt !== undefined ? { startedAt: patch.startedAt } : {},
3444
+ ...patch.finishedAt !== undefined ? { finishedAt: patch.finishedAt } : {},
3445
+ ...patch.late !== undefined ? { late: patch.late } : {},
3446
+ ...patch.summary !== undefined ? { summary: patch.summary } : {},
3447
+ ...patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {},
3448
+ ...patch.error !== undefined ? { error: patch.error } : {}
3449
+ };
3450
+ this.run(`UPDATE occurrences SET status = ?, started_at = ?, finished_at = ?, late = ?, summary = ?, cost_usd = ?, error = ?
3451
+ WHERE seq = ?`, next.status, next.startedAt, next.finishedAt, next.late ? 1 : 0, next.summary, next.costUsd, next.error, seqOf("o", id));
3452
+ return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
3453
+ }
3454
+ async deleteQueuedOccurrences(taskId) {
3455
+ return this.run("DELETE FROM occurrences WHERE task_id = ? AND status = 'queued'", taskId).changes;
3456
+ }
3457
+ async requeueRunning(lane) {
3458
+ const rows = lane === undefined ? this.all("SELECT seq FROM occurrences WHERE status = 'running' ORDER BY seq") : this.all("SELECT seq FROM occurrences WHERE status = 'running' AND lane = ? ORDER BY seq", lane);
3459
+ for (const r of rows)
3460
+ this.run("UPDATE occurrences SET status = 'queued' WHERE seq = ?", r.seq);
3461
+ return rows.map((r) => idOf("o", r.seq));
3462
+ }
3463
+ async addEvent(e) {
3464
+ const { lastInsertRowid } = this.run("INSERT INTO events (occurrence_id, agent, type, text, at) VALUES (?, ?, ?, ?, ?)", e.occurrenceId, e.agent, e.type, e.text, e.at);
3465
+ return toEvent(this.mustGet("events", "e", idOf("e", lastInsertRowid), "event"));
3466
+ }
3467
+ async listEvents(occurrenceId, after) {
3468
+ const cursor = after === undefined ? null : this.byId("events", "e", after);
3469
+ return this.all("SELECT * FROM events WHERE occurrence_id = ? AND seq > ? ORDER BY seq", occurrenceId, cursor ? Number(cursor.seq) : 0).map(toEvent);
3470
+ }
3471
+ async addTaskEvent(e) {
3472
+ const { lastInsertRowid } = this.run("INSERT INTO task_events (task_id, actor_id, kind, detail, at) VALUES (?, ?, ?, ?, ?)", e.taskId, e.actorId, e.kind, e.detail, e.at);
3473
+ return toTaskEvent(this.mustGet("task_events", "h", idOf("h", lastInsertRowid), "task event"));
3474
+ }
3475
+ async listTaskEvents(taskId) {
3476
+ return this.all("SELECT * FROM task_events WHERE task_id = ? ORDER BY seq", taskId).map(toTaskEvent);
3477
+ }
3478
+ async addReply(r) {
3479
+ const { lastInsertRowid } = this.run("INSERT INTO replies (occurrence_id, task_id, user_id, kind, payload, at) VALUES (?, ?, ?, ?, ?, ?)", r.occurrenceId, r.taskId, r.userId, r.kind, json(r.payload), r.at);
3480
+ return toReply(this.mustGet("replies", "r", idOf("r", lastInsertRowid), "reply"));
3481
+ }
3482
+ async listReplies(taskId) {
3483
+ return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
3484
+ }
3485
+ async addSeriesPoint(p) {
3486
+ const { lastInsertRowid } = this.run("INSERT INTO series (task_id, at, value, unit, note) VALUES (?, ?, ?, ?, ?)", p.taskId, p.at, p.value, p.unit ?? null, p.note ?? null);
3487
+ return toPoint(this.mustGet("series", "s", idOf("s", lastInsertRowid), "series point"));
3488
+ }
3489
+ async listSeries(taskId, filter = {}) {
3490
+ const points = filter.since === undefined ? this.all("SELECT * FROM series WHERE task_id = ? ORDER BY at, seq", taskId) : this.all("SELECT * FROM series WHERE task_id = ? AND at >= ? ORDER BY at, seq", taskId, filter.since);
3491
+ const kept = filter.limit === undefined ? points : points.slice(-filter.limit);
3492
+ return kept.map(toPoint);
3493
+ }
3494
+ }
3495
+
3496
+ // plugins/tracker/src/index.ts
3497
+ var DB_DIR = "tracker";
3498
+ var DB_FILE = "tracker.sqlite";
3499
+ var TRACKER_TYPES = Object.freeze({
3500
+ reminder,
3501
+ renewal
3502
+ });
3503
+ var NO_DM = {
3504
+ async sendDm() {
3505
+ throw new Error("this bot has no host.dm (rackbops-discord-bot#736)");
3506
+ }
3507
+ };
3508
+ function createPlugin(host, options = {}) {
3509
+ const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
3510
+ const guildId = parseGuildId(host.env.TRACKER_GUILD_ID);
3511
+ const clock = options.clock ?? { now: () => new Date };
3512
+ const types = options.types ?? TRACKER_TYPES;
3513
+ const health = { activatedAt: null, lastTickAt: null, blocked: null };
3514
+ let db = null;
3515
+ let store = null;
3516
+ let claims = null;
3517
+ let delivery = null;
3518
+ let deps = null;
3519
+ let warnedNoDm = false;
3520
+ const dm = host.dm?.bind(host);
3521
+ const surface = createSurface({
3522
+ deps: () => deps,
3523
+ guildId,
3524
+ log: host.log,
3525
+ ...options.membership ? { membership: options.membership } : {}
3526
+ });
3527
+ return {
3528
+ commands: surface.commands,
3529
+ interactions: surface.interactions,
3530
+ async activate() {
3531
+ let path = options.dbPath;
3532
+ if (path === undefined) {
3533
+ const dir = join(host.dataDir, DB_DIR);
3534
+ mkdirSync(dir, { recursive: true });
3535
+ path = join(dir, DB_FILE);
3536
+ }
3537
+ const opened = openDatabase(path);
3538
+ const openedStore = new SqliteStore(opened);
3539
+ const openedClaims = new ClaimStore(opened);
3540
+ try {
3541
+ const granted = await seedAdmins(openedStore, adminIds, clock.now());
3542
+ const admissions = new Admissions(opened);
3543
+ for (const id of adminIds) {
3544
+ const admin = await openedStore.findUserByDiscordId(id);
3545
+ if (admin)
3546
+ admissions.record(admin.id, null, clock.now().toISOString());
3547
+ }
3548
+ if (granted.length > 0)
3549
+ host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
3550
+ const requeued = await openedStore.requeueRunning();
3551
+ if (requeued.length > 0)
3552
+ host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
3553
+ for (const c of openedClaims.listUnsettled())
3554
+ reportUnconfirmed(openedClaims, host.log, c, clock.now().toISOString());
3555
+ } catch (err) {
3556
+ opened.close();
3557
+ throw err;
3558
+ }
3559
+ db = opened;
3560
+ store = openedStore;
3561
+ claims = openedClaims;
3562
+ delivery = new DeliveryHealth(opened, openedStore, {
3563
+ log: host.log,
3564
+ ...dm ? {
3565
+ tellOwner: async (owner, text) => {
3566
+ if (owner.discordId)
3567
+ await dm(owner.discordId, { content: text });
3568
+ }
3569
+ } : {}
3570
+ });
3571
+ deps = {
3572
+ store: openedStore,
3573
+ admissions: new Admissions(opened),
3574
+ health: delivery,
3575
+ clock,
3576
+ types,
3577
+ dm,
3578
+ log: host.log,
3579
+ notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM
3580
+ };
3581
+ if (guildId === null)
3582
+ host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
3583
+ health.blocked = typeof host.dm === "function" ? null : "this bot has no host.dm (it predates rackbops-discord-bot#736)";
3584
+ health.activatedAt = clock.now();
3585
+ },
3586
+ async dispose() {
3587
+ health.activatedAt = null;
3588
+ deps = null;
3589
+ delivery = null;
3590
+ store = null;
3591
+ claims = null;
3592
+ const closing = db;
3593
+ db = null;
3594
+ closing?.close();
3595
+ },
3596
+ ticks: [
3597
+ {
3598
+ name: "notify",
3599
+ async run(signal) {
3600
+ if (!store || !claims)
3601
+ return;
3602
+ const outcome = await runNotifyTick({ store, claims, clock, types, dm, log: host.log, ...delivery ? { health: delivery } : {} }, signal);
3603
+ if (outcome.kind === "no-dm") {
3604
+ if (!warnedNoDm)
3605
+ host.log.warn("notify lane is off: this bot has no host.dm (rackbops-discord-bot#736)");
3606
+ warnedNoDm = true;
3607
+ return;
3608
+ }
3609
+ if (outcome.kind === "aborted")
3610
+ return;
3611
+ health.lastTickAt = clock.now();
3612
+ const { ran, failed } = outcome.result;
3613
+ if (failed > 0)
3614
+ host.log.warn(`notify lane: ${ran} ran, ${failed} failed`);
3615
+ }
3616
+ }
3617
+ ],
3618
+ async http(request, info) {
3619
+ if (info.path === "/healthz" && (request.method === "GET" || request.method === "HEAD")) {
3620
+ return healthResponse(decideHealth(health, clock.now()));
3621
+ }
3622
+ return new Response("Not found", { status: 404 });
3623
+ }
3624
+ };
3625
+ }
3626
+ export {
3627
+ DB_DIR,
3628
+ DB_FILE,
3629
+ TRACKER_TYPES,
3630
+ createPlugin
3631
+ };