@rackbops/plugin-tracker 0.17.0 → 0.18.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 +4 -1
- package/dist/plugin.js +122 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -22,7 +22,9 @@ that reads a listing page you paste, or BoardGameGeek's marketplace once BGG app
|
|
|
22
22
|
application, every few hours with no model, and DMs each new listing within your limits once, until
|
|
23
23
|
you press Done, and, while the model runner is set up, has the model look at each new listing
|
|
24
24
|
first -- is it the thing, and what does its page show about the seller -- before DMing it; for
|
|
25
|
-
eBay it hands you an eBay search to save on eBay, and never reads eBay
|
|
25
|
+
eBay it hands you an eBay search to save on eBay, and never reads eBay -- and the daily "today and
|
|
26
|
+
overdue" digest (plan 5.5): one DM a day at each person's preferred hour listing their reminders
|
|
27
|
+
and renewals due today or overdue. The model runs never happen here: they go
|
|
26
28
|
through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
|
|
27
29
|
lane stays off until the city-hall Executor is configured (Configuration below).
|
|
28
30
|
|
|
@@ -556,6 +558,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
|
|
|
556
558
|
| Page reads | `src/fetch.ts` | docket's `Fetch` port for `price`: http or https on the default port, no credentials, every resolved address public (no loopback, private, link-local, CGNAT, multicast or reserved range, IPv4 or IPv6), redirects followed by hand and re-checked (at most 5), 15 s including the name lookup, at most 3 MB kept. The body is then rebuilt in linear time (`src/page.ts`) to just what extraction reads -- JSON-LD, meta tags, and the page with every `<` blanked, so `near` still reads text, attributes and script data -- because docket's extraction patterns take quadratic time on a page of unclosed tags. A read the tick's abort cuts short requeues its run instead of counting a miss. A DNS answer that changes between the check and the read is not caught here. |
|
|
557
559
|
| Renewals, prices | `src/tracked.ts`, `src/price.ts`, `src/series.ts`, `src/page.ts` | `/renewal`, `/price` and `/task decide`; the series lines of `/task history`. The series (docket's `series` table, schema 1) holds a renewal's paid amounts and a price's readings. |
|
|
558
560
|
| Delivery | `src/notifier.ts`, `src/buttons.ts` | docket's `Notifier` over `host.dm`, with the buttons. docket claims each (run, person) in the store's `deliveries` before the DM and settles it after. The notifier maps the host's answers to docket's errors: cannot be messaged (50007) or an unknown user is `DeliveryFailedError(msg, true)` (failed for good; 50007 and 10013 count toward the pause); a message refused for its content is a plain `DeliveryFailedError` (retried); a paused person is `ExecutorUnavailableError` (deferred); anything else is rethrown, so docket settles it unconfirmed and never resends it. |
|
|
561
|
+
| Daily digest | `src/digest.ts` | Plan 5.5's "today and overdue" DM, on the `notify` tick after the runs due now, no model. For each person whose preferred hour has come in their zone, the day is claimed first (docket's `claimNotice`, key `digest:<user>:<YYYY-MM-DD>`, research-triage's exactly-once pattern; forget-me erases it), then their own active reminders and renewals are read: overdue is the latest fired run before today with no done or decision from the owner, due today is a run not yet fired today or the latest fired run today still unanswered. A snoozed run, an older run behind a newer one, other types and received tasks are left out. Nothing listed, no DM; a paused person is passed by unclaimed; a failed send is not retried. |
|
|
559
562
|
| Task lock | `src/locks.ts` | One task at a time: its runs, answers and edits never overlap (docket 0.4.0 asks the host to serialize per task). |
|
|
560
563
|
| Pause | `src/delivery-health.ts` | The per-person failure count, the pause and the resume. |
|
|
561
564
|
| People | `src/people.ts`, `src/admissions.ts` | Discord id, time zone, preferred hour, admin flag -- in the tracker's store, never usr; who admitted each person and when they registered. `TRACKER_ADMIN_DISCORD_IDS` only ever grants admin. |
|
package/dist/plugin.js
CHANGED
|
@@ -7918,6 +7918,122 @@ function healthResponse(health) {
|
|
|
7918
7918
|
});
|
|
7919
7919
|
}
|
|
7920
7920
|
|
|
7921
|
+
// plugins/tracker/src/digest.ts
|
|
7922
|
+
var DIGEST_TYPES = new Set(["reminder", "renewal"]);
|
|
7923
|
+
var ANSWERS = new Set(["done", "decision"]);
|
|
7924
|
+
var MAX_LINES = 10;
|
|
7925
|
+
function digestKey(userId, day) {
|
|
7926
|
+
return `digest:${userId}:${day}`;
|
|
7927
|
+
}
|
|
7928
|
+
function localDay2(now, zone) {
|
|
7929
|
+
const w = wallClock(now, zone);
|
|
7930
|
+
const next = new Date(Date.UTC(w.year, w.month - 1, w.day + 1));
|
|
7931
|
+
return {
|
|
7932
|
+
day: `${w.year}-${String(w.month).padStart(2, "0")}-${String(w.day).padStart(2, "0")}`,
|
|
7933
|
+
start: zonedInstant(w.year, w.month, w.day, 0, 0, zone),
|
|
7934
|
+
end: zonedInstant(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), 0, 0, zone),
|
|
7935
|
+
hour: w.hour
|
|
7936
|
+
};
|
|
7937
|
+
}
|
|
7938
|
+
async function collectDigest(store, user, start, end) {
|
|
7939
|
+
const from = start.toISOString();
|
|
7940
|
+
const to = end.toISOString();
|
|
7941
|
+
const overdue = [];
|
|
7942
|
+
const today = [];
|
|
7943
|
+
for (const task of await store.listTasks({ ownerId: user.id, status: "active" })) {
|
|
7944
|
+
if (!DIGEST_TYPES.has(task.type))
|
|
7945
|
+
continue;
|
|
7946
|
+
const runs = await store.listOccurrences({ taskId: task.id });
|
|
7947
|
+
const last = runs.filter(hasFired).at(-1);
|
|
7948
|
+
if (last && last.status !== "snoozed" && !await answered(store, task, last)) {
|
|
7949
|
+
(last.dueAt < from ? overdue : today).push({ task, run: last, waiting: true });
|
|
7950
|
+
}
|
|
7951
|
+
for (const run of runs) {
|
|
7952
|
+
if (run.status === "queued" && !hasFired(run) && run.dueAt >= from && run.dueAt < to)
|
|
7953
|
+
today.push({ task, run, waiting: false });
|
|
7954
|
+
}
|
|
7955
|
+
}
|
|
7956
|
+
const byDue = (a, b) => a.run.dueAt < b.run.dueAt ? -1 : a.run.dueAt > b.run.dueAt ? 1 : 0;
|
|
7957
|
+
return { overdue: overdue.sort(byDue), today: today.sort(byDue) };
|
|
7958
|
+
}
|
|
7959
|
+
async function answered(store, task, run) {
|
|
7960
|
+
return (await store.listReplies(task.id)).some((r) => r.occurrenceId === run.id && r.userId === task.ownerId && ANSWERS.has(r.kind));
|
|
7961
|
+
}
|
|
7962
|
+
var WEEKDAYS3 = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
7963
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
7964
|
+
function dateLabel(now, zone) {
|
|
7965
|
+
const w = wallClock(now, zone);
|
|
7966
|
+
return `${WEEKDAYS3[w.weekday]} ${MONTHS[w.month - 1]} ${w.day}`;
|
|
7967
|
+
}
|
|
7968
|
+
function clockTime2(instant, zone) {
|
|
7969
|
+
const w = wallClock(new Date(instant), zone);
|
|
7970
|
+
return `${w.hour}:${String(w.minute).padStart(2, "0")}`;
|
|
7971
|
+
}
|
|
7972
|
+
function waitingOn(task) {
|
|
7973
|
+
return task.type === "renewal" ? "waiting on your keep, cancel or renewed" : "not marked done yet";
|
|
7974
|
+
}
|
|
7975
|
+
function section(title, items, line) {
|
|
7976
|
+
if (items.length === 0)
|
|
7977
|
+
return [];
|
|
7978
|
+
const lines = [title, ...items.slice(0, MAX_LINES).map(line)];
|
|
7979
|
+
if (items.length > MAX_LINES)
|
|
7980
|
+
lines.push(`- and ${items.length - MAX_LINES} more`);
|
|
7981
|
+
return lines;
|
|
7982
|
+
}
|
|
7983
|
+
function formatDigest(digest, user, now) {
|
|
7984
|
+
if (digest.overdue.length === 0 && digest.today.length === 0)
|
|
7985
|
+
return null;
|
|
7986
|
+
const zone = user.timeZone;
|
|
7987
|
+
const title = (t) => t.title.length > 80 ? `${t.title.slice(0, 77)}...` : t.title;
|
|
7988
|
+
const lines = [
|
|
7989
|
+
`Your day, ${dateLabel(now, zone)}:`,
|
|
7990
|
+
...section("**Overdue**", digest.overdue, (i) => `- \`${i.task.id}\` ${title(i.task)} -- ${i.task.type === "renewal" ? "asked" : "due"} ${formatInstant(i.run.dueAt, zone, now)}, ${waitingOn(i.task)}`),
|
|
7991
|
+
...section("**Due today**", digest.today, (i) => i.waiting ? `- \`${i.task.id}\` ${title(i.task)} -- ${clockTime2(i.run.dueAt, zone)}, ${waitingOn(i.task)}` : `- \`${i.task.id}\` ${title(i.task)} -- ${clockTime2(i.run.dueAt, zone)}`),
|
|
7992
|
+
"`/task done` marks a reminder done, `/task decide` answers a renewal, `/task snooze` puts either off; `/tasks` lists everything."
|
|
7993
|
+
];
|
|
7994
|
+
return lines.join(`
|
|
7995
|
+
`);
|
|
7996
|
+
}
|
|
7997
|
+
async function runDigests(d, now, signal) {
|
|
7998
|
+
const result = { claimed: 0, sent: 0, failed: 0 };
|
|
7999
|
+
for (const user of await d.store.listUsers()) {
|
|
8000
|
+
if (signal?.aborted)
|
|
8001
|
+
break;
|
|
8002
|
+
let day;
|
|
8003
|
+
try {
|
|
8004
|
+
day = localDay2(now, user.timeZone);
|
|
8005
|
+
} catch (err) {
|
|
8006
|
+
d.log.warn(`digest: ${user.id} has a time zone the clock cannot read (${err instanceof Error ? err.message : String(err)})`);
|
|
8007
|
+
continue;
|
|
8008
|
+
}
|
|
8009
|
+
if (day.hour < user.preferredHour)
|
|
8010
|
+
continue;
|
|
8011
|
+
if (d.health?.isPaused(user.id))
|
|
8012
|
+
continue;
|
|
8013
|
+
if (!await d.store.claimNotice(digestKey(user.id, day.day), now.toISOString()))
|
|
8014
|
+
continue;
|
|
8015
|
+
result.claimed++;
|
|
8016
|
+
let text;
|
|
8017
|
+
try {
|
|
8018
|
+
text = formatDigest(await collectDigest(d.store, user, day.start, day.end), user, now);
|
|
8019
|
+
} catch (err) {
|
|
8020
|
+
d.log.error(`digest: could not read ${user.id}'s day; today's digest is skipped`, err);
|
|
8021
|
+
result.failed++;
|
|
8022
|
+
continue;
|
|
8023
|
+
}
|
|
8024
|
+
if (text === null)
|
|
8025
|
+
continue;
|
|
8026
|
+
try {
|
|
8027
|
+
await d.notifier.sendDm(user.id, { text });
|
|
8028
|
+
result.sent++;
|
|
8029
|
+
} catch (err) {
|
|
8030
|
+
d.log.warn(`digest: today's digest to ${user.id} did not go out (${err instanceof Error ? err.message : String(err)}); it is not resent`);
|
|
8031
|
+
result.failed++;
|
|
8032
|
+
}
|
|
8033
|
+
}
|
|
8034
|
+
return result;
|
|
8035
|
+
}
|
|
8036
|
+
|
|
7921
8037
|
// plugins/tracker/src/store.ts
|
|
7922
8038
|
var DEFAULT_TIME_ZONE2 = "UTC";
|
|
7923
8039
|
var DEFAULT_PREFERRED_HOUR2 = 9;
|
|
@@ -8624,6 +8740,7 @@ class Roster {
|
|
|
8624
8740
|
del("web_login_tokens", "user_id = ?", userId);
|
|
8625
8741
|
del("api_tokens", "user_id = ?", userId);
|
|
8626
8742
|
del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
|
|
8743
|
+
del("notices", "key LIKE 'digest:' || ? || ':%'", userId);
|
|
8627
8744
|
del("ceiling_changes", "user_id = ?", userId);
|
|
8628
8745
|
this.db.query("UPDATE ceiling_changes SET set_by = ? WHERE set_by = ?").run(FORGOTTEN, userId);
|
|
8629
8746
|
del("users", "seq = ?", Number(seq));
|
|
@@ -12272,6 +12389,11 @@ function createPlugin(host, options = {}) {
|
|
|
12272
12389
|
const { ran, failed } = outcome.result;
|
|
12273
12390
|
if (failed > 0)
|
|
12274
12391
|
host.log.warn(`${kind === "poll" ? "poll" : "notify"} lane: ${ran} ran, ${failed} failed`);
|
|
12392
|
+
if (kind === "notify" && options.digest !== false && deps && store) {
|
|
12393
|
+
const r = await runDigests({ store, notifier: deps.notifier, log: host.log, ...delivery ? { health: delivery } : {} }, clock.now(), signal);
|
|
12394
|
+
if (r.failed > 0)
|
|
12395
|
+
host.log.warn(`digest: ${r.sent} sent, ${r.failed} failed`);
|
|
12396
|
+
}
|
|
12275
12397
|
}
|
|
12276
12398
|
const running = new Set;
|
|
12277
12399
|
const tracked = (p) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rackbops/plugin-tracker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research and an interest scout (through city-hall, with their findings), a want-list watcher (listing pages you paste, BoardGameGeek once its token is set, and an eBay saved search; with the model checking each new listing and its seller first, through city-hall) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /scout, /want, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed, and scouts, made and changed), an admin view (with the DMs that did not arrive, raising a person's daily model ceiling, and daily model usage per person), an operator switch that turns the daily budgets off for the alpha, forget-me, and a JSON task API with personal API tokens",
|
|
3
|
+
"version": "0.18.0",
|
|
4
|
+
"description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research and an interest scout (through city-hall, with their findings), a want-list watcher (listing pages you paste, BoardGameGeek once its token is set, and an eBay saved search; with the model checking each new listing and its seller first, through city-hall) by slash command, delivered by DM with buttons, and a daily DM at each person's hour of what is due today and overdue, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /scout, /want, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed, and scouts, made and changed), an admin view (with the DMs that did not arrive, raising a person's daily model ceiling, and daily model usage per person), an operator switch that turns the daily budgets off for the alpha, forget-me, and a JSON task API with personal API tokens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/plugin.js",
|
|
7
7
|
"files": ["dist"],
|