@rackbops/plugin-tracker 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -24
- package/dist/plugin.js +1443 -592
- package/package.json +5 -5
package/dist/plugin.js
CHANGED
|
@@ -3,10 +3,59 @@
|
|
|
3
3
|
import { mkdirSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
|
|
6
|
+
// node_modules/@rackbops/docket-core/dist/record.js
|
|
7
|
+
function isObj(v) {
|
|
8
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
var optional = (o, k, check) => o[k] === undefined || check(o[k]);
|
|
11
|
+
var isString = (v) => typeof v === "string";
|
|
12
|
+
var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
13
|
+
var isStringArray = (v) => Array.isArray(v) && v.every(isString);
|
|
14
|
+
var REPLY_KINDS = new Set([
|
|
15
|
+
"accept",
|
|
16
|
+
"decline",
|
|
17
|
+
"opt_out",
|
|
18
|
+
"done",
|
|
19
|
+
"snooze",
|
|
20
|
+
"decision",
|
|
21
|
+
"text"
|
|
22
|
+
]);
|
|
23
|
+
function isMessage(v) {
|
|
24
|
+
if (!isObj(v) || !isString(v.text))
|
|
25
|
+
return false;
|
|
26
|
+
const actions = v.actions;
|
|
27
|
+
if (actions !== undefined && !(Array.isArray(actions) && actions.every((a) => REPLY_KINDS.has(a)))) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return optional(v, "decisions", isStringArray) && v.ref === undefined;
|
|
31
|
+
}
|
|
32
|
+
function isSeries(v) {
|
|
33
|
+
return Array.isArray(v) && v.every((p) => isObj(p) && isNumber(p.value) && optional(p, "unit", isString) && optional(p, "note", isString) && optional(p, "at", isString));
|
|
34
|
+
}
|
|
35
|
+
function isFindings(v) {
|
|
36
|
+
return Array.isArray(v) && v.every((f) => isObj(f) && isString(f.text) && optional(f, "tags", isStringArray) && optional(f, "source", isString));
|
|
37
|
+
}
|
|
38
|
+
function isOutcome(v) {
|
|
39
|
+
return isObj(v) && v.snoozeUntil === undefined && optional(v, "notify", isMessage) && optional(v, "summary", isString) && optional(v, "series", isSeries) && optional(v, "complete", (c) => typeof c === "boolean") && optional(v, "findings", isFindings);
|
|
40
|
+
}
|
|
41
|
+
function parseRunRecord(value) {
|
|
42
|
+
if (!isObj(value))
|
|
43
|
+
return null;
|
|
44
|
+
const ok = isOutcome(value.outcome) && (value.costUsd === null || isNumber(value.costUsd)) && isString(value.firedAt) && (value.appliedAt === null || isString(value.appliedAt)) && Number.isInteger(value.resumes);
|
|
45
|
+
return ok ? value : null;
|
|
46
|
+
}
|
|
47
|
+
function hasFired(occurrence) {
|
|
48
|
+
return occurrence.record !== null && occurrence.record !== undefined;
|
|
49
|
+
}
|
|
50
|
+
function isFinishing(occurrence) {
|
|
51
|
+
return hasFired(occurrence) && occurrence.finishedAt === null;
|
|
52
|
+
}
|
|
53
|
+
|
|
6
54
|
// node_modules/@rackbops/docket-core/dist/answer.js
|
|
7
55
|
var RUN_KINDS = new Set(["done", "snooze", "decision"]);
|
|
8
56
|
var NOT_YOURS = "That is not yours to answer.";
|
|
9
57
|
var OVER = "That run is over; answer the latest message instead.";
|
|
58
|
+
var STILL_FINISHING = "That run is still finishing; try again in a minute.";
|
|
10
59
|
|
|
11
60
|
class ReplyRefusedError extends Error {
|
|
12
61
|
name = "ReplyRefusedError";
|
|
@@ -21,8 +70,11 @@ async function runRefusal(store, task, occurrenceId, userId) {
|
|
|
21
70
|
return "That run no longer exists.";
|
|
22
71
|
if (task.status !== "active")
|
|
23
72
|
return `That task is ${task.status}.`;
|
|
24
|
-
if (occurrence.status === "queued")
|
|
73
|
+
if ((occurrence.status === "queued" || occurrence.status === "running") && !hasFired(occurrence)) {
|
|
25
74
|
return "That run has not fired yet.";
|
|
75
|
+
}
|
|
76
|
+
if (isFinishing(occurrence))
|
|
77
|
+
return STILL_FINISHING;
|
|
26
78
|
if (occurrence.status === "snoozed" || occurrence.status === "skipped")
|
|
27
79
|
return OVER;
|
|
28
80
|
const answered = (await store.listReplies(task.id)).some((r) => r.occurrenceId === occurrence.id && RUN_KINDS.has(r.kind));
|
|
@@ -52,20 +104,40 @@ async function visibleTasks(store, actor, filter = {}) {
|
|
|
52
104
|
}
|
|
53
105
|
return out;
|
|
54
106
|
}
|
|
107
|
+
function seesAll(actor, task) {
|
|
108
|
+
return actor.admin || task.ownerId === actor.userId;
|
|
109
|
+
}
|
|
55
110
|
async function visibleOccurrences(store, actor, taskId) {
|
|
56
|
-
|
|
111
|
+
const task = await visibleTask(store, actor, taskId);
|
|
112
|
+
if (!task)
|
|
57
113
|
return null;
|
|
58
|
-
|
|
114
|
+
const runs = await store.listOccurrences({ taskId });
|
|
115
|
+
return seesAll(actor, task) ? runs : runs.map((o) => ({ ...o, record: null }));
|
|
59
116
|
}
|
|
60
117
|
async function visibleReplies(store, actor, taskId) {
|
|
61
|
-
|
|
118
|
+
const task = await visibleTask(store, actor, taskId);
|
|
119
|
+
if (!task)
|
|
62
120
|
return null;
|
|
63
|
-
|
|
64
|
-
|
|
121
|
+
const replies = await store.listReplies(taskId);
|
|
122
|
+
return seesAll(actor, task) ? replies : replies.filter((r) => r.userId === actor.userId);
|
|
123
|
+
}
|
|
124
|
+
var ABOUT_A_RECIPIENT = new Set([
|
|
125
|
+
"recipient_invited",
|
|
126
|
+
"recipient_accepted",
|
|
127
|
+
"recipient_declined",
|
|
128
|
+
"recipient_opted_out",
|
|
129
|
+
"recipient_removed",
|
|
130
|
+
"blocked",
|
|
131
|
+
"block_lifted"
|
|
132
|
+
]);
|
|
65
133
|
async function visibleHistory(store, actor, taskId) {
|
|
66
|
-
|
|
134
|
+
const task = await visibleTask(store, actor, taskId);
|
|
135
|
+
if (!task)
|
|
67
136
|
return null;
|
|
68
|
-
|
|
137
|
+
const events = await store.listTaskEvents(taskId);
|
|
138
|
+
if (seesAll(actor, task))
|
|
139
|
+
return events;
|
|
140
|
+
return events.filter((e) => !ABOUT_A_RECIPIENT.has(e.kind) || e.detail.split(" ")[0] === actor.userId);
|
|
69
141
|
}
|
|
70
142
|
async function visibleSeries(store, actor, taskId, filter) {
|
|
71
143
|
if (!await visibleTask(store, actor, taskId))
|
|
@@ -73,6 +145,191 @@ async function visibleSeries(store, actor, taskId, filter) {
|
|
|
73
145
|
return store.listSeries(taskId, filter);
|
|
74
146
|
}
|
|
75
147
|
|
|
148
|
+
// node_modules/@rackbops/docket-core/dist/zoned.js
|
|
149
|
+
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
150
|
+
var formatters = new Map;
|
|
151
|
+
function formatter(zone) {
|
|
152
|
+
let f = formatters.get(zone);
|
|
153
|
+
if (!f) {
|
|
154
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
155
|
+
timeZone: zone,
|
|
156
|
+
hourCycle: "h23",
|
|
157
|
+
year: "numeric",
|
|
158
|
+
month: "2-digit",
|
|
159
|
+
day: "2-digit",
|
|
160
|
+
hour: "2-digit",
|
|
161
|
+
minute: "2-digit",
|
|
162
|
+
weekday: "short"
|
|
163
|
+
});
|
|
164
|
+
formatters.set(zone, f);
|
|
165
|
+
}
|
|
166
|
+
return f;
|
|
167
|
+
}
|
|
168
|
+
function isTimeZone(zone) {
|
|
169
|
+
try {
|
|
170
|
+
formatter(zone);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function wallClock(instant, zone) {
|
|
177
|
+
const parts = formatter(zone).formatToParts(instant);
|
|
178
|
+
const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
179
|
+
return {
|
|
180
|
+
year: Number(get("year")),
|
|
181
|
+
month: Number(get("month")),
|
|
182
|
+
day: Number(get("day")),
|
|
183
|
+
hour: Number(get("hour")),
|
|
184
|
+
minute: Number(get("minute")),
|
|
185
|
+
weekday: WEEKDAYS.indexOf(get("weekday"))
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function zoneOffsetMinutes(instant, zone) {
|
|
189
|
+
const w = wallClock(instant, zone);
|
|
190
|
+
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
|
|
191
|
+
const truncated = Math.floor(instant.getTime() / 60000) * 60000;
|
|
192
|
+
return Math.round((asUtc - truncated) / 60000);
|
|
193
|
+
}
|
|
194
|
+
function reads(instant, zone, d, h, min) {
|
|
195
|
+
const w = wallClock(new Date(instant), zone);
|
|
196
|
+
return w.day === d && w.hour === h && w.minute === min;
|
|
197
|
+
}
|
|
198
|
+
function zonedInstant(year, month, day, hour, minute, zone) {
|
|
199
|
+
const wanted = Date.UTC(year, month - 1, day, hour, minute);
|
|
200
|
+
const offset1 = zoneOffsetMinutes(new Date(wanted), zone);
|
|
201
|
+
const candidate1 = wanted - offset1 * 60000;
|
|
202
|
+
if (reads(candidate1, zone, day, hour, minute))
|
|
203
|
+
return new Date(candidate1);
|
|
204
|
+
const offset2 = zoneOffsetMinutes(new Date(candidate1), zone);
|
|
205
|
+
const candidate2 = wanted - offset2 * 60000;
|
|
206
|
+
if (reads(candidate2, zone, day, hour, minute))
|
|
207
|
+
return new Date(candidate2);
|
|
208
|
+
return new Date(Math.max(candidate1, candidate2));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// node_modules/@rackbops/docket-core/dist/budget.js
|
|
212
|
+
var BUDGET_ZONE = "America/New_York";
|
|
213
|
+
var DEFAULT_BUDGET = {
|
|
214
|
+
person: { usd: 2, calls: 20 },
|
|
215
|
+
global: { usd: 10, calls: 100 }
|
|
216
|
+
};
|
|
217
|
+
function pad(n) {
|
|
218
|
+
return String(n).padStart(2, "0");
|
|
219
|
+
}
|
|
220
|
+
function budgetDay(now) {
|
|
221
|
+
const w = wallClock(now, BUDGET_ZONE);
|
|
222
|
+
const next = new Date(Date.UTC(w.year, w.month - 1, w.day + 1));
|
|
223
|
+
const start = zonedInstant(w.year, w.month, w.day, 0, 0, BUDGET_ZONE);
|
|
224
|
+
const end = zonedInstant(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), 0, 0, BUDGET_ZONE);
|
|
225
|
+
return {
|
|
226
|
+
day: `${w.year}-${pad(w.month)}-${pad(w.day)}`,
|
|
227
|
+
start: start.toISOString(),
|
|
228
|
+
end: end.toISOString()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function spent(rows) {
|
|
232
|
+
return rows.reduce((s, r) => ({ usd: s.usd + r.costUsd, calls: s.calls + r.calls }), {
|
|
233
|
+
usd: 0,
|
|
234
|
+
calls: 0
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function reached(used, limits) {
|
|
238
|
+
if (limits.calls !== null && used.calls >= limits.calls)
|
|
239
|
+
return "calls";
|
|
240
|
+
if (limits.usd !== null && used.usd >= limits.usd)
|
|
241
|
+
return "usd";
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
async function budgetHold(store, policy, owner, now) {
|
|
245
|
+
const day = budgetDay(now);
|
|
246
|
+
const window = { since: day.start, before: day.end };
|
|
247
|
+
const all = await store.listUsage(window);
|
|
248
|
+
const everyone = spent(all);
|
|
249
|
+
const globalLimit = reached(everyone, policy.global);
|
|
250
|
+
if (globalLimit) {
|
|
251
|
+
return hold("global", globalLimit, everyone, policy.global, day);
|
|
252
|
+
}
|
|
253
|
+
const limits = await policy.personFor?.(owner) ?? policy.person;
|
|
254
|
+
const mine = spent(all.filter((u) => u.userId === owner.id));
|
|
255
|
+
const personLimit = reached(mine, limits);
|
|
256
|
+
return personLimit ? hold("person", personLimit, mine, limits, day) : null;
|
|
257
|
+
}
|
|
258
|
+
function hold(scope, limit, used, limits, day) {
|
|
259
|
+
return { scope, limit, used, limits, until: day.end, day: day.day };
|
|
260
|
+
}
|
|
261
|
+
async function charge(store, c) {
|
|
262
|
+
return store.addUsage({
|
|
263
|
+
userId: c.userId,
|
|
264
|
+
taskId: c.taskId,
|
|
265
|
+
occurrenceId: c.occurrenceId ?? null,
|
|
266
|
+
source: c.source,
|
|
267
|
+
calls: c.calls,
|
|
268
|
+
costUsd: c.costUsd ?? 0,
|
|
269
|
+
at: c.at.toISOString()
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function describeLimit(h) {
|
|
273
|
+
return h.limit === "calls" ? `${h.limits.calls} model calls` : `${h.limits.usd} USD of model use (an estimate)`;
|
|
274
|
+
}
|
|
275
|
+
function budgetPersonMessage(h) {
|
|
276
|
+
return {
|
|
277
|
+
text: [
|
|
278
|
+
`You have reached today's limit of ${describeLimit(h)} for tasks that use the model.`,
|
|
279
|
+
"They will run again after midnight Eastern time. Reminders are not affected."
|
|
280
|
+
].join(`
|
|
281
|
+
`)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function budgetAdminMessage(h, who) {
|
|
285
|
+
const used = `(${h.used.calls} calls, about ${h.used.usd.toFixed(2)} USD)`;
|
|
286
|
+
if (h.scope === "global") {
|
|
287
|
+
return {
|
|
288
|
+
text: [
|
|
289
|
+
`Everyone together has reached today's limit of ${describeLimit(h)} ${used}.`,
|
|
290
|
+
"Every task that uses the model waits until midnight Eastern time."
|
|
291
|
+
].join(`
|
|
292
|
+
`)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const name = who?.displayName ?? who?.id ?? "Someone";
|
|
296
|
+
return {
|
|
297
|
+
text: [
|
|
298
|
+
`${name} has reached today's limit of ${describeLimit(h)} ${used}.`,
|
|
299
|
+
`${name}'s tasks that use the model wait until midnight Eastern time; an admin can raise ${name}'s limit.`
|
|
300
|
+
].join(`
|
|
301
|
+
`)
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function usageLimitAdminMessage(until, parsed, retryAt) {
|
|
305
|
+
const retry = retryAt && retryAt < until ? `, trying again at ${retryAt.toISOString()} in case it was misread` : "";
|
|
306
|
+
const when = parsed ? `until ${until.toISOString()}${retry}` : "for an hour, then try again (no usable reset time came with it)";
|
|
307
|
+
return {
|
|
308
|
+
text: [
|
|
309
|
+
"The Claude subscription's usage limit was reached.",
|
|
310
|
+
`Tasks that use the model wait ${when}; nobody's budget is charged for it.`
|
|
311
|
+
].join(`
|
|
312
|
+
`)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
async function noticeOnce(store, notifier, key, now, admins, person) {
|
|
316
|
+
if (!await store.claimNotice(key, now.toISOString()))
|
|
317
|
+
return false;
|
|
318
|
+
const sends = [];
|
|
319
|
+
for (const admin of await store.listUsers({ admin: true })) {
|
|
320
|
+
if (admin.id !== person?.user.id)
|
|
321
|
+
sends.push([admin.id, admins]);
|
|
322
|
+
}
|
|
323
|
+
if (person)
|
|
324
|
+
sends.push([person.user.id, person.message]);
|
|
325
|
+
for (const [userId, message] of sends) {
|
|
326
|
+
try {
|
|
327
|
+
await notifier.sendDm(userId, message);
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
76
333
|
// node_modules/@rackbops/docket-core/dist/capabilities.js
|
|
77
334
|
var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
|
|
78
335
|
var CAPABILITY_TIER = {
|
|
@@ -206,69 +463,6 @@ function isLane(value) {
|
|
|
206
463
|
return LANES.includes(value);
|
|
207
464
|
}
|
|
208
465
|
|
|
209
|
-
// node_modules/@rackbops/docket-core/dist/zoned.js
|
|
210
|
-
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
211
|
-
var formatters = new Map;
|
|
212
|
-
function formatter(zone) {
|
|
213
|
-
let f = formatters.get(zone);
|
|
214
|
-
if (!f) {
|
|
215
|
-
f = new Intl.DateTimeFormat("en-US", {
|
|
216
|
-
timeZone: zone,
|
|
217
|
-
hourCycle: "h23",
|
|
218
|
-
year: "numeric",
|
|
219
|
-
month: "2-digit",
|
|
220
|
-
day: "2-digit",
|
|
221
|
-
hour: "2-digit",
|
|
222
|
-
minute: "2-digit",
|
|
223
|
-
weekday: "short"
|
|
224
|
-
});
|
|
225
|
-
formatters.set(zone, f);
|
|
226
|
-
}
|
|
227
|
-
return f;
|
|
228
|
-
}
|
|
229
|
-
function isTimeZone(zone) {
|
|
230
|
-
try {
|
|
231
|
-
formatter(zone);
|
|
232
|
-
return true;
|
|
233
|
-
} catch {
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
function wallClock(instant, zone) {
|
|
238
|
-
const parts = formatter(zone).formatToParts(instant);
|
|
239
|
-
const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
|
|
240
|
-
return {
|
|
241
|
-
year: Number(get("year")),
|
|
242
|
-
month: Number(get("month")),
|
|
243
|
-
day: Number(get("day")),
|
|
244
|
-
hour: Number(get("hour")),
|
|
245
|
-
minute: Number(get("minute")),
|
|
246
|
-
weekday: WEEKDAYS.indexOf(get("weekday"))
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
function zoneOffsetMinutes(instant, zone) {
|
|
250
|
-
const w = wallClock(instant, zone);
|
|
251
|
-
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
|
|
252
|
-
const truncated = Math.floor(instant.getTime() / 60000) * 60000;
|
|
253
|
-
return Math.round((asUtc - truncated) / 60000);
|
|
254
|
-
}
|
|
255
|
-
function reads(instant, zone, d, h, min) {
|
|
256
|
-
const w = wallClock(new Date(instant), zone);
|
|
257
|
-
return w.day === d && w.hour === h && w.minute === min;
|
|
258
|
-
}
|
|
259
|
-
function zonedInstant(year, month, day, hour, minute, zone) {
|
|
260
|
-
const wanted = Date.UTC(year, month - 1, day, hour, minute);
|
|
261
|
-
const offset1 = zoneOffsetMinutes(new Date(wanted), zone);
|
|
262
|
-
const candidate1 = wanted - offset1 * 60000;
|
|
263
|
-
if (reads(candidate1, zone, day, hour, minute))
|
|
264
|
-
return new Date(candidate1);
|
|
265
|
-
const offset2 = zoneOffsetMinutes(new Date(candidate1), zone);
|
|
266
|
-
const candidate2 = wanted - offset2 * 60000;
|
|
267
|
-
if (reads(candidate2, zone, day, hour, minute))
|
|
268
|
-
return new Date(candidate2);
|
|
269
|
-
return new Date(Math.max(candidate1, candidate2));
|
|
270
|
-
}
|
|
271
|
-
|
|
272
466
|
// node_modules/@rackbops/docket-core/dist/schedule.js
|
|
273
467
|
var SCHEDULE_KINDS = ["once", "calendar", "poll", "period"];
|
|
274
468
|
function isScheduleKind(value) {
|
|
@@ -548,11 +742,30 @@ function snoozeKey(occurrenceId) {
|
|
|
548
742
|
}
|
|
549
743
|
var SNOOZE_PREFIX = "snooze:";
|
|
550
744
|
|
|
745
|
+
// node_modules/@rackbops/docket-core/dist/ports.js
|
|
746
|
+
class DeliveryFailedError extends Error {
|
|
747
|
+
unreachable;
|
|
748
|
+
name = "DeliveryFailedError";
|
|
749
|
+
constructor(message, unreachable = false) {
|
|
750
|
+
super(message);
|
|
751
|
+
this.unreachable = unreachable;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
class ExecutorUnavailableError extends Error {
|
|
756
|
+
name = "ExecutorUnavailableError";
|
|
757
|
+
}
|
|
758
|
+
|
|
551
759
|
// node_modules/@rackbops/docket-core/dist/delivery.js
|
|
552
|
-
var
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
760
|
+
var DEFAULT_DELIVERY_POLICY = {
|
|
761
|
+
attempts: 3,
|
|
762
|
+
deferrals: 8,
|
|
763
|
+
backoffMs: 60000,
|
|
764
|
+
maxBackoffMs: 3600000
|
|
765
|
+
};
|
|
766
|
+
var STALE_CLAIM_MS = 10 * 60000;
|
|
767
|
+
function backoff(policy, n) {
|
|
768
|
+
return Math.min(policy.backoffMs * 2 ** Math.max(0, n - 1), policy.maxBackoffMs);
|
|
556
769
|
}
|
|
557
770
|
function messageFor(message, occurrence, target, ownerId) {
|
|
558
771
|
const ref = { taskId: occurrence.taskId, occurrenceId: occurrence.id };
|
|
@@ -564,30 +777,129 @@ function messageFor(message, occurrence, target, ownerId) {
|
|
|
564
777
|
const kept = actions.filter((a) => !RUN_KINDS.has(a) && a !== "opt_out");
|
|
565
778
|
return { ...rest, actions: [...kept, "opt_out"], ref };
|
|
566
779
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
780
|
+
function reason(err) {
|
|
781
|
+
return err instanceof Error ? err.message : String(err);
|
|
782
|
+
}
|
|
783
|
+
function emptyReport() {
|
|
784
|
+
return { sent: [], retrying: [], failed: [], unconfirmed: [], untried: [] };
|
|
785
|
+
}
|
|
786
|
+
async function deliver(store, notifier, occurrence, ownerId, targets, message, options) {
|
|
787
|
+
const policy = options.policy ?? DEFAULT_DELIVERY_POLICY;
|
|
788
|
+
const now = () => options.now();
|
|
789
|
+
const report = emptyReport();
|
|
790
|
+
const owed = await store.listDeliveries({
|
|
791
|
+
occurrenceId: occurrence.id,
|
|
792
|
+
dueBefore: now().toISOString()
|
|
793
|
+
});
|
|
794
|
+
for (const row of owed) {
|
|
795
|
+
if (options.signal?.aborted) {
|
|
796
|
+
report.untried.push(row.userId);
|
|
576
797
|
continue;
|
|
577
798
|
}
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
799
|
+
const target = targets.get(row.userId);
|
|
800
|
+
if (!target) {
|
|
801
|
+
await finalFail(store, row, "no longer a recipient", now());
|
|
802
|
+
report.failed.push(row.userId);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const claim = await store.claimDelivery(occurrence.id, row.userId, now().toISOString());
|
|
806
|
+
if (!claim) {
|
|
807
|
+
report.untried.push(row.userId);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
await sendOne(store, notifier, occurrence, claim, target, ownerId, message, policy, now, report);
|
|
588
811
|
}
|
|
589
812
|
return report;
|
|
590
813
|
}
|
|
814
|
+
async function sendOne(store, notifier, occurrence, claim, target, ownerId, message, policy, now, report) {
|
|
815
|
+
const settle = (s) => store.settleDelivery(occurrence.id, claim.userId, s);
|
|
816
|
+
const counts = { attempts: claim.attempts, deferrals: claim.deferrals };
|
|
817
|
+
let messageId;
|
|
818
|
+
try {
|
|
819
|
+
({ messageId } = await notifier.sendDm(target.id, messageFor(message, occurrence, target, ownerId)));
|
|
820
|
+
} catch (err) {
|
|
821
|
+
const error = reason(err);
|
|
822
|
+
const at = now();
|
|
823
|
+
if (err instanceof ExecutorUnavailableError) {
|
|
824
|
+
const deferrals = claim.deferrals + 1;
|
|
825
|
+
const last = deferrals >= policy.deferrals;
|
|
826
|
+
await settle({
|
|
827
|
+
status: last ? "failed" : "deferred",
|
|
828
|
+
error,
|
|
829
|
+
...counts,
|
|
830
|
+
deferrals,
|
|
831
|
+
retryAt: last ? null : new Date(at.getTime() + backoff(policy, deferrals)).toISOString(),
|
|
832
|
+
at: at.toISOString()
|
|
833
|
+
});
|
|
834
|
+
(last ? report.failed : report.retrying).push(claim.userId);
|
|
835
|
+
} else if (err instanceof DeliveryFailedError) {
|
|
836
|
+
const attempts = claim.attempts + 1;
|
|
837
|
+
const last = err.unreachable || attempts >= policy.attempts;
|
|
838
|
+
await settle({
|
|
839
|
+
status: "failed",
|
|
840
|
+
error,
|
|
841
|
+
...counts,
|
|
842
|
+
attempts,
|
|
843
|
+
retryAt: last ? null : new Date(at.getTime() + backoff(policy, attempts)).toISOString(),
|
|
844
|
+
at: at.toISOString()
|
|
845
|
+
});
|
|
846
|
+
(last ? report.failed : report.retrying).push(claim.userId);
|
|
847
|
+
} else {
|
|
848
|
+
await settle({ status: "unconfirmed", error, ...counts, retryAt: null, at: at.toISOString() });
|
|
849
|
+
report.unconfirmed.push(claim.userId);
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
await settle({ status: "sent", messageId, ...counts, retryAt: null, at: now().toISOString() });
|
|
854
|
+
report.sent.push(claim.userId);
|
|
855
|
+
}
|
|
856
|
+
async function finalFail(store, row, error, at) {
|
|
857
|
+
await store.settleDelivery(row.occurrenceId, row.userId, {
|
|
858
|
+
status: "failed",
|
|
859
|
+
error,
|
|
860
|
+
attempts: row.attempts,
|
|
861
|
+
deferrals: row.deferrals,
|
|
862
|
+
retryAt: null,
|
|
863
|
+
at: at.toISOString()
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async function dropOwed(store, rows, error, at) {
|
|
867
|
+
for (const row of rows)
|
|
868
|
+
await finalFail(store, row, error, at);
|
|
869
|
+
}
|
|
870
|
+
async function settleStaleClaims(store, now, all = false) {
|
|
871
|
+
const cutoff = new Date(now.getTime() - STALE_CLAIM_MS).toISOString();
|
|
872
|
+
let settled = 0;
|
|
873
|
+
for (const row of await store.listDeliveries({ status: "claimed" })) {
|
|
874
|
+
if (!all && (row.claimedAt ?? "") > cutoff)
|
|
875
|
+
continue;
|
|
876
|
+
await store.settleDelivery(row.occurrenceId, row.userId, {
|
|
877
|
+
status: "unconfirmed",
|
|
878
|
+
error: "claimed, never settled",
|
|
879
|
+
attempts: row.attempts,
|
|
880
|
+
deferrals: row.deferrals,
|
|
881
|
+
retryAt: null,
|
|
882
|
+
at: now.toISOString()
|
|
883
|
+
});
|
|
884
|
+
settled += 1;
|
|
885
|
+
}
|
|
886
|
+
return settled;
|
|
887
|
+
}
|
|
888
|
+
async function freshRoundForPause(store, rows) {
|
|
889
|
+
for (const row of rows) {
|
|
890
|
+
if (row.attempts === 0 && row.deferrals === 0)
|
|
891
|
+
continue;
|
|
892
|
+
await store.settleDelivery(row.occurrenceId, row.userId, {
|
|
893
|
+
status: row.status === "claimed" ? "pending" : row.status,
|
|
894
|
+
messageId: row.messageId,
|
|
895
|
+
error: row.error,
|
|
896
|
+
attempts: 0,
|
|
897
|
+
deferrals: 0,
|
|
898
|
+
retryAt: row.retryAt,
|
|
899
|
+
at: row.settledAt ?? row.createdAt
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
591
903
|
|
|
592
904
|
// node_modules/@rackbops/docket-core/dist/describe.js
|
|
593
905
|
var WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
@@ -648,11 +960,6 @@ function describeSchedule(schedule, owner, viewerZone = owner.timeZone, now) {
|
|
|
648
960
|
}
|
|
649
961
|
}
|
|
650
962
|
|
|
651
|
-
// node_modules/@rackbops/docket-core/dist/ports.js
|
|
652
|
-
class ExecutorUnavailableError extends Error {
|
|
653
|
-
name = "ExecutorUnavailableError";
|
|
654
|
-
}
|
|
655
|
-
|
|
656
963
|
// node_modules/@rackbops/docket-core/dist/scheduler.js
|
|
657
964
|
var LATE_AFTER_MS = 5 * 60000;
|
|
658
965
|
|
|
@@ -666,7 +973,7 @@ async function materialize(store, task, owner, now) {
|
|
|
666
973
|
if (task.status !== "active" || !task.schedule)
|
|
667
974
|
return null;
|
|
668
975
|
const pending = await store.listOccurrences({ taskId: task.id });
|
|
669
|
-
const scheduled = pending.filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
976
|
+
const scheduled = pending.filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
|
|
670
977
|
if (scheduled.some((o) => o.status === "queued" || o.status === "running"))
|
|
671
978
|
return null;
|
|
672
979
|
const after = firstDueAfter(task.schedule, pending.length === 0, now);
|
|
@@ -691,6 +998,16 @@ function firstDueAfter(schedule, fresh, now) {
|
|
|
691
998
|
return new Date(Date.parse(schedule.start) - 1);
|
|
692
999
|
return now;
|
|
693
1000
|
}
|
|
1001
|
+
async function cancelScheduledRuns(store, taskId) {
|
|
1002
|
+
let removed = 0;
|
|
1003
|
+
for (const o of await store.listOccurrences({ taskId, status: "queued" })) {
|
|
1004
|
+
if (o.dedupeKey.startsWith(SNOOZE_PREFIX) || hasFired(o))
|
|
1005
|
+
continue;
|
|
1006
|
+
if (await store.deleteOccurrence(o.id))
|
|
1007
|
+
removed += 1;
|
|
1008
|
+
}
|
|
1009
|
+
return removed;
|
|
1010
|
+
}
|
|
694
1011
|
async function reschedule(store, task, owner, schedule, actorId, now) {
|
|
695
1012
|
if (schedule) {
|
|
696
1013
|
const problems = scheduleProblems(schedule);
|
|
@@ -698,7 +1015,7 @@ async function reschedule(store, task, owner, schedule, actorId, now) {
|
|
|
698
1015
|
throw new ScheduleError(problems.join("; "));
|
|
699
1016
|
}
|
|
700
1017
|
const at = now.toISOString();
|
|
701
|
-
const removed = await store
|
|
1018
|
+
const removed = await cancelScheduledRuns(store, task.id);
|
|
702
1019
|
const updated = await store.updateTask(task.id, { schedule, at });
|
|
703
1020
|
await store.addTaskEvent({
|
|
704
1021
|
taskId: task.id,
|
|
@@ -713,34 +1030,95 @@ async function reschedule(store, task, owner, schedule, actorId, now) {
|
|
|
713
1030
|
// node_modules/@rackbops/docket-core/dist/dispatch.js
|
|
714
1031
|
var ME = "docket";
|
|
715
1032
|
var SERIES_IN_CONTEXT = 100;
|
|
1033
|
+
var USAGE_LIMIT_BACKOFF_MS = 3600000;
|
|
1034
|
+
var MAX_USAGE_LIMIT_WAIT_MS = 86400000;
|
|
1035
|
+
var USAGE_WINDOW_MS = 5 * 3600000;
|
|
1036
|
+
var MAX_RESUMES = 3;
|
|
1037
|
+
var STALE_RUN_MS = 10 * 60000;
|
|
1038
|
+
|
|
1039
|
+
class UsageLimitReached extends Error {
|
|
1040
|
+
until;
|
|
1041
|
+
parsed;
|
|
1042
|
+
window;
|
|
1043
|
+
constructor(until, parsed, window) {
|
|
1044
|
+
super(`usage limit until ${until.toISOString()}`);
|
|
1045
|
+
this.until = until;
|
|
1046
|
+
this.parsed = parsed;
|
|
1047
|
+
this.window = window;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
716
1050
|
|
|
717
1051
|
class Lanes {
|
|
718
1052
|
d;
|
|
1053
|
+
executeAfter = null;
|
|
719
1054
|
constructor(d) {
|
|
720
1055
|
this.d = d;
|
|
721
1056
|
}
|
|
722
1057
|
async recover() {
|
|
723
|
-
|
|
1058
|
+
const ids = await this.d.store.requeueRunning();
|
|
1059
|
+
await settleStaleClaims(this.d.store, this.d.clock.now(), true);
|
|
1060
|
+
return ids;
|
|
724
1061
|
}
|
|
725
|
-
async tickNotify() {
|
|
1062
|
+
async tickNotify(signal) {
|
|
726
1063
|
const result = { ran: 0, failed: 0, skipped: 0 };
|
|
727
|
-
|
|
728
|
-
|
|
1064
|
+
const count = (verdict) => {
|
|
1065
|
+
if (verdict === "stop" || verdict === "held")
|
|
1066
|
+
result.skipped++;
|
|
1067
|
+
else
|
|
1068
|
+
verdict === "ran" ? result.ran++ : result.failed++;
|
|
1069
|
+
};
|
|
1070
|
+
await this.guarded(() => this.sweepStaleRuns());
|
|
1071
|
+
for (const occurrence of await this.unfinished()) {
|
|
1072
|
+
if (signal?.aborted)
|
|
1073
|
+
return result;
|
|
1074
|
+
count(await this.guarded(() => this.resume(occurrence, signal)));
|
|
1075
|
+
}
|
|
1076
|
+
const due = await this.due("notify");
|
|
1077
|
+
for (const [i, occurrence] of due.entries()) {
|
|
1078
|
+
if (signal?.aborted) {
|
|
1079
|
+
result.skipped += due.length - i;
|
|
1080
|
+
return result;
|
|
1081
|
+
}
|
|
1082
|
+
count(await this.guarded(() => this.runOne(occurrence, signal)));
|
|
729
1083
|
}
|
|
1084
|
+
if (!signal?.aborted)
|
|
1085
|
+
await this.guarded(() => this.deliverOwed(signal));
|
|
730
1086
|
return result;
|
|
731
1087
|
}
|
|
1088
|
+
async guarded(step) {
|
|
1089
|
+
try {
|
|
1090
|
+
return await step();
|
|
1091
|
+
} catch {
|
|
1092
|
+
return "failed";
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async sweepStaleRuns() {
|
|
1096
|
+
const { store, clock } = this.d;
|
|
1097
|
+
const cutoff = new Date(clock.now().getTime() - STALE_RUN_MS).toISOString();
|
|
1098
|
+
for (const o of await store.listOccurrences({ status: "running" })) {
|
|
1099
|
+
if (!hasFired(o) || (o.startedAt ?? "") > cutoff)
|
|
1100
|
+
continue;
|
|
1101
|
+
await store.updateOccurrenceIf(o.id, "running", { status: "queued", startedAt: null });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
732
1104
|
async tickExecute() {
|
|
733
1105
|
const due = await this.due("execute");
|
|
734
1106
|
if (!this.d.executor)
|
|
735
1107
|
return { ran: 0, failed: 0, skipped: due.length };
|
|
1108
|
+
if (this.executeAfter && this.d.clock.now() < this.executeAfter) {
|
|
1109
|
+
return { ran: 0, failed: 0, skipped: due.length };
|
|
1110
|
+
}
|
|
736
1111
|
const result = { ran: 0, failed: 0, skipped: 0 };
|
|
737
|
-
for (const occurrence of due) {
|
|
738
|
-
const
|
|
739
|
-
if (
|
|
740
|
-
result.skipped += due.length -
|
|
1112
|
+
for (const [i, occurrence] of due.entries()) {
|
|
1113
|
+
const verdict = await this.guarded(() => this.runOne(occurrence));
|
|
1114
|
+
if (verdict === "stop") {
|
|
1115
|
+
result.skipped += due.length - i;
|
|
741
1116
|
break;
|
|
742
1117
|
}
|
|
743
|
-
|
|
1118
|
+
if (verdict === "held")
|
|
1119
|
+
result.skipped++;
|
|
1120
|
+
else
|
|
1121
|
+
verdict === "ran" ? result.ran++ : result.failed++;
|
|
744
1122
|
}
|
|
745
1123
|
return result;
|
|
746
1124
|
}
|
|
@@ -774,7 +1152,7 @@ class Lanes {
|
|
|
774
1152
|
return null;
|
|
775
1153
|
const ctx = await this.context(loaded, occurrence, now);
|
|
776
1154
|
const outcome = await loaded.type.onReply({ ...ctx, reply });
|
|
777
|
-
await this.apply(task, outcome, now);
|
|
1155
|
+
await this.apply(task, outcome, now, null);
|
|
778
1156
|
if (outcome.complete)
|
|
779
1157
|
await this.complete(task, input.userId, now);
|
|
780
1158
|
else if (outcome.snoozeUntil) {
|
|
@@ -783,19 +1161,36 @@ class Lanes {
|
|
|
783
1161
|
return outcome;
|
|
784
1162
|
}
|
|
785
1163
|
async due(lane) {
|
|
786
|
-
|
|
1164
|
+
const queued = await this.d.store.listOccurrences({
|
|
787
1165
|
lane,
|
|
788
1166
|
status: "queued",
|
|
789
1167
|
dueBefore: this.d.clock.now().toISOString()
|
|
790
1168
|
});
|
|
1169
|
+
const status = new Map;
|
|
1170
|
+
const kept = [];
|
|
1171
|
+
for (const o of queued) {
|
|
1172
|
+
if (hasFired(o))
|
|
1173
|
+
continue;
|
|
1174
|
+
if (!status.has(o.taskId)) {
|
|
1175
|
+
status.set(o.taskId, (await this.d.store.getTask(o.taskId))?.status ?? null);
|
|
1176
|
+
}
|
|
1177
|
+
const s = status.get(o.taskId);
|
|
1178
|
+
if (s === null || s === "active")
|
|
1179
|
+
kept.push(o);
|
|
1180
|
+
}
|
|
1181
|
+
return kept;
|
|
791
1182
|
}
|
|
792
|
-
async
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
1183
|
+
async unfinished() {
|
|
1184
|
+
const queued = await this.d.store.listOccurrences({
|
|
1185
|
+
status: "queued",
|
|
1186
|
+
dueBefore: this.d.clock.now().toISOString()
|
|
1187
|
+
});
|
|
1188
|
+
return queued.filter(isFinishing);
|
|
1189
|
+
}
|
|
1190
|
+
async audience(task) {
|
|
796
1191
|
const owner = await this.d.store.getUser(task.ownerId);
|
|
797
1192
|
if (!owner)
|
|
798
|
-
return
|
|
1193
|
+
return null;
|
|
799
1194
|
const recipients = [];
|
|
800
1195
|
for (const r of await this.d.store.listRecipients(task.id)) {
|
|
801
1196
|
if (r.state !== "accepted")
|
|
@@ -804,7 +1199,16 @@ class Lanes {
|
|
|
804
1199
|
if (user)
|
|
805
1200
|
recipients.push(user);
|
|
806
1201
|
}
|
|
807
|
-
return {
|
|
1202
|
+
return { owner, recipients };
|
|
1203
|
+
}
|
|
1204
|
+
async load(task) {
|
|
1205
|
+
const type = this.d.types[task.type];
|
|
1206
|
+
if (!type)
|
|
1207
|
+
return { error: `no task type ${task.type}` };
|
|
1208
|
+
const people = await this.audience(task);
|
|
1209
|
+
if (!people)
|
|
1210
|
+
return { error: `no owner ${task.ownerId}` };
|
|
1211
|
+
return { task, type, ...people };
|
|
808
1212
|
}
|
|
809
1213
|
async context(loaded, occurrence, now) {
|
|
810
1214
|
const originalDueAt = await this.originalDueAt(occurrence);
|
|
@@ -837,75 +1241,275 @@ class Lanes {
|
|
|
837
1241
|
}
|
|
838
1242
|
return found;
|
|
839
1243
|
}
|
|
840
|
-
async apply(task, outcome, now) {
|
|
1244
|
+
async apply(task, outcome, now, occurrenceId) {
|
|
841
1245
|
const { store } = this.d;
|
|
842
1246
|
if (outcome.state !== undefined) {
|
|
843
1247
|
await store.updateTask(task.id, { state: outcome.state, at: now.toISOString() });
|
|
844
1248
|
}
|
|
845
|
-
for (const point of outcome.series ?? []) {
|
|
1249
|
+
for (const [i, point] of (outcome.series ?? []).entries()) {
|
|
846
1250
|
await store.addSeriesPoint({
|
|
847
1251
|
taskId: task.id,
|
|
848
1252
|
at: point.at ?? now.toISOString(),
|
|
849
1253
|
value: point.value,
|
|
850
1254
|
unit: point.unit ?? null,
|
|
851
|
-
note: point.note ?? null
|
|
1255
|
+
note: point.note ?? null,
|
|
1256
|
+
key: occurrenceId === null ? null : `${occurrenceId}:${i}`
|
|
852
1257
|
});
|
|
853
1258
|
}
|
|
854
1259
|
}
|
|
855
1260
|
async complete(task, actorId, now) {
|
|
856
1261
|
const { store } = this.d;
|
|
857
1262
|
const at = now.toISOString();
|
|
858
|
-
await store.
|
|
1263
|
+
for (const o of await store.listOccurrences({ taskId: task.id, status: "queued" })) {
|
|
1264
|
+
if (!hasFired(o))
|
|
1265
|
+
await store.deleteOccurrence(o.id);
|
|
1266
|
+
}
|
|
859
1267
|
await store.updateTask(task.id, { status: "done", at });
|
|
860
1268
|
await store.addTaskEvent({ taskId: task.id, actorId, kind: "completed", detail: "", at });
|
|
861
1269
|
}
|
|
862
|
-
async runOne(occurrence) {
|
|
1270
|
+
async runOne(occurrence, signal) {
|
|
863
1271
|
const { store, clock } = this.d;
|
|
864
1272
|
const task = await store.getTask(occurrence.taskId);
|
|
865
1273
|
if (!task)
|
|
866
1274
|
return this.fail(occurrence, `no task ${occurrence.taskId}`);
|
|
1275
|
+
if (task.status !== "active")
|
|
1276
|
+
return "held";
|
|
1277
|
+
if (await this.finishing(task.id, occurrence.id))
|
|
1278
|
+
return "held";
|
|
867
1279
|
const loaded = await this.load(task);
|
|
868
1280
|
if ("error" in loaded)
|
|
869
1281
|
return this.fail(occurrence, loaded.error);
|
|
1282
|
+
if (loaded.type.lane === "execute") {
|
|
1283
|
+
const held = await this.budgetCheck(loaded.owner);
|
|
1284
|
+
if (held)
|
|
1285
|
+
return held;
|
|
1286
|
+
}
|
|
870
1287
|
const now = clock.now();
|
|
871
1288
|
const late = isLate(occurrence, now);
|
|
872
|
-
await store.
|
|
1289
|
+
const started = await store.updateOccurrenceIf(occurrence.id, "queued", {
|
|
873
1290
|
status: "running",
|
|
874
1291
|
startedAt: now.toISOString(),
|
|
875
1292
|
late,
|
|
876
1293
|
error: null
|
|
877
1294
|
});
|
|
878
|
-
|
|
1295
|
+
if (!started || hasFired(started))
|
|
1296
|
+
return "held";
|
|
1297
|
+
let record;
|
|
1298
|
+
try {
|
|
1299
|
+
await this.event(occurrence, "status", late ? "started late" : "started");
|
|
1300
|
+
const ctx = await this.context(loaded, started, now);
|
|
1301
|
+
const { outcome, costUsd } = await this.execute(loaded.type, ctx, started);
|
|
1302
|
+
if (signal?.aborted) {
|
|
1303
|
+
await this.requeue(started, "the tick was aborted before the outcome was stored");
|
|
1304
|
+
return "stop";
|
|
1305
|
+
}
|
|
1306
|
+
const { snoozeUntil: _replyOnly, ...kept } = outcome;
|
|
1307
|
+
record = {
|
|
1308
|
+
outcome: kept,
|
|
1309
|
+
costUsd,
|
|
1310
|
+
firedAt: clock.now().toISOString(),
|
|
1311
|
+
appliedAt: null,
|
|
1312
|
+
resumes: 0
|
|
1313
|
+
};
|
|
1314
|
+
await store.updateOccurrence(started.id, { record });
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
if (err instanceof ExecutorUnavailableError || err instanceof UsageLimitReached) {
|
|
1317
|
+
await this.requeue(started, err.message);
|
|
1318
|
+
if (err instanceof UsageLimitReached)
|
|
1319
|
+
await this.usageLimit(err);
|
|
1320
|
+
return "stop";
|
|
1321
|
+
}
|
|
1322
|
+
await this.fail(started, err instanceof Error ? err.message : String(err));
|
|
1323
|
+
await this.next(task, loaded.owner);
|
|
1324
|
+
return "failed";
|
|
1325
|
+
}
|
|
1326
|
+
return this.finishFired({ ...started, record }, task, record, signal);
|
|
1327
|
+
}
|
|
1328
|
+
async requeue(occurrence, why) {
|
|
1329
|
+
await this.event(occurrence, "status", `waiting: ${why}`);
|
|
1330
|
+
await this.d.store.updateOccurrence(occurrence.id, { status: "queued", startedAt: null });
|
|
1331
|
+
}
|
|
1332
|
+
async resume(occurrence, signal) {
|
|
1333
|
+
const { store, clock } = this.d;
|
|
1334
|
+
const started = await store.updateOccurrenceIf(occurrence.id, "queued", {
|
|
1335
|
+
status: "running",
|
|
1336
|
+
startedAt: clock.now().toISOString()
|
|
1337
|
+
});
|
|
1338
|
+
if (!started)
|
|
1339
|
+
return "held";
|
|
1340
|
+
const record = parseRunRecord(started.record);
|
|
1341
|
+
const task = await store.getTask(started.taskId);
|
|
1342
|
+
if (!record || !task) {
|
|
1343
|
+
const error = record ? `no task ${started.taskId}` : "the run's record could not be read";
|
|
1344
|
+
await this.event(started, "error", error);
|
|
1345
|
+
await store.updateOccurrence(started.id, {
|
|
1346
|
+
status: "done",
|
|
1347
|
+
finishedAt: clock.now().toISOString(),
|
|
1348
|
+
error
|
|
1349
|
+
});
|
|
1350
|
+
await dropOwed(store, await store.listDeliveries({ occurrenceId: started.id }), error, clock.now());
|
|
1351
|
+
return "failed";
|
|
1352
|
+
}
|
|
1353
|
+
await this.event(started, "status", "resumed");
|
|
1354
|
+
return this.finishFired(started, task, record, signal);
|
|
1355
|
+
}
|
|
1356
|
+
async finishFired(occurrence, task, record, signal) {
|
|
1357
|
+
const { store, clock } = this.d;
|
|
1358
|
+
const { outcome } = record;
|
|
1359
|
+
let people = null;
|
|
1360
|
+
let current = null;
|
|
879
1361
|
try {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
if (
|
|
884
|
-
await
|
|
1362
|
+
people = await this.audience(task);
|
|
1363
|
+
if (people && !outcome.complete)
|
|
1364
|
+
await this.next(task, people.owner);
|
|
1365
|
+
if (record.appliedAt === null) {
|
|
1366
|
+
await this.apply(task, outcome, new Date(record.firedAt), occurrence.id);
|
|
1367
|
+
const applied = { ...record, appliedAt: clock.now().toISOString() };
|
|
1368
|
+
await store.updateOccurrence(occurrence.id, { record: applied });
|
|
1369
|
+
record = applied;
|
|
1370
|
+
}
|
|
1371
|
+
if (outcome.notify && people) {
|
|
1372
|
+
const at = clock.now().toISOString();
|
|
1373
|
+
const snoozeRun = occurrence.dedupeKey.startsWith(SNOOZE_PREFIX);
|
|
1374
|
+
const to = snoozeRun ? [people.owner] : [people.owner, ...people.recipients];
|
|
1375
|
+
for (const u of to)
|
|
1376
|
+
await store.planDelivery(occurrence.id, u.id, at);
|
|
885
1377
|
}
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
status: snoozed ? "snoozed" : "done",
|
|
1378
|
+
const done = await store.updateOccurrenceIf(occurrence.id, "running", {
|
|
1379
|
+
status: "done",
|
|
889
1380
|
finishedAt: clock.now().toISOString(),
|
|
890
1381
|
summary: outcome.summary ?? null,
|
|
891
|
-
costUsd
|
|
1382
|
+
costUsd: record.costUsd,
|
|
1383
|
+
error: people ? null : `no owner ${task.ownerId}`
|
|
892
1384
|
});
|
|
893
|
-
if (
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1385
|
+
if (!done)
|
|
1386
|
+
return "held";
|
|
1387
|
+
current = await store.getTask(task.id);
|
|
1388
|
+
if (outcome.complete && current && current.status !== "done") {
|
|
1389
|
+
await this.complete(current, null, clock.now());
|
|
1390
|
+
current = await store.getTask(task.id);
|
|
1391
|
+
}
|
|
898
1392
|
} catch (err) {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1393
|
+
return this.putBack(occurrence, record, err);
|
|
1394
|
+
}
|
|
1395
|
+
const sending = current?.status === "active" || current?.status === "done";
|
|
1396
|
+
if (outcome.notify && people && sending) {
|
|
1397
|
+
try {
|
|
1398
|
+
await this.deliverRun(occurrence, task, people, outcome.notify, signal);
|
|
1399
|
+
} catch {}
|
|
1400
|
+
}
|
|
1401
|
+
return "ran";
|
|
1402
|
+
}
|
|
1403
|
+
async putBack(occurrence, record, err) {
|
|
1404
|
+
const { store, clock } = this.d;
|
|
1405
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1406
|
+
const resumes = record.resumes + 1;
|
|
1407
|
+
try {
|
|
1408
|
+
await this.event(occurrence, "error", message);
|
|
1409
|
+
if (resumes > MAX_RESUMES) {
|
|
1410
|
+
await store.updateOccurrenceIf(occurrence.id, "running", {
|
|
1411
|
+
status: "done",
|
|
1412
|
+
finishedAt: clock.now().toISOString(),
|
|
1413
|
+
error: `could not finish after ${MAX_RESUMES} tries: ${message}`
|
|
1414
|
+
});
|
|
1415
|
+
return "failed";
|
|
903
1416
|
}
|
|
904
|
-
await
|
|
905
|
-
|
|
906
|
-
|
|
1417
|
+
await store.updateOccurrenceIf(occurrence.id, "running", {
|
|
1418
|
+
status: "queued",
|
|
1419
|
+
startedAt: null,
|
|
1420
|
+
record: { ...record, resumes }
|
|
1421
|
+
});
|
|
1422
|
+
} catch {}
|
|
1423
|
+
return "held";
|
|
1424
|
+
}
|
|
1425
|
+
async finishing(taskId, except) {
|
|
1426
|
+
for (const status of ["queued", "running"]) {
|
|
1427
|
+
const runs = await this.d.store.listOccurrences({ taskId, status });
|
|
1428
|
+
if (runs.some((o) => o.id !== except && isFinishing(o)))
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
return false;
|
|
1432
|
+
}
|
|
1433
|
+
async deliverRun(occurrence, task, people, message, signal) {
|
|
1434
|
+
const targets = new Map;
|
|
1435
|
+
for (const u of [people.owner, ...people.recipients])
|
|
1436
|
+
targets.set(u.id, u);
|
|
1437
|
+
await deliver(this.d.store, this.d.notifier, occurrence, task.ownerId, targets, message, {
|
|
1438
|
+
now: () => this.d.clock.now(),
|
|
1439
|
+
policy: this.d.delivery ?? DEFAULT_DELIVERY_POLICY,
|
|
1440
|
+
...signal ? { signal } : {}
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
async deliverOwed(signal) {
|
|
1444
|
+
const { store, clock } = this.d;
|
|
1445
|
+
await settleStaleClaims(store, clock.now());
|
|
1446
|
+
const byRun = new Map;
|
|
1447
|
+
for (const row of await store.listDeliveries({ dueBefore: clock.now().toISOString() })) {
|
|
1448
|
+
byRun.set(row.occurrenceId, [...byRun.get(row.occurrenceId) ?? [], row]);
|
|
1449
|
+
}
|
|
1450
|
+
for (const [occurrenceId, rows] of byRun) {
|
|
1451
|
+
if (signal?.aborted)
|
|
1452
|
+
return;
|
|
1453
|
+
try {
|
|
1454
|
+
const occurrence = await store.getOccurrence(occurrenceId);
|
|
1455
|
+
const record = occurrence ? parseRunRecord(occurrence.record) : null;
|
|
1456
|
+
const task = occurrence ? await store.getTask(occurrence.taskId) : null;
|
|
1457
|
+
const message = record?.outcome.notify;
|
|
1458
|
+
const people = task ? await this.audience(task) : null;
|
|
1459
|
+
if (!occurrence || !message || !task || !people || task.status === "archived") {
|
|
1460
|
+
await dropOwed(store, rows, "the run or its task is gone", clock.now());
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (task.status === "paused") {
|
|
1464
|
+
await freshRoundForPause(store, rows);
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
await this.deliverRun(occurrence, task, people, message, signal);
|
|
1468
|
+
} catch {}
|
|
907
1469
|
}
|
|
908
1470
|
}
|
|
1471
|
+
async budgetCheck(owner) {
|
|
1472
|
+
const { store, notifier, clock } = this.d;
|
|
1473
|
+
const now = clock.now();
|
|
1474
|
+
const hold = await budgetHold(store, this.d.budget ?? DEFAULT_BUDGET, owner, now);
|
|
1475
|
+
if (!hold)
|
|
1476
|
+
return null;
|
|
1477
|
+
if (hold.scope === "global") {
|
|
1478
|
+
await noticeOnce(store, notifier, `budget:global:${hold.day}`, now, budgetAdminMessage(hold, null));
|
|
1479
|
+
return "stop";
|
|
1480
|
+
}
|
|
1481
|
+
await noticeOnce(store, notifier, `budget:person:${owner.id}:${hold.day}`, now, budgetAdminMessage(hold, owner), { user: owner, message: budgetPersonMessage(hold) });
|
|
1482
|
+
return "held";
|
|
1483
|
+
}
|
|
1484
|
+
async usageLimit(err) {
|
|
1485
|
+
const { store, notifier, clock } = this.d;
|
|
1486
|
+
this.executeAfter = err.until;
|
|
1487
|
+
const key = `usage-limit:${err.window}`;
|
|
1488
|
+
await noticeOnce(store, notifier, key, clock.now(), usageLimitAdminMessage(err.parsed ? new Date(err.window) : err.until, err.parsed, err.until));
|
|
1489
|
+
}
|
|
1490
|
+
async chargeRun(occurrence, task, result) {
|
|
1491
|
+
if (result.kind === "usage_limit") {
|
|
1492
|
+
const now = this.d.clock.now();
|
|
1493
|
+
const reset = result.resetsAt ? new Date(result.resetsAt) : null;
|
|
1494
|
+
if (reset && !Number.isNaN(reset.getTime()) && reset > now) {
|
|
1495
|
+
const until = new Date(Math.min(reset.getTime(), now.getTime() + MAX_USAGE_LIMIT_WAIT_MS));
|
|
1496
|
+
throw new UsageLimitReached(until, true, reset.toISOString());
|
|
1497
|
+
}
|
|
1498
|
+
const window = Math.floor(now.getTime() / USAGE_WINDOW_MS);
|
|
1499
|
+
throw new UsageLimitReached(new Date(now.getTime() + USAGE_LIMIT_BACKOFF_MS), false, `unknown:${window}`);
|
|
1500
|
+
}
|
|
1501
|
+
if (result.kind === "auth_failed")
|
|
1502
|
+
return;
|
|
1503
|
+
await charge(this.d.store, {
|
|
1504
|
+
userId: task.ownerId,
|
|
1505
|
+
taskId: task.id,
|
|
1506
|
+
occurrenceId: occurrence.id,
|
|
1507
|
+
source: "run",
|
|
1508
|
+
calls: 1,
|
|
1509
|
+
costUsd: result.totalCostUsd ?? null,
|
|
1510
|
+
at: this.d.clock.now()
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
909
1513
|
async next(task, owner) {
|
|
910
1514
|
const current = await this.d.store.getTask(task.id);
|
|
911
1515
|
if (current)
|
|
@@ -923,12 +1527,17 @@ class Lanes {
|
|
|
923
1527
|
throw new ExecutorUnavailableError("no executor configured");
|
|
924
1528
|
const spec = await type.prepare(ctx);
|
|
925
1529
|
const result = await this.d.executor.run(spec, occurrence.id);
|
|
1530
|
+
await this.chargeRun(occurrence, ctx.task, result);
|
|
926
1531
|
const outcome = await type.finish(ctx, result);
|
|
927
1532
|
return { outcome, costUsd: result.totalCostUsd ?? null };
|
|
928
1533
|
}
|
|
929
1534
|
async snooze(task, occurrence, until, reply, now) {
|
|
930
1535
|
const { store } = this.d;
|
|
931
|
-
await store.
|
|
1536
|
+
const current = await store.getOccurrence(occurrence.id);
|
|
1537
|
+
if (!current || current.status !== "done" && current.status !== "failed")
|
|
1538
|
+
return;
|
|
1539
|
+
if (!await store.updateOccurrenceIf(current.id, current.status, { status: "snoozed" }))
|
|
1540
|
+
return;
|
|
932
1541
|
await this.event(occurrence, "status", `snoozed until ${until.toISOString()} by ${reply.userId}`);
|
|
933
1542
|
await store.createOccurrence({
|
|
934
1543
|
taskId: task.id,
|
|
@@ -940,13 +1549,12 @@ class Lanes {
|
|
|
940
1549
|
}
|
|
941
1550
|
async fail(occurrence, message) {
|
|
942
1551
|
await this.event(occurrence, "error", message);
|
|
943
|
-
const snoozed = (await this.d.store.getOccurrence(occurrence.id))?.status === "snoozed";
|
|
944
1552
|
await this.d.store.updateOccurrence(occurrence.id, {
|
|
945
|
-
status:
|
|
1553
|
+
status: "failed",
|
|
946
1554
|
finishedAt: this.d.clock.now().toISOString(),
|
|
947
1555
|
error: message
|
|
948
1556
|
});
|
|
949
|
-
return
|
|
1557
|
+
return "failed";
|
|
950
1558
|
}
|
|
951
1559
|
async event(occurrence, type, text) {
|
|
952
1560
|
await this.d.store.addEvent({
|
|
@@ -961,6 +1569,16 @@ class Lanes {
|
|
|
961
1569
|
|
|
962
1570
|
// node_modules/@rackbops/docket-core/dist/messages.js
|
|
963
1571
|
var ADMIN_DISCLOSURE = "An admin of this tracker can see every task and its history, including yours.";
|
|
1572
|
+
function registrationText(user, options) {
|
|
1573
|
+
const hour = String(user.preferredHour).padStart(2, "0");
|
|
1574
|
+
return [
|
|
1575
|
+
options.first ? "You are registered." : "Your settings are updated.",
|
|
1576
|
+
`Reminders reach you by DM. One without a time of day arrives at ${hour}:00, ${user.timeZone} time.`,
|
|
1577
|
+
`Your tasks are private to you and anyone you choose to share them with. ${ADMIN_DISCLOSURE}`,
|
|
1578
|
+
...options.notes ?? []
|
|
1579
|
+
].join(`
|
|
1580
|
+
`);
|
|
1581
|
+
}
|
|
964
1582
|
function name(user) {
|
|
965
1583
|
return user.displayName ?? "Someone";
|
|
966
1584
|
}
|
|
@@ -982,7 +1600,7 @@ async function taskList(store, actor) {
|
|
|
982
1600
|
const tasks = await visibleTasks(store, { ...actor, admin: false }, { status: "active" });
|
|
983
1601
|
const entries = [];
|
|
984
1602
|
for (const task of tasks) {
|
|
985
|
-
const queued = await store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
1603
|
+
const queued = (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !hasFired(o));
|
|
986
1604
|
const from = task.ownerId === actor.userId ? null : await store.getUser(task.ownerId);
|
|
987
1605
|
entries.push({ task, next: queued[0] ?? null, from });
|
|
988
1606
|
}
|
|
@@ -1841,13 +2459,18 @@ function decideAccess(input) {
|
|
|
1841
2459
|
return NOT_REGISTERED;
|
|
1842
2460
|
return null;
|
|
1843
2461
|
}
|
|
1844
|
-
function
|
|
2462
|
+
function parseGuildIds(raw) {
|
|
1845
2463
|
if (raw === undefined || raw.trim() === "")
|
|
1846
2464
|
return null;
|
|
1847
|
-
const
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
2465
|
+
const ids = [];
|
|
2466
|
+
for (const entry of raw.split(",")) {
|
|
2467
|
+
const id = entry.trim();
|
|
2468
|
+
if (!/^[0-9]{17,20}$/.test(id))
|
|
2469
|
+
throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
|
|
2470
|
+
if (!ids.includes(id))
|
|
2471
|
+
ids.push(id);
|
|
2472
|
+
}
|
|
2473
|
+
return ids;
|
|
1851
2474
|
}
|
|
1852
2475
|
|
|
1853
2476
|
// plugins/tracker/src/admissions.ts
|
|
@@ -1874,53 +2497,6 @@ class Admissions {
|
|
|
1874
2497
|
}
|
|
1875
2498
|
}
|
|
1876
2499
|
|
|
1877
|
-
// plugins/tracker/src/claims.ts
|
|
1878
|
-
function toClaim(r) {
|
|
1879
|
-
const s = (v) => v === null || v === undefined ? null : String(v);
|
|
1880
|
-
return {
|
|
1881
|
-
occurrenceId: String(r.occurrence_id),
|
|
1882
|
-
userId: String(r.user_id),
|
|
1883
|
-
discordId: String(r.discord_id),
|
|
1884
|
-
status: String(r.status),
|
|
1885
|
-
messageId: s(r.message_id),
|
|
1886
|
-
channelId: s(r.channel_id),
|
|
1887
|
-
error: s(r.error),
|
|
1888
|
-
claimedAt: String(r.claimed_at),
|
|
1889
|
-
settledAt: s(r.settled_at),
|
|
1890
|
-
reportedAt: s(r.reported_at)
|
|
1891
|
-
};
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
class ClaimStore {
|
|
1895
|
-
db;
|
|
1896
|
-
constructor(db) {
|
|
1897
|
-
this.db = db;
|
|
1898
|
-
}
|
|
1899
|
-
get(occurrenceId, userId) {
|
|
1900
|
-
const r = this.db.query("SELECT * FROM delivery_claims WHERE occurrence_id = ? AND user_id = ?").get(occurrenceId, userId);
|
|
1901
|
-
return r ? toClaim(r) : null;
|
|
1902
|
-
}
|
|
1903
|
-
claim(occurrenceId, userId, discordId, at) {
|
|
1904
|
-
const { changes } = this.db.query(`INSERT INTO delivery_claims (occurrence_id, user_id, discord_id, status, claimed_at)
|
|
1905
|
-
VALUES (?, ?, ?, 'claimed', ?)
|
|
1906
|
-
ON CONFLICT (occurrence_id, user_id) DO UPDATE SET
|
|
1907
|
-
discord_id = excluded.discord_id, status = 'claimed', claimed_at = excluded.claimed_at,
|
|
1908
|
-
message_id = NULL, channel_id = NULL, error = NULL, settled_at = NULL, reported_at = NULL
|
|
1909
|
-
WHERE delivery_claims.status = 'failed'`).run(occurrenceId, userId, discordId, at);
|
|
1910
|
-
return changes === 1;
|
|
1911
|
-
}
|
|
1912
|
-
settle(occurrenceId, userId, status, at, detail = {}) {
|
|
1913
|
-
this.db.query(`UPDATE delivery_claims SET status = ?, message_id = ?, channel_id = ?, error = ?, settled_at = ?
|
|
1914
|
-
WHERE occurrence_id = ? AND user_id = ?`).run(status, detail.messageId ?? null, detail.channelId ?? null, detail.error ?? null, at, occurrenceId, userId);
|
|
1915
|
-
}
|
|
1916
|
-
markReported(occurrenceId, userId, at) {
|
|
1917
|
-
this.db.query("UPDATE delivery_claims SET reported_at = ? WHERE occurrence_id = ? AND user_id = ?").run(at, occurrenceId, userId);
|
|
1918
|
-
}
|
|
1919
|
-
listUnsettled() {
|
|
1920
|
-
return this.db.query("SELECT * FROM delivery_claims WHERE status IN ('claimed', 'unconfirmed') ORDER BY claimed_at").all().map(toClaim);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
2500
|
// plugins/tracker/src/delivery-health.ts
|
|
1925
2501
|
var PAUSE_AFTER = 3;
|
|
1926
2502
|
var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
|
|
@@ -2372,13 +2948,22 @@ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
|
|
|
2372
2948
|
var NO_MENTIONS = { allowedMentions: { parse: [] } };
|
|
2373
2949
|
var UNKNOWN_MEMBER = 10007;
|
|
2374
2950
|
var UNKNOWN_USER = 10013;
|
|
2375
|
-
async function lookupMembership(interaction,
|
|
2376
|
-
if (
|
|
2951
|
+
async function lookupMembership(interaction, guildIds, discordId, log) {
|
|
2952
|
+
if (guildIds === null)
|
|
2377
2953
|
return "not-checked";
|
|
2378
|
-
if (interaction.guildId
|
|
2954
|
+
if (interaction.guildId !== null && guildIds.includes(interaction.guildId) && interaction.user.id === discordId)
|
|
2379
2955
|
return "member";
|
|
2956
|
+
const lookups = guildIds.map((guildId) => memberOf(interaction.client, guildId, discordId, log));
|
|
2957
|
+
const firstYes = new Promise((resolve) => {
|
|
2958
|
+
for (const l of lookups)
|
|
2959
|
+
l.then((a) => a === "member" && resolve("member"));
|
|
2960
|
+
});
|
|
2961
|
+
const all = Promise.all(lookups).then((answers) => answers.includes("unknown") ? "unknown" : answers.includes("member") ? "member" : "not-member");
|
|
2962
|
+
return Promise.race([firstYes, all]);
|
|
2963
|
+
}
|
|
2964
|
+
async function memberOf(client, guildId, discordId, log) {
|
|
2380
2965
|
try {
|
|
2381
|
-
const guild = await
|
|
2966
|
+
const guild = await client.guilds.fetch(guildId);
|
|
2382
2967
|
await guild.members.fetch({ user: discordId, force: true });
|
|
2383
2968
|
return "member";
|
|
2384
2969
|
} catch (err) {
|
|
@@ -2504,7 +3089,7 @@ function retimed(s, day, owner) {
|
|
|
2504
3089
|
return null;
|
|
2505
3090
|
}
|
|
2506
3091
|
async function heldRuns(store, task) {
|
|
2507
|
-
return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
3092
|
+
return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
|
|
2508
3093
|
}
|
|
2509
3094
|
async function restoreHeldRun(store, task, held, before, after, now) {
|
|
2510
3095
|
const s = task.schedule;
|
|
@@ -2549,13 +3134,10 @@ async function allowPerson(d, admin, target) {
|
|
|
2549
3134
|
return `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
|
|
2550
3135
|
}
|
|
2551
3136
|
function registeredText(user, first) {
|
|
2552
|
-
return
|
|
2553
|
-
first
|
|
2554
|
-
`
|
|
2555
|
-
|
|
2556
|
-
`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.`
|
|
2557
|
-
].join(`
|
|
2558
|
-
`);
|
|
3137
|
+
return registrationText(user, {
|
|
3138
|
+
first,
|
|
3139
|
+
notes: [`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.`]
|
|
3140
|
+
});
|
|
2559
3141
|
}
|
|
2560
3142
|
async function registerPerson(d, user, input) {
|
|
2561
3143
|
let updated;
|
|
@@ -2615,21 +3197,21 @@ async function rescheduleOwned(d, owner, zoneChanged, before) {
|
|
|
2615
3197
|
continue;
|
|
2616
3198
|
if (!zoneChanged && s.hour !== undefined)
|
|
2617
3199
|
continue;
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
3200
|
+
await d.locks.run(task.id, async () => {
|
|
3201
|
+
const current = await d.store.getTask(task.id);
|
|
3202
|
+
if (!current || current.status !== "active" && current.status !== "paused")
|
|
3203
|
+
return;
|
|
3204
|
+
const held = current.status === "paused" ? await heldRuns(d.store, current) : [];
|
|
3205
|
+
const updated = await rescheduleKeepingSnoozes(d, current, owner, s, owner.id);
|
|
3206
|
+
if (held.length > 0)
|
|
3207
|
+
await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
|
|
3208
|
+
});
|
|
2622
3209
|
moved++;
|
|
2623
3210
|
}
|
|
2624
3211
|
return moved;
|
|
2625
3212
|
}
|
|
2626
3213
|
async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
|
|
2627
|
-
const
|
|
2628
|
-
const now = d.clock.now();
|
|
2629
|
-
const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
|
|
2630
|
-
for (const o of snoozes) {
|
|
2631
|
-
await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
|
|
2632
|
-
}
|
|
3214
|
+
const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, d.clock.now());
|
|
2633
3215
|
return updated;
|
|
2634
3216
|
}
|
|
2635
3217
|
async function liveTaskCap(d, owner) {
|
|
@@ -2673,14 +3255,24 @@ async function ownTask(d, user, taskId) {
|
|
|
2673
3255
|
const task = await visibleTask(d.store, { userId: user.id, admin: false }, taskId.trim());
|
|
2674
3256
|
return task && task.ownerId === user.id ? task : null;
|
|
2675
3257
|
}
|
|
3258
|
+
function answerable(o) {
|
|
3259
|
+
if (o.status === "done" || o.status === "failed")
|
|
3260
|
+
return true;
|
|
3261
|
+
return (o.status === "running" || o.status === "queued") && hasFired(o);
|
|
3262
|
+
}
|
|
3263
|
+
async function latestRun(d, taskId) {
|
|
3264
|
+
return (await d.store.listOccurrences({ taskId })).filter(answerable).at(-1) ?? null;
|
|
3265
|
+
}
|
|
2676
3266
|
async function answerLatest(d, user, input) {
|
|
2677
3267
|
if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
|
|
2678
3268
|
return `\`until\` is longer than ${MAX_WHEN} characters.`;
|
|
2679
3269
|
const task = await ownTask(d, user, input.taskId);
|
|
2680
3270
|
if (!task)
|
|
2681
3271
|
return NO_SUCH_TASK;
|
|
2682
|
-
|
|
2683
|
-
|
|
3272
|
+
return d.locks.run(task.id, () => answerLatestLocked(d, user, task, input));
|
|
3273
|
+
}
|
|
3274
|
+
async function answerLatestLocked(d, user, task, input) {
|
|
3275
|
+
const latest = await latestRun(d, task.id);
|
|
2684
3276
|
if (!latest)
|
|
2685
3277
|
return "That task has not reminded you yet, so there is nothing to answer.";
|
|
2686
3278
|
let payload = null;
|
|
@@ -2716,7 +3308,13 @@ function editable(task) {
|
|
|
2716
3308
|
return task.status === "active" || task.status === "paused";
|
|
2717
3309
|
}
|
|
2718
3310
|
var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
|
|
2719
|
-
|
|
3311
|
+
function locked(d, taskId, act) {
|
|
3312
|
+
return d.locks.run(taskId.trim(), act);
|
|
3313
|
+
}
|
|
3314
|
+
function pauseTask(d, user, taskId) {
|
|
3315
|
+
return locked(d, taskId, () => pauseLocked(d, user, taskId));
|
|
3316
|
+
}
|
|
3317
|
+
async function pauseLocked(d, user, taskId) {
|
|
2720
3318
|
const task = await ownLiveTask(d, user, taskId);
|
|
2721
3319
|
if (!task)
|
|
2722
3320
|
return { ok: false, error: NO_SUCH_TASK };
|
|
@@ -2727,7 +3325,10 @@ async function pauseTask(d, user, taskId) {
|
|
|
2727
3325
|
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
|
|
2728
3326
|
return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
|
|
2729
3327
|
}
|
|
2730
|
-
|
|
3328
|
+
function resumeTask(d, user, taskId) {
|
|
3329
|
+
return locked(d, taskId, () => resumeLocked(d, user, taskId));
|
|
3330
|
+
}
|
|
3331
|
+
async function resumeLocked(d, user, taskId) {
|
|
2731
3332
|
const task = await ownLiveTask(d, user, taskId);
|
|
2732
3333
|
if (!task)
|
|
2733
3334
|
return { ok: false, error: NO_SUCH_TASK };
|
|
@@ -2753,12 +3354,18 @@ async function resumeTask(d, user, taskId) {
|
|
|
2753
3354
|
const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
|
|
2754
3355
|
return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
|
|
2755
3356
|
}
|
|
2756
|
-
|
|
3357
|
+
function deleteTask(d, user, taskId) {
|
|
3358
|
+
return locked(d, taskId, () => deleteLocked(d, user, taskId));
|
|
3359
|
+
}
|
|
3360
|
+
async function deleteLocked(d, user, taskId) {
|
|
2757
3361
|
const task = await ownLiveTask(d, user, taskId);
|
|
2758
3362
|
if (!task)
|
|
2759
3363
|
return { ok: false, error: NO_SUCH_TASK };
|
|
2760
3364
|
const at = d.clock.now().toISOString();
|
|
2761
|
-
await d.store.
|
|
3365
|
+
for (const o of await d.store.listOccurrences({ taskId: task.id, status: "queued" })) {
|
|
3366
|
+
if (!hasFired(o))
|
|
3367
|
+
await d.store.deleteOccurrence(o.id);
|
|
3368
|
+
}
|
|
2762
3369
|
await d.store.updateTask(task.id, { status: "archived", at });
|
|
2763
3370
|
await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
|
|
2764
3371
|
return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
|
|
@@ -2893,30 +3500,31 @@ function hostButtons(message) {
|
|
|
2893
3500
|
// plugins/tracker/src/notifier.ts
|
|
2894
3501
|
var HOST_CANNOT_MESSAGE = "recipient cannot be messaged";
|
|
2895
3502
|
var MAX_CONTENT = 2000;
|
|
2896
|
-
var UNCONFIRMED_MESSAGE_ID = "unconfirmed";
|
|
2897
3503
|
var HOST_SNOWFLAKE = /^[0-9]{5,25}$/;
|
|
2898
3504
|
var URL_OR_WRAPPED = /<(https?:\/\/[^\s<>]+)>|(https?:\/\/[^\s<>]+)/g;
|
|
2899
3505
|
function wrapBareUrls(content) {
|
|
2900
3506
|
return content.replace(URL_OR_WRAPPED, (whole, wrapped, bare) => wrapped !== undefined ? whole : `<${bare}>`);
|
|
2901
3507
|
}
|
|
2902
|
-
|
|
2903
|
-
|
|
3508
|
+
var UNKNOWN_USER2 = 10013;
|
|
3509
|
+
function isUnknownUser(err) {
|
|
3510
|
+
if (typeof err === "object" && err !== null && err.code === UNKNOWN_USER2)
|
|
2904
3511
|
return true;
|
|
3512
|
+
return (err instanceof Error ? err.message : String(err)) === "Unknown User";
|
|
3513
|
+
}
|
|
3514
|
+
function isHostRefusal(err) {
|
|
2905
3515
|
const message = err instanceof Error ? err.message : String(err);
|
|
2906
|
-
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
|
|
3516
|
+
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.startsWith("content is longer than") || isButtonRefusal(err);
|
|
2907
3517
|
}
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
}
|
|
3518
|
+
function deliveryError(err) {
|
|
3519
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3520
|
+
if (reason === HOST_CANNOT_MESSAGE || isUnknownUser(err))
|
|
3521
|
+
return new DeliveryFailedError(reason, true);
|
|
3522
|
+
if (isHostRefusal(err))
|
|
3523
|
+
return new DeliveryFailedError(reason);
|
|
3524
|
+
return err;
|
|
2916
3525
|
}
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
name = "UndeliverableError";
|
|
3526
|
+
function isUnreachable(err) {
|
|
3527
|
+
return err instanceof DeliveryFailedError && err.unreachable;
|
|
2920
3528
|
}
|
|
2921
3529
|
var HOST_BUTTON_REFUSAL = /^(interactive buttons are not supported|buttons? (label|customId|customIds|style|must|is not valid|and links need|need this plugin))/;
|
|
2922
3530
|
function isButtonRefusal(err) {
|
|
@@ -2940,39 +3548,18 @@ function createDmNotifier(d) {
|
|
|
2940
3548
|
async sendDm(userId, message) {
|
|
2941
3549
|
const user = await d.store.getUser(userId);
|
|
2942
3550
|
if (!user)
|
|
2943
|
-
throw new
|
|
3551
|
+
throw new DeliveryFailedError(`no user ${userId}`, true);
|
|
2944
3552
|
const discordId = user.discordId;
|
|
2945
3553
|
if (!discordId || !HOST_SNOWFLAKE.test(discordId))
|
|
2946
|
-
throw new
|
|
3554
|
+
throw new DeliveryFailedError(`user ${userId} has no valid Discord id`, true);
|
|
2947
3555
|
const hostMessage = toHostMessage(message);
|
|
2948
3556
|
if (!hostMessage)
|
|
2949
|
-
throw new
|
|
2950
|
-
const occurrenceId = message.ref?.occurrenceId ?? null;
|
|
2951
|
-
if (occurrenceId !== null) {
|
|
2952
|
-
const existing = d.claims.get(occurrenceId, userId);
|
|
2953
|
-
if (existing?.status === "sent")
|
|
2954
|
-
return { messageId: existing.messageId || UNCONFIRMED_MESSAGE_ID };
|
|
2955
|
-
if (existing && existing.status !== "failed") {
|
|
2956
|
-
if (existing.status === "claimed") {
|
|
2957
|
-
d.claims.settle(occurrenceId, userId, "unconfirmed", d.clock.now().toISOString(), { error: "claimed, never settled" });
|
|
2958
|
-
}
|
|
2959
|
-
reportUnconfirmed(d.claims, d.log, existing, d.clock.now().toISOString());
|
|
2960
|
-
return { messageId: UNCONFIRMED_MESSAGE_ID };
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
3557
|
+
throw new DeliveryFailedError("the message has no text");
|
|
2963
3558
|
if (d.health?.isPaused(userId))
|
|
2964
3559
|
throw new ExecutorUnavailableError(`delivery to ${userId} is paused`);
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
if (occurrenceId !== null) {
|
|
2968
|
-
const at = d.clock.now().toISOString();
|
|
2969
|
-
if (!d.claims.claim(occurrenceId, userId, discordId, at)) {
|
|
2970
|
-
return { messageId: UNCONFIRMED_MESSAGE_ID };
|
|
2971
|
-
}
|
|
2972
|
-
}
|
|
3560
|
+
const buttons = hostButtons(message);
|
|
3561
|
+
let delivery;
|
|
2973
3562
|
try {
|
|
2974
|
-
const buttons = hostButtons(message);
|
|
2975
|
-
let delivery;
|
|
2976
3563
|
try {
|
|
2977
3564
|
delivery = await d.dm(discordId, buttons.length > 0 ? { ...hostMessage, buttons } : hostMessage);
|
|
2978
3565
|
} catch (err) {
|
|
@@ -2981,39 +3568,28 @@ function createDmNotifier(d) {
|
|
|
2981
3568
|
d.log.warn(`DM sent without buttons: the host refused them (${err instanceof Error ? err.message : String(err)})`);
|
|
2982
3569
|
delivery = await d.dm(discordId, hostMessage);
|
|
2983
3570
|
}
|
|
2984
|
-
d.health?.recordSuccess(userId);
|
|
2985
|
-
if (occurrenceId !== null) {
|
|
2986
|
-
d.claims.settle(occurrenceId, userId, "sent", d.clock.now().toISOString(), {
|
|
2987
|
-
messageId: delivery.messageId,
|
|
2988
|
-
channelId: delivery.channelId
|
|
2989
|
-
});
|
|
2990
|
-
}
|
|
2991
|
-
return { messageId: delivery.messageId };
|
|
2992
3571
|
} catch (err) {
|
|
3572
|
+
const mapped = deliveryError(err);
|
|
2993
3573
|
const reason = err instanceof Error ? err.message : String(err);
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3574
|
+
if (reason === HOST_CANNOT_MESSAGE || isUnknownUser(err)) {
|
|
3575
|
+
try {
|
|
3576
|
+
const counted = await d.health?.recordFailure(userId, reason, d.clock.now().toISOString());
|
|
3577
|
+
if (counted?.paused)
|
|
3578
|
+
d.log.warn(`paused delivery to ${userId}: ${counted.failures} DMs in a row could not be delivered`);
|
|
3579
|
+
} catch (countErr) {
|
|
3580
|
+
d.log.error(`could not count a failed DM to ${userId}`, countErr);
|
|
3581
|
+
}
|
|
3582
|
+
} else if (!(mapped instanceof DeliveryFailedError)) {
|
|
3583
|
+
const what = message.ref?.occurrenceId ? `delivery of ${message.ref.occurrenceId}` : "a DM";
|
|
3584
|
+
d.log.warn(`${what} to ${userId} is unconfirmed (${reason}); it will not be resent`);
|
|
3004
3585
|
}
|
|
3005
|
-
throw
|
|
3586
|
+
throw mapped;
|
|
3006
3587
|
}
|
|
3588
|
+
d.health?.recordSuccess(userId);
|
|
3589
|
+
return { messageId: delivery.messageId };
|
|
3007
3590
|
}
|
|
3008
3591
|
};
|
|
3009
3592
|
}
|
|
3010
|
-
function reportUnconfirmed(claims, log, claim, at) {
|
|
3011
|
-
if (claim.reportedAt !== null)
|
|
3012
|
-
return;
|
|
3013
|
-
const what = claim.status === "claimed" ? `claimed ${claim.claimedAt}, never settled` : `unconfirmed since ${claim.claimedAt} (${claim.error ?? "no detail"})`;
|
|
3014
|
-
log.warn(`delivery of ${claim.occurrenceId} to ${claim.userId} was ${what}; it will not be resent`);
|
|
3015
|
-
claims.markReported(claim.occurrenceId, claim.userId, at);
|
|
3016
|
-
}
|
|
3017
3593
|
|
|
3018
3594
|
// plugins/tracker/src/press.ts
|
|
3019
3595
|
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.";
|
|
@@ -3059,8 +3635,9 @@ async function finishShare(d, p, err) {
|
|
|
3059
3635
|
detail: `${p.targetId}: the invitation could not be delivered`,
|
|
3060
3636
|
at: d.clock.now().toISOString()
|
|
3061
3637
|
});
|
|
3062
|
-
if (err
|
|
3063
|
-
|
|
3638
|
+
if (isUnreachable(err)) {
|
|
3639
|
+
const why = err instanceof Error && err.message === HOST_CANNOT_MESSAGE ? "their DMs are closed, or they blocked the bot" : "I can't reach them on Discord";
|
|
3640
|
+
return `I could not DM <@${p.targetDiscordId}> (${why}), so the invitation is withdrawn.`;
|
|
3064
3641
|
}
|
|
3065
3642
|
d.log.error(`invitation DM for ${p.taskId} to ${p.targetId} failed`, err);
|
|
3066
3643
|
return "The invitation could not be sent; try again in a minute.";
|
|
@@ -3070,7 +3647,19 @@ var CONSENT_TEXT = {
|
|
|
3070
3647
|
decline: "Declined. You will not get this task's results.",
|
|
3071
3648
|
opt_out: "You will not get this task's messages any more."
|
|
3072
3649
|
};
|
|
3650
|
+
async function taskOfRef(d, ref) {
|
|
3651
|
+
const decoded = decodeReplyRef(ref);
|
|
3652
|
+
if (!decoded)
|
|
3653
|
+
return null;
|
|
3654
|
+
if (decoded.scope === "t")
|
|
3655
|
+
return decoded.id;
|
|
3656
|
+
return (await d.store.getOccurrence(decoded.id))?.taskId ?? null;
|
|
3657
|
+
}
|
|
3073
3658
|
async function press(d, user, ref) {
|
|
3659
|
+
const taskId = await taskOfRef(d, ref);
|
|
3660
|
+
return taskId === null ? pressLocked(d, user, ref) : d.locks.run(taskId, () => pressLocked(d, user, ref));
|
|
3661
|
+
}
|
|
3662
|
+
async function pressLocked(d, user, ref) {
|
|
3074
3663
|
const verdict = await replyForRef(d.store, ref, user.id);
|
|
3075
3664
|
if (!verdict.ok)
|
|
3076
3665
|
return { ok: false, error: verdict.error };
|
|
@@ -3101,6 +3690,11 @@ async function replyRefusal(d, user, occurrenceId) {
|
|
|
3101
3690
|
return mine?.state === "accepted" ? null : "That is not yours to reply to.";
|
|
3102
3691
|
}
|
|
3103
3692
|
async function submitReply(d, user, occurrenceId, text) {
|
|
3693
|
+
const taskId = (await d.store.getOccurrence(occurrenceId))?.taskId;
|
|
3694
|
+
const act = () => submitReplyLocked(d, user, occurrenceId, text);
|
|
3695
|
+
return taskId === undefined ? act() : d.locks.run(taskId, act);
|
|
3696
|
+
}
|
|
3697
|
+
async function submitReplyLocked(d, user, occurrenceId, text) {
|
|
3104
3698
|
const refusal = await replyRefusal(d, user, occurrenceId);
|
|
3105
3699
|
if (refusal)
|
|
3106
3700
|
return refusal;
|
|
@@ -3598,18 +4192,22 @@ async function decideRenewal(d, user, input) {
|
|
|
3598
4192
|
return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
|
|
3599
4193
|
if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
|
|
3600
4194
|
return "The amount has to be zero or more.";
|
|
3601
|
-
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
|
|
3602
|
-
const latest = fired.at(-1);
|
|
3603
|
-
if (!latest)
|
|
3604
|
-
return "That renewal has no ask waiting for an answer yet.";
|
|
3605
4195
|
const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
|
|
3606
|
-
|
|
3607
|
-
await
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
4196
|
+
const refused = await d.locks.run(task.id, async () => {
|
|
4197
|
+
const latest = await latestRun(d, task.id);
|
|
4198
|
+
if (!latest)
|
|
4199
|
+
return "That renewal has no ask waiting for an answer yet.";
|
|
4200
|
+
try {
|
|
4201
|
+
await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
|
|
4202
|
+
return null;
|
|
4203
|
+
} catch (err) {
|
|
4204
|
+
if (err instanceof ReplyRefusedError)
|
|
4205
|
+
return err.message;
|
|
4206
|
+
throw err;
|
|
4207
|
+
}
|
|
4208
|
+
});
|
|
4209
|
+
if (refused !== null)
|
|
4210
|
+
return refused;
|
|
3613
4211
|
if (input.choice === "cancel")
|
|
3614
4212
|
return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
|
|
3615
4213
|
const currency = task.config.currency ?? "";
|
|
@@ -3687,7 +4285,7 @@ function createSurface(w) {
|
|
|
3687
4285
|
const ctx = {
|
|
3688
4286
|
deps: w.deps,
|
|
3689
4287
|
queue: w.queue ?? serial(),
|
|
3690
|
-
membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.
|
|
4288
|
+
membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildIds, discordId, w.log),
|
|
3691
4289
|
log: w.log
|
|
3692
4290
|
};
|
|
3693
4291
|
const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
|
|
@@ -3879,6 +4477,29 @@ function healthResponse(health) {
|
|
|
3879
4477
|
});
|
|
3880
4478
|
}
|
|
3881
4479
|
|
|
4480
|
+
// plugins/tracker/src/locks.ts
|
|
4481
|
+
class TaskLocks {
|
|
4482
|
+
tails = new Map;
|
|
4483
|
+
async run(taskId, fn) {
|
|
4484
|
+
const before = this.tails.get(taskId) ?? Promise.resolve();
|
|
4485
|
+
let release = () => {};
|
|
4486
|
+
const mine = new Promise((resolve) => release = resolve);
|
|
4487
|
+
const tail = before.then(() => mine);
|
|
4488
|
+
this.tails.set(taskId, tail);
|
|
4489
|
+
try {
|
|
4490
|
+
await before;
|
|
4491
|
+
return await fn();
|
|
4492
|
+
} finally {
|
|
4493
|
+
release();
|
|
4494
|
+
if (this.tails.get(taskId) === tail)
|
|
4495
|
+
this.tails.delete(taskId);
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
held(taskId) {
|
|
4499
|
+
return this.tails.has(taskId);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
|
|
3882
4503
|
// plugins/tracker/src/notify-lane.ts
|
|
3883
4504
|
var FETCH_TYPES = new Set(["price"]);
|
|
3884
4505
|
function takesType(kind, type) {
|
|
@@ -3901,17 +4522,41 @@ function untilAborted(type, signal) {
|
|
|
3901
4522
|
}
|
|
3902
4523
|
};
|
|
3903
4524
|
}
|
|
3904
|
-
function
|
|
4525
|
+
async function tasksWithWork(store, now, keep) {
|
|
4526
|
+
const at = now.toISOString();
|
|
4527
|
+
const ids = [];
|
|
4528
|
+
const add = (id) => void (ids.includes(id) || ids.push(id));
|
|
4529
|
+
for (const o of await store.listOccurrences({ status: "queued", dueBefore: at }))
|
|
4530
|
+
add(o.taskId);
|
|
4531
|
+
for (const o of await store.listOccurrences({ status: "running" }))
|
|
4532
|
+
add(o.taskId);
|
|
4533
|
+
const rows = [...await store.listDeliveries({ dueBefore: at }), ...await store.listDeliveries({ status: "claimed" })];
|
|
4534
|
+
for (const row of rows) {
|
|
4535
|
+
const o = await store.getOccurrence(row.occurrenceId);
|
|
4536
|
+
if (o)
|
|
4537
|
+
add(o.taskId);
|
|
4538
|
+
}
|
|
4539
|
+
const kept = [];
|
|
4540
|
+
for (const id of ids) {
|
|
4541
|
+
if (keep(await store.getTask(id)))
|
|
4542
|
+
kept.push(id);
|
|
4543
|
+
}
|
|
4544
|
+
return kept;
|
|
4545
|
+
}
|
|
4546
|
+
function taskStore(store, taskId) {
|
|
3905
4547
|
const view = Object.create(store);
|
|
3906
|
-
view.listOccurrences =
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
4548
|
+
view.listOccurrences = (filter = {}) => store.listOccurrences(filter.taskId === undefined ? { ...filter, taskId } : filter);
|
|
4549
|
+
view.listDeliveries = async (filter = {}) => {
|
|
4550
|
+
const rows = await store.listDeliveries(filter);
|
|
4551
|
+
if (filter.occurrenceId !== undefined)
|
|
4552
|
+
return rows;
|
|
4553
|
+
const ours = new Map;
|
|
3910
4554
|
const kept = [];
|
|
3911
|
-
for (const
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
4555
|
+
for (const row of rows) {
|
|
4556
|
+
if (!ours.has(row.occurrenceId))
|
|
4557
|
+
ours.set(row.occurrenceId, (await store.getOccurrence(row.occurrenceId))?.taskId === taskId);
|
|
4558
|
+
if (ours.get(row.occurrenceId))
|
|
4559
|
+
kept.push(row);
|
|
3915
4560
|
}
|
|
3916
4561
|
return kept;
|
|
3917
4562
|
};
|
|
@@ -3923,22 +4568,31 @@ async function runNotifyTick(d, signal) {
|
|
|
3923
4568
|
if (signal?.aborted)
|
|
3924
4569
|
return { kind: "aborted" };
|
|
3925
4570
|
const kind = d.kind ?? "notify";
|
|
3926
|
-
const
|
|
3927
|
-
const notifier = createDmNotifier({
|
|
3928
|
-
store,
|
|
3929
|
-
claims: d.claims,
|
|
3930
|
-
dm: d.dm,
|
|
3931
|
-
clock: d.clock,
|
|
3932
|
-
log: d.log,
|
|
3933
|
-
...signal ? { signal } : {},
|
|
3934
|
-
...d.health ? { health: d.health } : {}
|
|
3935
|
-
});
|
|
4571
|
+
const notifier = createDmNotifier({ store: d.store, dm: d.dm, clock: d.clock, log: d.log, ...d.health ? { health: d.health } : {} });
|
|
3936
4572
|
let types = d.types;
|
|
3937
4573
|
if (kind === "poll" && signal) {
|
|
3938
4574
|
types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
|
|
3939
4575
|
}
|
|
3940
|
-
const
|
|
3941
|
-
const result =
|
|
4576
|
+
const locks = d.locks;
|
|
4577
|
+
const result = { ran: 0, failed: 0, skipped: 0 };
|
|
4578
|
+
for (const taskId of await tasksWithWork(d.store, d.clock.now(), (task) => task ? takesType(kind, task.type) : kind === "notify")) {
|
|
4579
|
+
if (signal?.aborted)
|
|
4580
|
+
break;
|
|
4581
|
+
const pass = async () => {
|
|
4582
|
+
const lanes = new Lanes({
|
|
4583
|
+
store: taskStore(d.store, taskId),
|
|
4584
|
+
clock: d.clock,
|
|
4585
|
+
types,
|
|
4586
|
+
notifier,
|
|
4587
|
+
...kind === "poll" && d.fetch ? { fetch: d.fetch } : {}
|
|
4588
|
+
});
|
|
4589
|
+
const r = await lanes.tickNotify(signal);
|
|
4590
|
+
result.ran += r.ran;
|
|
4591
|
+
result.failed += r.failed;
|
|
4592
|
+
result.skipped += r.skipped;
|
|
4593
|
+
};
|
|
4594
|
+
await (locks ? locks.run(taskId, pass) : pass());
|
|
4595
|
+
}
|
|
3942
4596
|
return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
|
|
3943
4597
|
}
|
|
3944
4598
|
|
|
@@ -4128,6 +4782,68 @@ var MIGRATIONS = [
|
|
|
4128
4782
|
member_checked_at TEXT
|
|
4129
4783
|
);
|
|
4130
4784
|
CREATE INDEX api_tokens_user ON api_tokens (user_id);
|
|
4785
|
+
`,
|
|
4786
|
+
`
|
|
4787
|
+
ALTER TABLE occurrences ADD COLUMN record TEXT;
|
|
4788
|
+
|
|
4789
|
+
ALTER TABLE series ADD COLUMN key TEXT;
|
|
4790
|
+
CREATE UNIQUE INDEX series_key ON series (key) WHERE key IS NOT NULL;
|
|
4791
|
+
|
|
4792
|
+
CREATE TABLE deliveries (
|
|
4793
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4794
|
+
occurrence_id TEXT NOT NULL,
|
|
4795
|
+
user_id TEXT NOT NULL,
|
|
4796
|
+
status TEXT NOT NULL,
|
|
4797
|
+
message_id TEXT,
|
|
4798
|
+
error TEXT,
|
|
4799
|
+
attempts INTEGER NOT NULL,
|
|
4800
|
+
deferrals INTEGER NOT NULL,
|
|
4801
|
+
retry_at TEXT,
|
|
4802
|
+
created_at TEXT NOT NULL,
|
|
4803
|
+
claimed_at TEXT,
|
|
4804
|
+
settled_at TEXT,
|
|
4805
|
+
UNIQUE (occurrence_id, user_id)
|
|
4806
|
+
);
|
|
4807
|
+
CREATE INDEX deliveries_user ON deliveries (user_id);
|
|
4808
|
+
CREATE INDEX deliveries_owed ON deliveries (retry_at) WHERE retry_at IS NOT NULL;
|
|
4809
|
+
CREATE INDEX deliveries_status ON deliveries (status);
|
|
4810
|
+
INSERT INTO deliveries (occurrence_id, user_id, status, message_id, error, attempts, deferrals, retry_at, created_at, claimed_at, settled_at)
|
|
4811
|
+
SELECT occurrence_id, user_id,
|
|
4812
|
+
CASE
|
|
4813
|
+
WHEN status IN ('sent', 'failed') THEN status
|
|
4814
|
+
WHEN status = 'unconfirmed' AND reported_at IS NOT NULL THEN 'unconfirmed'
|
|
4815
|
+
ELSE 'claimed'
|
|
4816
|
+
END,
|
|
4817
|
+
message_id, error,
|
|
4818
|
+
CASE WHEN status = 'failed' THEN 1 ELSE 0 END,
|
|
4819
|
+
0, NULL, claimed_at, claimed_at,
|
|
4820
|
+
CASE
|
|
4821
|
+
WHEN status IN ('sent', 'failed') OR (status = 'unconfirmed' AND reported_at IS NOT NULL)
|
|
4822
|
+
THEN COALESCE(settled_at, claimed_at)
|
|
4823
|
+
ELSE NULL
|
|
4824
|
+
END
|
|
4825
|
+
FROM delivery_claims ORDER BY claimed_at, occurrence_id, user_id;
|
|
4826
|
+
DROP TABLE delivery_claims;
|
|
4827
|
+
|
|
4828
|
+
CREATE TABLE usage (
|
|
4829
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4830
|
+
user_id TEXT NOT NULL,
|
|
4831
|
+
task_id TEXT,
|
|
4832
|
+
occurrence_id TEXT,
|
|
4833
|
+
source TEXT NOT NULL,
|
|
4834
|
+
calls INTEGER NOT NULL,
|
|
4835
|
+
cost_usd REAL NOT NULL,
|
|
4836
|
+
at TEXT NOT NULL
|
|
4837
|
+
);
|
|
4838
|
+
CREATE INDEX usage_user ON usage (user_id, at);
|
|
4839
|
+
|
|
4840
|
+
CREATE TABLE notices (
|
|
4841
|
+
key TEXT PRIMARY KEY,
|
|
4842
|
+
at TEXT NOT NULL
|
|
4843
|
+
);
|
|
4844
|
+
|
|
4845
|
+
DROP INDEX users_usr_subject;
|
|
4846
|
+
ALTER TABLE users DROP COLUMN usr_subject;
|
|
4131
4847
|
`
|
|
4132
4848
|
];
|
|
4133
4849
|
var BUSY_TIMEOUT_MS = 5000;
|
|
@@ -4153,212 +4869,33 @@ function openDatabase(path) {
|
|
|
4153
4869
|
db.exec("PRAGMA secure_delete = ON");
|
|
4154
4870
|
migrate(db);
|
|
4155
4871
|
return db;
|
|
4156
|
-
} catch (err) {
|
|
4157
|
-
db.close();
|
|
4158
|
-
throw err;
|
|
4159
|
-
}
|
|
4160
|
-
}
|
|
4161
|
-
var META_TABLE = "tracker_meta";
|
|
4162
|
-
var VACUUMED = "vacuumed_for_secure_delete";
|
|
4163
|
-
function vacuumOnce(db, path) {
|
|
4164
|
-
if (path === ":memory:")
|
|
4165
|
-
return { ran: false };
|
|
4166
|
-
try {
|
|
4167
|
-
db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
4168
|
-
if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
|
|
4169
|
-
return { ran: false };
|
|
4170
|
-
db.exec("PRAGMA busy_timeout = 0");
|
|
4171
|
-
try {
|
|
4172
|
-
db.exec("VACUUM");
|
|
4173
|
-
const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4174
|
-
if (r && Number(r.busy) !== 0)
|
|
4175
|
-
throw new Error("the write-ahead log is busy");
|
|
4176
|
-
} finally {
|
|
4177
|
-
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4178
|
-
}
|
|
4179
|
-
db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
|
|
4180
|
-
return { ran: true };
|
|
4181
|
-
} catch (err) {
|
|
4182
|
-
return { ran: false, error: err instanceof Error ? err.message : String(err) };
|
|
4183
|
-
}
|
|
4184
|
-
}
|
|
4185
|
-
|
|
4186
|
-
// plugins/tracker/src/roster.ts
|
|
4187
|
-
var FORGOTTEN = "forgotten";
|
|
4188
|
-
function historyNames(kind, detail, id) {
|
|
4189
|
-
switch (kind) {
|
|
4190
|
-
case "recipient_invited":
|
|
4191
|
-
case "recipient_accepted":
|
|
4192
|
-
case "recipient_declined":
|
|
4193
|
-
case "recipient_opted_out":
|
|
4194
|
-
return detail === id;
|
|
4195
|
-
case "blocked":
|
|
4196
|
-
return detail === `${id} 24h` || detail === `${id} permanent`;
|
|
4197
|
-
case "recipient_removed":
|
|
4198
|
-
return detail.startsWith(`${id}: `);
|
|
4199
|
-
case "paused":
|
|
4200
|
-
return detail.startsWith(`delivery to {${id}} paused:`);
|
|
4201
|
-
default:
|
|
4202
|
-
return false;
|
|
4203
|
-
}
|
|
4204
|
-
}
|
|
4205
|
-
function eventNames(type, text, id) {
|
|
4206
|
-
return type === "delivered" && text.startsWith(`${id} `);
|
|
4207
|
-
}
|
|
4208
|
-
function errorForms(id) {
|
|
4209
|
-
return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
|
|
4210
|
-
}
|
|
4211
|
-
function errorNames(text, id) {
|
|
4212
|
-
return text !== null && errorForms(id).test(text);
|
|
4213
|
-
}
|
|
4214
|
-
function redactError(text, id) {
|
|
4215
|
-
return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
|
|
4216
|
-
}
|
|
4217
|
-
|
|
4218
|
-
class Roster {
|
|
4219
|
-
db;
|
|
4220
|
-
constructor(db) {
|
|
4221
|
-
this.db = db;
|
|
4222
|
-
}
|
|
4223
|
-
people() {
|
|
4224
|
-
const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
|
|
4225
|
-
a.admitted_by, a.registered_at, h.paused_at, h.failures
|
|
4226
|
-
FROM users u
|
|
4227
|
-
LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
|
|
4228
|
-
LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
|
|
4229
|
-
ORDER BY u.seq`).all();
|
|
4230
|
-
const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
|
|
4231
|
-
const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
|
|
4232
|
-
WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
|
|
4233
|
-
return rows.map((r) => {
|
|
4234
|
-
const id = `u${Number(r.seq)}`;
|
|
4235
|
-
const counts = { active: 0, paused: 0, done: 0, archived: 0 };
|
|
4236
|
-
for (const o of owned)
|
|
4237
|
-
if (o.owner_id === id && o.status in counts)
|
|
4238
|
-
counts[o.status] = Number(o.n);
|
|
4239
|
-
const str = (v) => v === null || v === undefined ? null : String(v);
|
|
4240
|
-
return {
|
|
4241
|
-
id,
|
|
4242
|
-
discordId: str(r.discord_id),
|
|
4243
|
-
displayName: str(r.display_name),
|
|
4244
|
-
timeZone: String(r.time_zone),
|
|
4245
|
-
preferredHour: Number(r.preferred_hour),
|
|
4246
|
-
admin: Number(r.admin) === 1,
|
|
4247
|
-
createdAt: String(r.created_at),
|
|
4248
|
-
admittedBy: str(r.admitted_by),
|
|
4249
|
-
registeredAt: str(r.registered_at),
|
|
4250
|
-
deliveryPausedAt: str(r.paused_at),
|
|
4251
|
-
failures: Number(r.failures ?? 0),
|
|
4252
|
-
owned: counts,
|
|
4253
|
-
receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
|
|
4254
|
-
};
|
|
4255
|
-
});
|
|
4256
|
-
}
|
|
4257
|
-
checkpoint() {
|
|
4258
|
-
this.db.exec("PRAGMA busy_timeout = 0");
|
|
4259
|
-
try {
|
|
4260
|
-
const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4261
|
-
return !r || Number(r.busy) === 0;
|
|
4262
|
-
} finally {
|
|
4263
|
-
this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4264
|
-
}
|
|
4265
|
-
}
|
|
4266
|
-
othersNamed(taskId, userId, name) {
|
|
4267
|
-
const ids = new Set;
|
|
4268
|
-
for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
|
|
4269
|
-
ids.add(r.user_id);
|
|
4270
|
-
for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
|
|
4271
|
-
const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
|
|
4272
|
-
if (id)
|
|
4273
|
-
ids.add(id);
|
|
4274
|
-
}
|
|
4275
|
-
ids.delete(userId);
|
|
4276
|
-
const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
|
|
4277
|
-
return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
|
|
4278
|
-
}
|
|
4279
|
-
admins() {
|
|
4280
|
-
return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
|
|
4281
|
-
}
|
|
4282
|
-
activeBlocks(now) {
|
|
4283
|
-
const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
|
|
4284
|
-
WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
|
|
4285
|
-
return rows.map((r) => ({
|
|
4286
|
-
id: `b${Number(r.seq)}`,
|
|
4287
|
-
ownerId: String(r.owner_id),
|
|
4288
|
-
recipientId: String(r.recipient_id),
|
|
4289
|
-
expiresAt: r.expires_at === null ? null : String(r.expires_at),
|
|
4290
|
-
createdAt: String(r.created_at)
|
|
4291
|
-
}));
|
|
4292
|
-
}
|
|
4293
|
-
erase(userId) {
|
|
4294
|
-
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
4295
|
-
if (seq === undefined)
|
|
4296
|
-
throw new Error(`no user ${userId}`);
|
|
4297
|
-
return this.db.transaction(() => {
|
|
4298
|
-
const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
|
|
4299
|
-
if (!person)
|
|
4300
|
-
throw new Error(`no user ${userId}`);
|
|
4301
|
-
const rows = {};
|
|
4302
|
-
const del = (table, where, ...params) => {
|
|
4303
|
-
rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
|
|
4304
|
-
};
|
|
4305
|
-
const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
|
|
4306
|
-
const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
|
|
4307
|
-
const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
|
|
4308
|
-
const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
|
|
4309
|
-
const recipientOf = new Set([
|
|
4310
|
-
...pausedFor,
|
|
4311
|
-
...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
|
|
4312
|
-
...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
|
|
4313
|
-
]);
|
|
4314
|
-
del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
|
|
4315
|
-
del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
|
|
4316
|
-
del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4317
|
-
del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
|
|
4318
|
-
del("series", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4319
|
-
del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4320
|
-
del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
4321
|
-
del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
|
|
4322
|
-
del("tasks", "owner_id = ?", userId);
|
|
4323
|
-
const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
|
|
4324
|
-
for (const r of containing("task_events", "kind, detail", "detail")) {
|
|
4325
|
-
if (historyNames(String(r.kind), String(r.detail), userId))
|
|
4326
|
-
del("task_events", "seq = ?", Number(r.seq));
|
|
4327
|
-
}
|
|
4328
|
-
for (const r of containing("events", "type, text", "text")) {
|
|
4329
|
-
if (eventNames(String(r.type), String(r.text), userId))
|
|
4330
|
-
del("events", "seq = ?", Number(r.seq));
|
|
4331
|
-
else if (errorNames(String(r.text), userId))
|
|
4332
|
-
this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
|
|
4333
|
-
}
|
|
4334
|
-
for (const r of containing("occurrences", "error", "error")) {
|
|
4335
|
-
if (errorNames(String(r.error), userId))
|
|
4336
|
-
this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
|
|
4337
|
-
}
|
|
4338
|
-
if (person.display_name !== null) {
|
|
4339
|
-
const legacy = `delivery to ${person.display_name} paused:`;
|
|
4340
|
-
const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
|
|
4341
|
-
for (const r of rowsNaming) {
|
|
4342
|
-
if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
|
|
4343
|
-
continue;
|
|
4344
|
-
if (this.othersNamed(r.task_id, userId, person.display_name))
|
|
4345
|
-
continue;
|
|
4346
|
-
del("task_events", "seq = ?", r.seq);
|
|
4347
|
-
}
|
|
4348
|
-
}
|
|
4349
|
-
del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
|
|
4350
|
-
this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
|
|
4351
|
-
del("admissions", "user_id = ?", userId);
|
|
4352
|
-
this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
|
|
4353
|
-
del("delivery_health", "user_id = ?", userId);
|
|
4354
|
-
del("web_sessions", "user_id = ?", userId);
|
|
4355
|
-
del("web_login_tokens", "user_id = ?", userId);
|
|
4356
|
-
del("api_tokens", "user_id = ?", userId);
|
|
4357
|
-
del("users", "seq = ?", Number(seq));
|
|
4358
|
-
const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
|
|
4359
|
-
const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
|
|
4360
|
-
return { rows, freed };
|
|
4361
|
-
})();
|
|
4872
|
+
} catch (err) {
|
|
4873
|
+
db.close();
|
|
4874
|
+
throw err;
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
var META_TABLE = "tracker_meta";
|
|
4878
|
+
var VACUUMED = "vacuumed_for_secure_delete";
|
|
4879
|
+
function vacuumOnce(db, path) {
|
|
4880
|
+
if (path === ":memory:")
|
|
4881
|
+
return { ran: false };
|
|
4882
|
+
try {
|
|
4883
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
4884
|
+
if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
|
|
4885
|
+
return { ran: false };
|
|
4886
|
+
db.exec("PRAGMA busy_timeout = 0");
|
|
4887
|
+
try {
|
|
4888
|
+
db.exec("VACUUM");
|
|
4889
|
+
const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
4890
|
+
if (r && Number(r.busy) !== 0)
|
|
4891
|
+
throw new Error("the write-ahead log is busy");
|
|
4892
|
+
} finally {
|
|
4893
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
4894
|
+
}
|
|
4895
|
+
db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
|
|
4896
|
+
return { ran: true };
|
|
4897
|
+
} catch (err) {
|
|
4898
|
+
return { ran: false, error: err instanceof Error ? err.message : String(err) };
|
|
4362
4899
|
}
|
|
4363
4900
|
}
|
|
4364
4901
|
|
|
@@ -4387,7 +4924,6 @@ function toUser(r) {
|
|
|
4387
4924
|
return {
|
|
4388
4925
|
id: idOf("u", r.seq),
|
|
4389
4926
|
discordId: str(r.discord_id),
|
|
4390
|
-
usrSubject: str(r.usr_subject),
|
|
4391
4927
|
displayName: str(r.display_name),
|
|
4392
4928
|
timeZone: String(r.time_zone),
|
|
4393
4929
|
preferredHour: Number(r.preferred_hour),
|
|
@@ -4440,6 +4976,7 @@ function toOccurrence(r) {
|
|
|
4440
4976
|
summary: str(r.summary),
|
|
4441
4977
|
costUsd: r.cost_usd === null || r.cost_usd === undefined ? null : Number(r.cost_usd),
|
|
4442
4978
|
error: str(r.error),
|
|
4979
|
+
record: r.record === null || r.record === undefined ? null : parse(r.record),
|
|
4443
4980
|
createdAt: String(r.created_at)
|
|
4444
4981
|
};
|
|
4445
4982
|
}
|
|
@@ -4475,7 +5012,37 @@ function toReply(r) {
|
|
|
4475
5012
|
};
|
|
4476
5013
|
}
|
|
4477
5014
|
function toPoint(r) {
|
|
4478
|
-
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) };
|
|
5015
|
+
return { id: idOf("s", r.seq), taskId: String(r.task_id), key: str(r.key), at: String(r.at), value: Number(r.value), unit: str(r.unit), note: str(r.note) };
|
|
5016
|
+
}
|
|
5017
|
+
function toUsage(r) {
|
|
5018
|
+
return {
|
|
5019
|
+
id: idOf("c", r.seq),
|
|
5020
|
+
userId: String(r.user_id),
|
|
5021
|
+
taskId: str(r.task_id),
|
|
5022
|
+
occurrenceId: str(r.occurrence_id),
|
|
5023
|
+
source: String(r.source),
|
|
5024
|
+
calls: Number(r.calls),
|
|
5025
|
+
costUsd: Number(r.cost_usd),
|
|
5026
|
+
at: String(r.at)
|
|
5027
|
+
};
|
|
5028
|
+
}
|
|
5029
|
+
function toDelivery(r) {
|
|
5030
|
+
return {
|
|
5031
|
+
occurrenceId: String(r.occurrence_id),
|
|
5032
|
+
userId: String(r.user_id),
|
|
5033
|
+
status: String(r.status),
|
|
5034
|
+
messageId: str(r.message_id),
|
|
5035
|
+
error: str(r.error),
|
|
5036
|
+
attempts: Number(r.attempts),
|
|
5037
|
+
deferrals: Number(r.deferrals),
|
|
5038
|
+
retryAt: str(r.retry_at),
|
|
5039
|
+
createdAt: String(r.created_at),
|
|
5040
|
+
claimedAt: str(r.claimed_at),
|
|
5041
|
+
settledAt: str(r.settled_at)
|
|
5042
|
+
};
|
|
5043
|
+
}
|
|
5044
|
+
function deleteDeliveriesOf(db, userId) {
|
|
5045
|
+
return db.query("DELETE FROM deliveries WHERE user_id = ?").run(userId).changes;
|
|
4479
5046
|
}
|
|
4480
5047
|
|
|
4481
5048
|
class SqliteStore {
|
|
@@ -4510,13 +5077,9 @@ class SqliteStore {
|
|
|
4510
5077
|
const r = this.one("SELECT * FROM users WHERE discord_id = ? ORDER BY seq LIMIT 1", discordId);
|
|
4511
5078
|
return r ? toUser(r) : null;
|
|
4512
5079
|
}
|
|
4513
|
-
async findUserBySubject(usrSubject) {
|
|
4514
|
-
const r = this.one("SELECT * FROM users WHERE usr_subject = ? ORDER BY seq LIMIT 1", usrSubject);
|
|
4515
|
-
return r ? toUser(r) : null;
|
|
4516
|
-
}
|
|
4517
5080
|
async createUser(u) {
|
|
4518
|
-
const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id,
|
|
4519
|
-
VALUES (?, ?, ?, ?, ?,
|
|
5081
|
+
const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id, display_name, time_zone, preferred_hour, admin, created_at)
|
|
5082
|
+
VALUES (?, ?, ?, ?, ?, ?)`, u.discordId ?? null, u.displayName ?? null, u.timeZone ?? DEFAULT_TIME_ZONE2, u.preferredHour ?? DEFAULT_PREFERRED_HOUR2, u.admin ? 1 : 0, u.at);
|
|
4520
5083
|
return toUser(this.mustGet("users", "u", idOf("u", lastInsertRowid), "user"));
|
|
4521
5084
|
}
|
|
4522
5085
|
async updateUser(id, patch) {
|
|
@@ -4524,16 +5087,19 @@ class SqliteStore {
|
|
|
4524
5087
|
const next = {
|
|
4525
5088
|
...cur,
|
|
4526
5089
|
...patch.discordId !== undefined ? { discordId: patch.discordId } : {},
|
|
4527
|
-
...patch.usrSubject !== undefined ? { usrSubject: patch.usrSubject } : {},
|
|
4528
5090
|
...patch.displayName !== undefined ? { displayName: patch.displayName } : {},
|
|
4529
5091
|
...patch.timeZone !== undefined ? { timeZone: patch.timeZone } : {},
|
|
4530
5092
|
...patch.preferredHour !== undefined ? { preferredHour: patch.preferredHour } : {},
|
|
4531
5093
|
...patch.admin !== undefined ? { admin: patch.admin } : {}
|
|
4532
5094
|
};
|
|
4533
|
-
this.run(`UPDATE users SET discord_id = ?,
|
|
4534
|
-
WHERE seq = ?`, next.discordId, next.
|
|
5095
|
+
this.run(`UPDATE users SET discord_id = ?, display_name = ?, time_zone = ?, preferred_hour = ?, admin = ?
|
|
5096
|
+
WHERE seq = ?`, next.discordId, next.displayName, next.timeZone, next.preferredHour, next.admin ? 1 : 0, seqOf("u", id));
|
|
4535
5097
|
return toUser(this.mustGet("users", "u", id, "user"));
|
|
4536
5098
|
}
|
|
5099
|
+
async listUsers(filter = {}) {
|
|
5100
|
+
const rows = filter.admin === undefined ? this.all("SELECT * FROM users ORDER BY seq") : this.all("SELECT * FROM users WHERE admin = ? ORDER BY seq", filter.admin ? 1 : 0);
|
|
5101
|
+
return rows.map(toUser);
|
|
5102
|
+
}
|
|
4537
5103
|
async createTask(t) {
|
|
4538
5104
|
const { lastInsertRowid } = this.run(`INSERT INTO tasks (owner_id, type, title, config, state, schedule, lane, capabilities, status, created_at, updated_at)
|
|
4539
5105
|
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);
|
|
@@ -4625,28 +5191,56 @@ class SqliteStore {
|
|
|
4625
5191
|
return this.all(`SELECT * FROM occurrences ${clause} ORDER BY due_at, created_at, seq`, ...params).map(toOccurrence);
|
|
4626
5192
|
}
|
|
4627
5193
|
async updateOccurrence(id, patch) {
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
...cur,
|
|
4631
|
-
...patch.status !== undefined ? { status: patch.status } : {},
|
|
4632
|
-
...patch.startedAt !== undefined ? { startedAt: patch.startedAt } : {},
|
|
4633
|
-
...patch.finishedAt !== undefined ? { finishedAt: patch.finishedAt } : {},
|
|
4634
|
-
...patch.late !== undefined ? { late: patch.late } : {},
|
|
4635
|
-
...patch.summary !== undefined ? { summary: patch.summary } : {},
|
|
4636
|
-
...patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {},
|
|
4637
|
-
...patch.error !== undefined ? { error: patch.error } : {}
|
|
4638
|
-
};
|
|
4639
|
-
this.run(`UPDATE occurrences SET status = ?, started_at = ?, finished_at = ?, late = ?, summary = ?, cost_usd = ?, error = ?
|
|
4640
|
-
WHERE seq = ?`, next.status, next.startedAt, next.finishedAt, next.late ? 1 : 0, next.summary, next.costUsd, next.error, seqOf("o", id));
|
|
5194
|
+
this.mustGet("occurrences", "o", id, "occurrence");
|
|
5195
|
+
this.patchOccurrence(id, patch, null);
|
|
4641
5196
|
return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
|
|
4642
5197
|
}
|
|
4643
|
-
async
|
|
4644
|
-
|
|
5198
|
+
async updateOccurrenceIf(id, expected, patch) {
|
|
5199
|
+
if (!this.patchOccurrence(id, patch, expected))
|
|
5200
|
+
return null;
|
|
5201
|
+
return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
|
|
5202
|
+
}
|
|
5203
|
+
patchOccurrence(id, patch, expected) {
|
|
5204
|
+
const seq = seqOf("o", id);
|
|
5205
|
+
if (seq === null)
|
|
5206
|
+
return false;
|
|
5207
|
+
const sets = [];
|
|
5208
|
+
const params = [];
|
|
5209
|
+
const set = (column, value) => (sets.push(`${column} = ?`), params.push(value));
|
|
5210
|
+
if (patch.status !== undefined)
|
|
5211
|
+
set("status", patch.status);
|
|
5212
|
+
if (patch.startedAt !== undefined)
|
|
5213
|
+
set("started_at", patch.startedAt);
|
|
5214
|
+
if (patch.finishedAt !== undefined)
|
|
5215
|
+
set("finished_at", patch.finishedAt);
|
|
5216
|
+
if (patch.late !== undefined)
|
|
5217
|
+
set("late", patch.late ? 1 : 0);
|
|
5218
|
+
if (patch.summary !== undefined)
|
|
5219
|
+
set("summary", patch.summary);
|
|
5220
|
+
if (patch.costUsd !== undefined)
|
|
5221
|
+
set("cost_usd", patch.costUsd);
|
|
5222
|
+
if (patch.error !== undefined)
|
|
5223
|
+
set("error", patch.error);
|
|
5224
|
+
if (patch.record !== undefined)
|
|
5225
|
+
set("record", patch.record === null ? null : json(patch.record));
|
|
5226
|
+
if (sets.length === 0)
|
|
5227
|
+
sets.push("seq = seq");
|
|
5228
|
+
params.push(seq);
|
|
5229
|
+
const guard = expected === null ? "" : " AND status = ?";
|
|
5230
|
+
if (expected !== null)
|
|
5231
|
+
params.push(expected);
|
|
5232
|
+
return this.run(`UPDATE occurrences SET ${sets.join(", ")} WHERE seq = ?${guard}`, ...params).changes > 0;
|
|
5233
|
+
}
|
|
5234
|
+
async deleteOccurrence(id) {
|
|
5235
|
+
const seq = seqOf("o", id);
|
|
5236
|
+
if (seq === null)
|
|
5237
|
+
return false;
|
|
5238
|
+
return this.run("DELETE FROM occurrences WHERE seq = ? AND status = 'queued'", seq).changes > 0;
|
|
4645
5239
|
}
|
|
4646
5240
|
async requeueRunning(lane) {
|
|
4647
5241
|
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);
|
|
4648
5242
|
for (const r of rows)
|
|
4649
|
-
this.run("UPDATE occurrences SET status = 'queued' WHERE seq = ?", r.seq);
|
|
5243
|
+
this.run("UPDATE occurrences SET status = 'queued', started_at = NULL WHERE seq = ? AND status = 'running'", r.seq);
|
|
4650
5244
|
return rows.map((r) => idOf("o", r.seq));
|
|
4651
5245
|
}
|
|
4652
5246
|
async addEvent(e) {
|
|
@@ -4672,7 +5266,14 @@ class SqliteStore {
|
|
|
4672
5266
|
return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
|
|
4673
5267
|
}
|
|
4674
5268
|
async addSeriesPoint(p) {
|
|
4675
|
-
const
|
|
5269
|
+
const key = p.key ?? null;
|
|
5270
|
+
const { changes, lastInsertRowid } = this.run("INSERT INTO series (task_id, key, at, value, unit, note) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) WHERE key IS NOT NULL DO NOTHING", p.taskId, key, p.at, p.value, p.unit ?? null, p.note ?? null);
|
|
5271
|
+
if (changes === 0) {
|
|
5272
|
+
const known = this.one("SELECT * FROM series WHERE key = ?", key);
|
|
5273
|
+
if (!known)
|
|
5274
|
+
throw new Error(`series point ${key} was neither added nor found`);
|
|
5275
|
+
return toPoint(known);
|
|
5276
|
+
}
|
|
4676
5277
|
return toPoint(this.mustGet("series", "s", idOf("s", lastInsertRowid), "series point"));
|
|
4677
5278
|
}
|
|
4678
5279
|
async listSeries(taskId, filter = {}) {
|
|
@@ -4680,6 +5281,246 @@ class SqliteStore {
|
|
|
4680
5281
|
const kept = filter.limit === undefined ? points : points.slice(-filter.limit);
|
|
4681
5282
|
return kept.map(toPoint);
|
|
4682
5283
|
}
|
|
5284
|
+
async addUsage(u) {
|
|
5285
|
+
const { lastInsertRowid } = this.run("INSERT INTO usage (user_id, task_id, occurrence_id, source, calls, cost_usd, at) VALUES (?, ?, ?, ?, ?, ?, ?)", u.userId, u.taskId, u.occurrenceId, u.source, u.calls, u.costUsd, u.at);
|
|
5286
|
+
return toUsage(this.mustGet("usage", "c", idOf("c", lastInsertRowid), "usage"));
|
|
5287
|
+
}
|
|
5288
|
+
async listUsage(filter = {}) {
|
|
5289
|
+
const where = [];
|
|
5290
|
+
const params = [];
|
|
5291
|
+
if (filter.userId !== undefined)
|
|
5292
|
+
where.push("user_id = ?"), params.push(filter.userId);
|
|
5293
|
+
if (filter.since !== undefined)
|
|
5294
|
+
where.push("at >= ?"), params.push(filter.since);
|
|
5295
|
+
if (filter.before !== undefined)
|
|
5296
|
+
where.push("at < ?"), params.push(filter.before);
|
|
5297
|
+
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
5298
|
+
return this.all(`SELECT * FROM usage ${clause} ORDER BY at, seq`, ...params).map(toUsage);
|
|
5299
|
+
}
|
|
5300
|
+
async claimNotice(key, at) {
|
|
5301
|
+
return this.run("INSERT INTO notices (key, at) VALUES (?, ?) ON CONFLICT (key) DO NOTHING", key, at).changes === 1;
|
|
5302
|
+
}
|
|
5303
|
+
delivery(occurrenceId, userId) {
|
|
5304
|
+
const r = this.one("SELECT * FROM deliveries WHERE occurrence_id = ? AND user_id = ?", occurrenceId, userId);
|
|
5305
|
+
return r ? toDelivery(r) : null;
|
|
5306
|
+
}
|
|
5307
|
+
async planDelivery(occurrenceId, userId, at) {
|
|
5308
|
+
const { changes } = this.run(`INSERT INTO deliveries (occurrence_id, user_id, status, attempts, deferrals, retry_at, created_at)
|
|
5309
|
+
VALUES (?, ?, 'pending', 0, 0, ?, ?)
|
|
5310
|
+
ON CONFLICT (occurrence_id, user_id) DO NOTHING`, occurrenceId, userId, at, at);
|
|
5311
|
+
return changes === 1 ? this.delivery(occurrenceId, userId) : null;
|
|
5312
|
+
}
|
|
5313
|
+
async claimDelivery(occurrenceId, userId, at) {
|
|
5314
|
+
const { changes } = this.run(`UPDATE deliveries SET status = 'claimed', claimed_at = ?, retry_at = NULL
|
|
5315
|
+
WHERE occurrence_id = ? AND user_id = ? AND retry_at IS NOT NULL`, at, occurrenceId, userId);
|
|
5316
|
+
return changes === 1 ? this.delivery(occurrenceId, userId) : null;
|
|
5317
|
+
}
|
|
5318
|
+
async settleDelivery(occurrenceId, userId, settle) {
|
|
5319
|
+
const { changes } = this.run(`UPDATE deliveries SET status = ?, message_id = ?, error = ?, attempts = ?, deferrals = ?, retry_at = ?, settled_at = ?
|
|
5320
|
+
WHERE occurrence_id = ? AND user_id = ?`, settle.status, settle.messageId ?? null, settle.error ?? null, settle.attempts, settle.deferrals, settle.retryAt, settle.at, occurrenceId, userId);
|
|
5321
|
+
if (changes === 0)
|
|
5322
|
+
throw new Error(`no delivery ${occurrenceId}:${userId}`);
|
|
5323
|
+
return this.delivery(occurrenceId, userId);
|
|
5324
|
+
}
|
|
5325
|
+
async listDeliveries(filter = {}) {
|
|
5326
|
+
const where = [];
|
|
5327
|
+
const params = [];
|
|
5328
|
+
if (filter.occurrenceId !== undefined)
|
|
5329
|
+
where.push("occurrence_id = ?"), params.push(filter.occurrenceId);
|
|
5330
|
+
if (filter.userId !== undefined)
|
|
5331
|
+
where.push("user_id = ?"), params.push(filter.userId);
|
|
5332
|
+
if (filter.status !== undefined)
|
|
5333
|
+
where.push("status = ?"), params.push(filter.status);
|
|
5334
|
+
if (filter.dueBefore !== undefined)
|
|
5335
|
+
where.push("retry_at IS NOT NULL AND retry_at <= ?"), params.push(filter.dueBefore);
|
|
5336
|
+
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
|
5337
|
+
return this.all(`SELECT * FROM deliveries ${clause} ORDER BY created_at, seq`, ...params).map(toDelivery);
|
|
5338
|
+
}
|
|
5339
|
+
async deleteDeliveries(userId) {
|
|
5340
|
+
return deleteDeliveriesOf(this.db, userId);
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
|
|
5344
|
+
// plugins/tracker/src/roster.ts
|
|
5345
|
+
var FORGOTTEN = "forgotten";
|
|
5346
|
+
function historyNames(kind, detail, id) {
|
|
5347
|
+
switch (kind) {
|
|
5348
|
+
case "recipient_invited":
|
|
5349
|
+
case "recipient_accepted":
|
|
5350
|
+
case "recipient_declined":
|
|
5351
|
+
case "recipient_opted_out":
|
|
5352
|
+
return detail === id;
|
|
5353
|
+
case "blocked":
|
|
5354
|
+
return detail === `${id} 24h` || detail === `${id} permanent`;
|
|
5355
|
+
case "recipient_removed":
|
|
5356
|
+
return detail.startsWith(`${id}: `);
|
|
5357
|
+
case "paused":
|
|
5358
|
+
return detail.startsWith(`delivery to {${id}} paused:`);
|
|
5359
|
+
default:
|
|
5360
|
+
return false;
|
|
5361
|
+
}
|
|
5362
|
+
}
|
|
5363
|
+
function eventNames(type, text, id) {
|
|
5364
|
+
return type === "delivered" && text.startsWith(`${id} `);
|
|
5365
|
+
}
|
|
5366
|
+
function errorForms(id) {
|
|
5367
|
+
return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
|
|
5368
|
+
}
|
|
5369
|
+
function errorNames(text, id) {
|
|
5370
|
+
return text !== null && errorForms(id).test(text);
|
|
5371
|
+
}
|
|
5372
|
+
function redactError(text, id) {
|
|
5373
|
+
return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
|
|
5374
|
+
}
|
|
5375
|
+
|
|
5376
|
+
class Roster {
|
|
5377
|
+
db;
|
|
5378
|
+
constructor(db) {
|
|
5379
|
+
this.db = db;
|
|
5380
|
+
}
|
|
5381
|
+
people() {
|
|
5382
|
+
const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
|
|
5383
|
+
a.admitted_by, a.registered_at, h.paused_at, h.failures
|
|
5384
|
+
FROM users u
|
|
5385
|
+
LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
|
|
5386
|
+
LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
|
|
5387
|
+
ORDER BY u.seq`).all();
|
|
5388
|
+
const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
|
|
5389
|
+
const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
|
|
5390
|
+
WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
|
|
5391
|
+
return rows.map((r) => {
|
|
5392
|
+
const id = `u${Number(r.seq)}`;
|
|
5393
|
+
const counts = { active: 0, paused: 0, done: 0, archived: 0 };
|
|
5394
|
+
for (const o of owned)
|
|
5395
|
+
if (o.owner_id === id && o.status in counts)
|
|
5396
|
+
counts[o.status] = Number(o.n);
|
|
5397
|
+
const str = (v) => v === null || v === undefined ? null : String(v);
|
|
5398
|
+
return {
|
|
5399
|
+
id,
|
|
5400
|
+
discordId: str(r.discord_id),
|
|
5401
|
+
displayName: str(r.display_name),
|
|
5402
|
+
timeZone: String(r.time_zone),
|
|
5403
|
+
preferredHour: Number(r.preferred_hour),
|
|
5404
|
+
admin: Number(r.admin) === 1,
|
|
5405
|
+
createdAt: String(r.created_at),
|
|
5406
|
+
admittedBy: str(r.admitted_by),
|
|
5407
|
+
registeredAt: str(r.registered_at),
|
|
5408
|
+
deliveryPausedAt: str(r.paused_at),
|
|
5409
|
+
failures: Number(r.failures ?? 0),
|
|
5410
|
+
owned: counts,
|
|
5411
|
+
receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
|
|
5412
|
+
};
|
|
5413
|
+
});
|
|
5414
|
+
}
|
|
5415
|
+
checkpoint() {
|
|
5416
|
+
this.db.exec("PRAGMA busy_timeout = 0");
|
|
5417
|
+
try {
|
|
5418
|
+
const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
5419
|
+
return !r || Number(r.busy) === 0;
|
|
5420
|
+
} finally {
|
|
5421
|
+
this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
5422
|
+
}
|
|
5423
|
+
}
|
|
5424
|
+
othersNamed(taskId, userId, name) {
|
|
5425
|
+
const ids = new Set;
|
|
5426
|
+
for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
|
|
5427
|
+
ids.add(r.user_id);
|
|
5428
|
+
for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
|
|
5429
|
+
const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
|
|
5430
|
+
if (id)
|
|
5431
|
+
ids.add(id);
|
|
5432
|
+
}
|
|
5433
|
+
ids.delete(userId);
|
|
5434
|
+
const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
|
|
5435
|
+
return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
|
|
5436
|
+
}
|
|
5437
|
+
admins() {
|
|
5438
|
+
return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
|
|
5439
|
+
}
|
|
5440
|
+
activeBlocks(now) {
|
|
5441
|
+
const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
|
|
5442
|
+
WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
|
|
5443
|
+
return rows.map((r) => ({
|
|
5444
|
+
id: `b${Number(r.seq)}`,
|
|
5445
|
+
ownerId: String(r.owner_id),
|
|
5446
|
+
recipientId: String(r.recipient_id),
|
|
5447
|
+
expiresAt: r.expires_at === null ? null : String(r.expires_at),
|
|
5448
|
+
createdAt: String(r.created_at)
|
|
5449
|
+
}));
|
|
5450
|
+
}
|
|
5451
|
+
erase(userId) {
|
|
5452
|
+
const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
|
|
5453
|
+
if (seq === undefined)
|
|
5454
|
+
throw new Error(`no user ${userId}`);
|
|
5455
|
+
return this.db.transaction(() => {
|
|
5456
|
+
const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
|
|
5457
|
+
if (!person)
|
|
5458
|
+
throw new Error(`no user ${userId}`);
|
|
5459
|
+
const rows = {};
|
|
5460
|
+
const del = (table, where, ...params) => {
|
|
5461
|
+
rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
|
|
5462
|
+
};
|
|
5463
|
+
const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
|
|
5464
|
+
const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
|
|
5465
|
+
const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
|
|
5466
|
+
const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
|
|
5467
|
+
const recipientOf = new Set([
|
|
5468
|
+
...pausedFor,
|
|
5469
|
+
...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
|
|
5470
|
+
...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
|
|
5471
|
+
]);
|
|
5472
|
+
del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
|
|
5473
|
+
del("deliveries", `occurrence_id IN (${THEIR_RUNS})`, userId);
|
|
5474
|
+
rows.deliveries = (rows.deliveries ?? 0) + deleteDeliveriesOf(this.db, userId);
|
|
5475
|
+
del("usage", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
5476
|
+
del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
5477
|
+
del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
|
|
5478
|
+
del("series", `task_id IN (${THEIR_TASKS})`, userId);
|
|
5479
|
+
del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
5480
|
+
del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
|
|
5481
|
+
del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
|
|
5482
|
+
del("tasks", "owner_id = ?", userId);
|
|
5483
|
+
const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
|
|
5484
|
+
for (const r of containing("task_events", "kind, detail", "detail")) {
|
|
5485
|
+
if (historyNames(String(r.kind), String(r.detail), userId))
|
|
5486
|
+
del("task_events", "seq = ?", Number(r.seq));
|
|
5487
|
+
}
|
|
5488
|
+
for (const r of containing("events", "type, text", "text")) {
|
|
5489
|
+
if (eventNames(String(r.type), String(r.text), userId))
|
|
5490
|
+
del("events", "seq = ?", Number(r.seq));
|
|
5491
|
+
else if (errorNames(String(r.text), userId))
|
|
5492
|
+
this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
|
|
5493
|
+
}
|
|
5494
|
+
for (const r of containing("occurrences", "error", "error")) {
|
|
5495
|
+
if (errorNames(String(r.error), userId))
|
|
5496
|
+
this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
|
|
5497
|
+
}
|
|
5498
|
+
if (person.display_name !== null) {
|
|
5499
|
+
const legacy = `delivery to ${person.display_name} paused:`;
|
|
5500
|
+
const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
|
|
5501
|
+
for (const r of rowsNaming) {
|
|
5502
|
+
if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
|
|
5503
|
+
continue;
|
|
5504
|
+
if (this.othersNamed(r.task_id, userId, person.display_name))
|
|
5505
|
+
continue;
|
|
5506
|
+
del("task_events", "seq = ?", r.seq);
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
|
|
5510
|
+
this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
|
|
5511
|
+
del("admissions", "user_id = ?", userId);
|
|
5512
|
+
this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
|
|
5513
|
+
del("delivery_health", "user_id = ?", userId);
|
|
5514
|
+
del("web_sessions", "user_id = ?", userId);
|
|
5515
|
+
del("web_login_tokens", "user_id = ?", userId);
|
|
5516
|
+
del("api_tokens", "user_id = ?", userId);
|
|
5517
|
+
del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
|
|
5518
|
+
del("users", "seq = ?", Number(seq));
|
|
5519
|
+
const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
|
|
5520
|
+
const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
|
|
5521
|
+
return { rows, freed };
|
|
5522
|
+
})();
|
|
5523
|
+
}
|
|
4683
5524
|
}
|
|
4684
5525
|
|
|
4685
5526
|
// plugins/tracker/src/admin.ts
|
|
@@ -6831,7 +7672,7 @@ async function applyEdit(d, task, user, patch) {
|
|
|
6831
7672
|
}
|
|
6832
7673
|
async function nextRun(d, task) {
|
|
6833
7674
|
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
6834
|
-
return queued
|
|
7675
|
+
return queued.find((o) => !hasFired(o)) ?? null;
|
|
6835
7676
|
}
|
|
6836
7677
|
function savedText(task, user, next, now, what) {
|
|
6837
7678
|
const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
|
|
@@ -6839,7 +7680,10 @@ function savedText(task, user, next, now, what) {
|
|
|
6839
7680
|
return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
|
|
6840
7681
|
}
|
|
6841
7682
|
var blank = (v) => v === undefined || v.trim() === "";
|
|
6842
|
-
|
|
7683
|
+
function editReminder(d, user, taskId, input) {
|
|
7684
|
+
return d.locks.run(taskId.trim(), () => editReminderLocked(d, user, taskId, input));
|
|
7685
|
+
}
|
|
7686
|
+
async function editReminderLocked(d, user, taskId, input) {
|
|
6843
7687
|
const task = await owned(d, user, taskId, "reminder");
|
|
6844
7688
|
if (typeof task === "string")
|
|
6845
7689
|
return { ok: false, error: task };
|
|
@@ -6883,10 +7727,13 @@ async function askedAbout(d, task, owner, date) {
|
|
|
6883
7727
|
const old = task.schedule;
|
|
6884
7728
|
if (old?.kind !== "period")
|
|
6885
7729
|
return false;
|
|
6886
|
-
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
7730
|
+
const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => (o.status !== "queued" || hasFired(o)) && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
|
|
6887
7731
|
return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
|
|
6888
7732
|
}
|
|
6889
|
-
|
|
7733
|
+
function editRenewal(d, user, taskId, input) {
|
|
7734
|
+
return d.locks.run(taskId.trim(), () => editRenewalLocked(d, user, taskId, input));
|
|
7735
|
+
}
|
|
7736
|
+
async function editRenewalLocked(d, user, taskId, input) {
|
|
6890
7737
|
const task = await owned(d, user, taskId, "renewal");
|
|
6891
7738
|
if (typeof task === "string")
|
|
6892
7739
|
return { ok: false, error: task };
|
|
@@ -6935,7 +7782,10 @@ async function editRenewal(d, user, taskId, input) {
|
|
|
6935
7782
|
const cadence = describeSchedule(schedule, user, user.timeZone, now);
|
|
6936
7783
|
return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
|
|
6937
7784
|
}
|
|
6938
|
-
|
|
7785
|
+
function editPrice(d, user, taskId, input) {
|
|
7786
|
+
return d.locks.run(taskId.trim(), () => editPriceLocked(d, user, taskId, input));
|
|
7787
|
+
}
|
|
7788
|
+
async function editPriceLocked(d, user, taskId, input) {
|
|
6939
7789
|
const task = await owned(d, user, taskId, "price");
|
|
6940
7790
|
if (typeof task === "string")
|
|
6941
7791
|
return { ok: false, error: task };
|
|
@@ -7248,7 +8098,7 @@ async function nextAt(d, task) {
|
|
|
7248
8098
|
if (task.status !== "active")
|
|
7249
8099
|
return null;
|
|
7250
8100
|
const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
|
|
7251
|
-
return queued
|
|
8101
|
+
return queued.find((o) => !hasFired(o))?.dueAt ?? null;
|
|
7252
8102
|
}
|
|
7253
8103
|
async function taskJson(d, user, task) {
|
|
7254
8104
|
const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
|
|
@@ -7778,7 +8628,7 @@ function createWebHandler(w) {
|
|
|
7778
8628
|
return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
|
|
7779
8629
|
}
|
|
7780
8630
|
async function recheck(d, user, checkedAt) {
|
|
7781
|
-
if (w.
|
|
8631
|
+
if (w.guildIds === null)
|
|
7782
8632
|
return "ok";
|
|
7783
8633
|
const now = d.clock.now().getTime();
|
|
7784
8634
|
const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
|
|
@@ -7818,7 +8668,7 @@ function createWebHandler(w) {
|
|
|
7818
8668
|
return { kind: "signed-out", note: RECHECK_FAILED };
|
|
7819
8669
|
}
|
|
7820
8670
|
async function memberOf(discordId) {
|
|
7821
|
-
if (w.
|
|
8671
|
+
if (w.guildIds === null)
|
|
7822
8672
|
return "not-checked";
|
|
7823
8673
|
return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
|
|
7824
8674
|
}
|
|
@@ -8019,12 +8869,12 @@ var TRACKER_TYPES = Object.freeze({
|
|
|
8019
8869
|
});
|
|
8020
8870
|
var NO_DM = {
|
|
8021
8871
|
async sendDm() {
|
|
8022
|
-
throw new
|
|
8872
|
+
throw new DeliveryFailedError("this bot has no host.dm (rackbops-discord-bot#736)");
|
|
8023
8873
|
}
|
|
8024
8874
|
};
|
|
8025
8875
|
function createPlugin(host, options = {}) {
|
|
8026
8876
|
const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
|
|
8027
|
-
const
|
|
8877
|
+
const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
|
|
8028
8878
|
const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
|
|
8029
8879
|
const clock = options.clock ?? { now: () => new Date };
|
|
8030
8880
|
const types = options.types ?? TRACKER_TYPES;
|
|
@@ -8032,26 +8882,26 @@ function createPlugin(host, options = {}) {
|
|
|
8032
8882
|
const health = { activatedAt: null, lastTickAt: null, blocked: null };
|
|
8033
8883
|
let db = null;
|
|
8034
8884
|
let store = null;
|
|
8035
|
-
let claims = null;
|
|
8036
8885
|
let delivery = null;
|
|
8037
8886
|
let deps = null;
|
|
8038
8887
|
let warnedNoDm = false;
|
|
8039
8888
|
const dm = host.dm?.bind(host);
|
|
8040
8889
|
const queue = serial();
|
|
8890
|
+
const locks = new TaskLocks;
|
|
8041
8891
|
const surface = createSurface({
|
|
8042
8892
|
deps: () => deps,
|
|
8043
|
-
|
|
8893
|
+
guildIds,
|
|
8044
8894
|
log: host.log,
|
|
8045
8895
|
queue,
|
|
8046
|
-
web: webOrigin ? { origin: webOrigin, name: host.name, gated:
|
|
8896
|
+
web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildIds !== null } : null,
|
|
8047
8897
|
...options.membership ? { membership: options.membership } : {}
|
|
8048
8898
|
});
|
|
8049
8899
|
async function tick(kind, signal) {
|
|
8050
|
-
if (!store
|
|
8900
|
+
if (!store)
|
|
8051
8901
|
return;
|
|
8052
8902
|
const outcome = await runNotifyTick({
|
|
8053
8903
|
store,
|
|
8054
|
-
|
|
8904
|
+
locks,
|
|
8055
8905
|
clock,
|
|
8056
8906
|
types,
|
|
8057
8907
|
dm,
|
|
@@ -8105,14 +8955,14 @@ function createPlugin(host, options = {}) {
|
|
|
8105
8955
|
const clientMembership = async (discordId) => {
|
|
8106
8956
|
if (!discordClient)
|
|
8107
8957
|
return null;
|
|
8108
|
-
return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient },
|
|
8958
|
+
return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildIds, discordId, host.log);
|
|
8109
8959
|
};
|
|
8110
8960
|
const web = createWebHandler({
|
|
8111
8961
|
name: host.name,
|
|
8112
8962
|
origin: webOrigin,
|
|
8113
8963
|
deps: () => deps,
|
|
8114
8964
|
queue,
|
|
8115
|
-
|
|
8965
|
+
guildIds,
|
|
8116
8966
|
membership: options.webMembership ?? clientMembership
|
|
8117
8967
|
});
|
|
8118
8968
|
const interactions = surface.interactions;
|
|
@@ -8137,7 +8987,6 @@ function createPlugin(host, options = {}) {
|
|
|
8137
8987
|
}
|
|
8138
8988
|
const opened = openDatabase(path);
|
|
8139
8989
|
const openedStore = new SqliteStore(opened);
|
|
8140
|
-
const openedClaims = new ClaimStore(opened);
|
|
8141
8990
|
try {
|
|
8142
8991
|
const vacuum = vacuumOnce(opened, path);
|
|
8143
8992
|
if ("error" in vacuum)
|
|
@@ -8151,18 +9000,20 @@ function createPlugin(host, options = {}) {
|
|
|
8151
9000
|
}
|
|
8152
9001
|
if (granted.length > 0)
|
|
8153
9002
|
host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
|
|
8154
|
-
const
|
|
9003
|
+
const open = await openedStore.listDeliveries({ status: "claimed" });
|
|
9004
|
+
const requeued = await new Lanes({ store: openedStore, clock, types, notifier: NO_DM }).recover();
|
|
8155
9005
|
if (requeued.length > 0)
|
|
8156
9006
|
host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
|
|
8157
|
-
for (const c of
|
|
8158
|
-
|
|
9007
|
+
for (const c of open) {
|
|
9008
|
+
const why = c.error ? ` (${c.error})` : "";
|
|
9009
|
+
host.log.warn(`delivery of ${c.occurrenceId} to ${c.userId} claimed ${c.claimedAt ?? "?"} is unconfirmed${why}; it will not be resent`);
|
|
9010
|
+
}
|
|
8159
9011
|
} catch (err) {
|
|
8160
9012
|
opened.close();
|
|
8161
9013
|
throw err;
|
|
8162
9014
|
}
|
|
8163
9015
|
db = opened;
|
|
8164
9016
|
store = openedStore;
|
|
8165
|
-
claims = openedClaims;
|
|
8166
9017
|
delivery = new DeliveryHealth(opened, openedStore, {
|
|
8167
9018
|
log: host.log,
|
|
8168
9019
|
...dm ? {
|
|
@@ -8181,7 +9032,8 @@ function createPlugin(host, options = {}) {
|
|
|
8181
9032
|
fetch: pageFetch(),
|
|
8182
9033
|
dm,
|
|
8183
9034
|
log: host.log,
|
|
8184
|
-
notifier: dm ? createDmNotifier({ store: openedStore,
|
|
9035
|
+
notifier: dm ? createDmNotifier({ store: openedStore, dm, clock, log: host.log, health: delivery }) : NO_DM,
|
|
9036
|
+
locks,
|
|
8185
9037
|
logins: new LoginLinks(opened),
|
|
8186
9038
|
sessions: new Sessions(opened),
|
|
8187
9039
|
apiTokens: new ApiTokens(opened),
|
|
@@ -8190,7 +9042,7 @@ function createPlugin(host, options = {}) {
|
|
|
8190
9042
|
lanes,
|
|
8191
9043
|
webEditor: webOrigin !== null
|
|
8192
9044
|
};
|
|
8193
|
-
if (
|
|
9045
|
+
if (guildIds === null)
|
|
8194
9046
|
host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
|
|
8195
9047
|
health.blocked = typeof host.dm === "function" ? null : "this bot has no host.dm (it predates rackbops-discord-bot#736)";
|
|
8196
9048
|
health.activatedAt = clock.now();
|
|
@@ -8200,7 +9052,6 @@ function createPlugin(host, options = {}) {
|
|
|
8200
9052
|
deps = null;
|
|
8201
9053
|
delivery = null;
|
|
8202
9054
|
store = null;
|
|
8203
|
-
claims = null;
|
|
8204
9055
|
const closing = db;
|
|
8205
9056
|
db = null;
|
|
8206
9057
|
closing?.close();
|