@rackbops/plugin-tracker 0.30.0 → 0.31.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 +17 -5
- package/dist/plugin.js +92 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,10 +39,10 @@ as in a server (plan 5.5, item 39).
|
|
|
39
39
|
|
|
40
40
|
| Command | Who | What |
|
|
41
41
|
|---|---|---|
|
|
42
|
-
| `/allow user` | a tracker admin | Admits a person: they can now `/register`. With the usr link on (0.21.0), run in a server, it also allows them in usr as `tracker:member` and keeps their usr user id. The admin must be linked in usr and hold `tracker:register` and `tracker:member` there. Run again, it asks usr again (usr only ever adds), so it links someone not yet linked and relinks someone whose usr link was made anew. The usr user id is final only once they have signed up on usr: one who finishes signing up while signed in to a usr account they already had is moved to it by usr, and the account `/allow` made is dropped, so their usr sign-in fails until a second `/allow
|
|
42
|
+
| `/allow user` | a tracker admin | Admits a person: they can now `/register`. With the usr link on (0.21.0), run in a server, it also allows them in usr as `tracker:member` and keeps their usr user id. The admin must be linked in usr and hold `tracker:register` and `tracker:member` there. Run again, it asks usr again (usr only ever adds), so it links someone not yet linked and relinks someone whose usr link was made anew. The usr user id is final only once they have signed up on usr: one who finishes signing up while signed in to a usr account they already had is moved to it by usr, and the account `/allow` made is dropped, so their usr sign-in fails until a second `/allow`, or an admin's Refresh usr links, links the one they have. A usr refusal or outage leaves the admission standing and says what to do; in a DM it says to run it in the server. |
|
|
43
43
|
| `/register [hour] [zone]` | an admitted person | Signs up, or changes the preferred hour (0-23, default 9) and time zone (default `America/New_York`). The reply says an admin can see every task. With the usr link on (0.21.0), run in a server, it also carries a linked person's one-time usr sign-up link, until usr says they have signed up; in a DM it says to run it in the server. If usr says they are not allowed yet (a usr admin removed their link), it forgets their link and says to ask for `/allow` again. See **First admin and usr** below. |
|
|
44
44
|
| `/remind text [when] [repeat]` | a registered person | A reminder by DM. `when` is docket's `parseWhen` grammar (`in 20 minutes`, `tomorrow 9am`, `fri at 17:30`); `repeat` is once (the default), daily, weekly or monthly. A repeating one with no `when` starts today at the preferred hour. |
|
|
45
|
-
| `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
|
|
45
|
+
| `/renewal name amount currency renews [unit] [every] [lead] [note]` | registered | A subscription, domain, warranty or membership (docket's `renewal`, a `period` schedule). `amount` is from 0 to 1,000,000 (`MAX_AMOUNT` in `src/limits.ts`; `/task decide`, the web editor and the task API take the same range); `renews` is the next renewal or expiry date, `YYYY-MM-DD`, today or later; `unit` yearly (the default), monthly, weekly or daily, `every` how many of those; the ask comes `lead` days before (default 7) at the preferred hour, with Keep, Cancel, Renewed and Snooze. If that ask is already past but the date is not, the first ask comes within a minute (a zone or hour change before it fires drops it for the next period's). Keep and Renewed record what was paid; Cancel ends it. |
|
|
46
46
|
| `/price url [name] [hours] [drop] [baseline] [near]` | registered | A price (docket's `price`, a `poll` schedule every `hours`, default 12, at most 168). `url` may be any public web page (Page reads, under What it does), eBay's and BGG's included, or a link that redirects to one; only the want-list watcher refuses eBay. The page is read once at once, and nothing is created unless a price is found in it; then the first check, within a minute, DMs the starting price, and a drop of `drop` percent or more (default 10) from the `baseline` -- the last (default), first or highest price seen -- DMs the owner once per crossing. `near` is the words just before the price, for a page with no structured price. At most 20 per person. |
|
|
47
47
|
| `/research question [context] [deadline] [at] [sources]` | registered | A one-off research request (docket's `research`, a `once` schedule at `at`, default now). A research run splits the question into its parts and looks each up on the web (read-and-web tools only, plan 5.6), naming each part's best source and trying it -- BoardGameGeek included, read honestly, a page that refuses it listed as one it could not open (with the web area; without it, named as a gap) -- and never opening eBay (eBay's User Agreement, section 3; since 0.30.0, docket-types 0.10.0), a reviewer run checks the draft against its sources, and only a passed answer is DMed to you (and accepted recipients); its claims are kept as findings on the task's page. `deadline` (docket's `parseWhen`) goes into the request; a run that would start past it makes no call and tells you. `question` must hold the question itself: fewer than 3 characters, or no letter at all (a stray `0`), is refused before anything is made. At most 5 waiting per person (a request waiting for pages for a fuller pass, below, not counted: its first answer is delivered); every run counts against the daily budget (below). `sources: browser` (0.28.0, docket-types 0.8.0's `sources: "sent"`; default `web`) answers from pages your own browser gathers instead -- ask Claude in Chrome to gather them and send them in through the task API (`POST /api/v1/tasks/<id>/sources`, then `/sources/ready`; Task API below), for pages a web run cannot or may not read, such as a BoardGameGeek page that refuses it or eBay, which the research never opens: it answers only from the pages sent in, and the request waits for them, its run queued 14 days out or 10 minutes before the deadline, whichever is sooner (a deadline less than 20 minutes away is refused; the margin makes a missed deadline unlikely, not impossible, since a run held by the budget or behind another Job can still start late), and moved to now by `ready`; a request whose `ready` never comes runs then on whatever pages are in, and with none makes no model call and DMs you so. `at` does not go with it, and it needs the web area. **A fuller pass** (0.29.0, docket-types 0.9.0; with the web area only): when the research run lists pages it needed and could not open (a best source that refused it, BoardGameGeek's included), the reviewed answer is DMed with a "Couldn't open:" list, and the request waits for pages for up to 14 days (or until 10 minutes before its deadline), sending, running and charging nothing; ask Claude in Chrome to gather them and send them in as for a browser request, and `ready` starts a second pass that DMs an "Update to ..." of what it adds or corrects. With no `ready` by then, the second pass runs on whatever pages are in, and with none the request ends and the first answer stands. Answers that research is not available while the city-hall Executor is not set up. |
|
|
48
48
|
| `/scout new interests [lens] [for] [notes] [every]` | registered | An interest scout (docket-types' `scout` type, since 0.6.0; plan 1.2 row 1): every `every` days (default 1, at most 30) at your preferred hour, a model run looks on the web for 5 to 10 things published or available in the last 30 days that fit the `interests` (separated by commas, at most 20, each at most 80 characters) through the `lens` -- `general` (the default), `birthday` (fun or a little grandiose), `anniversary` (a romantic angle) or `christmas` (tied to their interests) -- for `for` (someone else, e.g. Anne; empty means you), weighing `notes`, and DMs them to you (and accepted recipients). A link it showed before is not shown again (it remembers the newest 300); fewer than five come with the run's reason. At most 3 scouts per person; each run counts against the daily budget (below), up to 1.50 USD. Answers that the scout is not available while the city-hall Executor is not set up. |
|
|
@@ -250,6 +250,7 @@ served at a hashed path, cached for a year).
|
|
|
250
250
|
| `/admin/usage` | Admins only: model usage per budget day (below). |
|
|
251
251
|
| `/admin/people/<id>` | Admins only: one person -- Discord id, registration, zone and hour, delivery, task counts, their tasks -- with Make admin or Revoke admin, Resume delivery (when paused), and Remove from the tracker; their daily ceiling, with the form to change it; and their API tokens, each with Revoke. |
|
|
252
252
|
| `/admin/usr-link` | Admins only, POST only, with the usr link on: Link everyone to usr (below). |
|
|
253
|
+
| `/admin/usr-refresh` | Admins only, POST only, with the usr link on: Refresh usr links (below). |
|
|
253
254
|
| `/admin/allow`, `/admin/people/<id>/grant`, `/revoke`, `/resume-delivery`, `/ceiling`, `/forget`, `/admin/blocks/<id>/lift`, `/admin/tokens/<id>/revoke` | Admins only, POST only: the acts below. |
|
|
254
255
|
| `/api/v1/...` | The JSON task API (below): bearer tokens only, never the cookie. |
|
|
255
256
|
| `/signin` | Where a request that is not signed in is sent (a POST, or any request with the usr link off): says to run `/web`, and with the usr link on offers Sign in with usr. |
|
|
@@ -729,9 +730,9 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
|
|
|
729
730
|
| `TRACKER_WEB_URL` | no | The https origin the bot's HTTP is reached at through its tunnel, e.g. `https://clerk.example.com` (no path). `/web` links and the allowed `Origin` come from it, never from a request's `Host`. Unset = no web area (`/web` says so, the pages answer 404); anything but a bare https origin refuses to load. |
|
|
730
731
|
| `TRACKER_BUDGET_UNLIMITED` | no | Budgets off for the alpha (0.14.0): `true` or `1` means no daily ceiling -- a person's or the global one -- holds a model run, bot-wide. Each Job's own caps still apply, usage is still recorded (see `/admin/usage`), the bot logs one line at start saying so (or, with the execute lane off, that no model work runs), the People and Usage admin pages show a "Budgets off (alpha): unlimited" banner, and the person page's ceiling section shows a "not enforced" note. Unset, empty, `false` or `0` = the ceilings apply (the default); anything else refuses to load. |
|
|
731
732
|
| `TRACKER_CITY_HALL_URL` | no | The https origin of the city-hall that queues the tracker's model Jobs (no path). |
|
|
732
|
-
| `TRACKER_CITY_HALL_KEY` | yes | The source bearer key city-hall checks on `/api/execute/jobs` (its `CITY_HALL_API_KEY`, city-hall#18). Never logged. Kept in the instance's own env, never in a repo. |
|
|
733
|
+
| `TRACKER_CITY_HALL_KEY` | yes | The source bearer key city-hall checks on `/api/execute/jobs` (its `CITY_HALL_API_KEY`, city-hall#18). Visible ASCII only: a value with whitespace inside it or any other character outside visible ASCII refuses to load (whitespace at either end is trimmed), naming the variable. Never logged. Kept in the instance's own env, never in a repo. |
|
|
733
734
|
| `TRACKER_CITY_HALL_CAPABILITY` | no | The capability tag every Job names, one only docket-runner carries (plan item 71), e.g. `claude-cli:subscription`. No default: a guessed tag could send the tracker's Jobs to another agent. |
|
|
734
|
-
| `TRACKER_CITY_HALL_ACCESS_CLIENT_ID`, `TRACKER_CITY_HALL_ACCESS_CLIENT_SECRET` | yes | A Cloudflare Access service token for city-hall's edge (`CF-Access-Client-Id` / `-Secret`), when one is in front. Both or neither. |
|
|
735
|
+
| `TRACKER_CITY_HALL_ACCESS_CLIENT_ID`, `TRACKER_CITY_HALL_ACCESS_CLIENT_SECRET` | yes | A Cloudflare Access service token for city-hall's edge (`CF-Access-Client-Id` / `-Secret`), when one is in front. Both or neither, each visible ASCII only, as for the key. |
|
|
735
736
|
| `TRACKER_BGG_TOKEN` | yes | BoardGameGeek's XML API Bearer token for the tracker's registered application. Set = `/want source: bgg` watches a game's BGG marketplace; unset or empty = `/want source: bgg` makes an inbox watch instead, whose listings are sent in (as for eBay). Never logged. BGG declined the tracker's application (applied 2026-09-29, non-commercial), so no token exists today; if BGG ever grants one, put it in the instance's `.env` and recreate the bot. BGG's terms ask for attribution: every BGG line in a DM says "via BoardGameGeek". |
|
|
736
737
|
| `TRACKER_USR_URL` | no | The https origin of our usr (Rackbops/usr), e.g. `https://id.example.com` (no path). With `TRACKER_USR_KEY` it turns the usr link on (0.20.0): people stay in the tracker, and each is linked to a usr account by usr's user id (`users.usr_subject`). Unset = off, and the tracker behaves as before (migration 9 still adds the empty column). `/allow` and `/register` use it since 0.21.0, and the web sign-in since 0.22.0 (Signing in through usr, above). |
|
|
737
738
|
| `TRACKER_USR_KEY` | yes | The tracker's usr API key, with the role `usr:discord` only (usr's `/api/discord/allow` and `/register-link`), never `tracker:admin` or `tracker:register`, and a usr Discord service (`PUT /api/discord/services/<keyId>`) set to the tracker's app. Required with `TRACKER_USR_URL`; set without it, or with whitespace in it, refuses to load. Never logged. |
|
|
@@ -765,7 +766,18 @@ instead. Only a 400 from usr whose reason starts with one of the person's own fi
|
|
|
765
766
|
`display_name`) is theirs, listed by name while the rest go on; any other 400, refusal or failure (the admin not linked in usr or missing `tracker:register` or `tracker:member`,
|
|
766
767
|
the key, usr unreachable) stops the run, since it would fail everyone the same way. It never starts
|
|
767
768
|
on its own. It passes over everyone already linked, so it does not fix a link usr moved at sign-up
|
|
768
|
-
(`/allow` above)
|
|
769
|
+
(`/allow` above); Refresh usr links does.
|
|
770
|
+
|
|
771
|
+
**Refresh usr links** (#170, the admin page, with the usr link on). The same run, over everyone on the
|
|
772
|
+
list already linked. usr answers each with the account it has for them now, and a link usr moved at
|
|
773
|
+
sign-up is updated, so their usr sign-in works. It changes usr: it puts `tracker:member` back for
|
|
774
|
+
everyone still linked and in the server, undoing a usr admin's removal of it, and someone whose usr
|
|
775
|
+
link a usr admin removed gets a new usr account (an existing account's display name and who allowed
|
|
776
|
+
it stay as they were). It ends with how many were refreshed, how many moved to another account, how
|
|
777
|
+
many got a new usr account, how many failed and how many were skipped, naming each failure (not a
|
|
778
|
+
member, a 400 about them, or the account usr names already linked to someone else here) while the
|
|
779
|
+
rest go on; a failure that would fail everyone stops it, as above, with the counts so far. Someone
|
|
780
|
+
forgotten is never sent, and one run of either kind goes at a time.
|
|
769
781
|
|
|
770
782
|
**First admin and usr.** usr's `/api/discord/allow` needs the admin running `/allow` to be linked in
|
|
771
783
|
usr already, so the first admin cannot be `/allow`ed by anyone. With the usr link on, a configured
|
package/dist/plugin.js
CHANGED
|
@@ -6811,8 +6811,14 @@ function parseCityHallConfig(env) {
|
|
|
6811
6811
|
if (capability !== undefined && (capability.length > MAX_CAPABILITY || !CAPABILITY.test(capability))) {
|
|
6812
6812
|
throw new Error(`${ENV.capability}: "${capability}" is not a capability tag (lowercase words joined by ":" or "-", e.g. claude-cli:subscription)`);
|
|
6813
6813
|
}
|
|
6814
|
-
|
|
6815
|
-
|
|
6814
|
+
for (const [name, v] of [[ENV.key, key], [ENV.accessId, accessId], [ENV.accessSecret, accessSecret]]) {
|
|
6815
|
+
if (v === undefined)
|
|
6816
|
+
continue;
|
|
6817
|
+
if (/\s/.test(v))
|
|
6818
|
+
throw new Error(`${name} has whitespace in it`);
|
|
6819
|
+
if (!/^[!-~]+$/.test(v))
|
|
6820
|
+
throw new Error(`${name} has a character in it that is not visible ASCII (letters, digits and punctuation only)`);
|
|
6821
|
+
}
|
|
6816
6822
|
const missing = [
|
|
6817
6823
|
...origin === undefined ? [ENV.url] : [],
|
|
6818
6824
|
...key === undefined ? [ENV.key] : [],
|
|
@@ -7168,6 +7174,7 @@ var MAX_WHEN = 100;
|
|
|
7168
7174
|
var MAX_NOTE = 300;
|
|
7169
7175
|
var MAX_URL = 1000;
|
|
7170
7176
|
var MAX_NEAR = 100;
|
|
7177
|
+
var MAX_AMOUNT = 1e6;
|
|
7171
7178
|
var CURRENCY_LENGTH = 3;
|
|
7172
7179
|
var DATE_LENGTH = 10;
|
|
7173
7180
|
var MAX_ZONE = 64;
|
|
@@ -8316,6 +8323,12 @@ var MAX_EVERY = 100;
|
|
|
8316
8323
|
function plural3(n, word) {
|
|
8317
8324
|
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
8318
8325
|
}
|
|
8326
|
+
function amountProblem(amount) {
|
|
8327
|
+
if (!Number.isFinite(amount) || amount < 0 || amount > MAX_AMOUNT) {
|
|
8328
|
+
return `The amount has to be a number from 0 to ${MAX_AMOUNT.toLocaleString("en-US")}.`;
|
|
8329
|
+
}
|
|
8330
|
+
return null;
|
|
8331
|
+
}
|
|
8319
8332
|
var PERIOD_UNITS = ["year", "month", "week", "day"];
|
|
8320
8333
|
function renewalPlan(d, user, input) {
|
|
8321
8334
|
const bad = (error) => ({ ok: false, error });
|
|
@@ -8324,8 +8337,9 @@ function renewalPlan(d, user, input) {
|
|
|
8324
8337
|
return bad("Say what renews: a subscription, a domain, a warranty.");
|
|
8325
8338
|
if (name.length > MAX_TITLE)
|
|
8326
8339
|
return bad(`That name is longer than ${MAX_TITLE} characters.`);
|
|
8327
|
-
|
|
8328
|
-
|
|
8340
|
+
const amountBad = amountProblem(input.amount);
|
|
8341
|
+
if (amountBad)
|
|
8342
|
+
return bad(amountBad);
|
|
8329
8343
|
const currency = input.currency.trim().toUpperCase();
|
|
8330
8344
|
if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
|
|
8331
8345
|
return bad("The currency is a three-letter code, such as USD or EUR.");
|
|
@@ -8406,8 +8420,9 @@ async function decideRenewal(d, user, input) {
|
|
|
8406
8420
|
return NO_SUCH_TASK;
|
|
8407
8421
|
if (task.type !== "renewal")
|
|
8408
8422
|
return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
|
|
8409
|
-
|
|
8410
|
-
|
|
8423
|
+
const amountBad = input.amount === undefined ? null : amountProblem(input.amount);
|
|
8424
|
+
if (amountBad)
|
|
8425
|
+
return amountBad;
|
|
8411
8426
|
const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
|
|
8412
8427
|
const refused = await d.locks.turn(task.id, async () => {
|
|
8413
8428
|
const latest = await latestRun(d, task.id);
|
|
@@ -9924,7 +9939,7 @@ function createSurface(w) {
|
|
|
9924
9939
|
},
|
|
9925
9940
|
{
|
|
9926
9941
|
name: "renewal",
|
|
9927
|
-
build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(MAX_TITLE)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(DATE_LENGTH)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
|
|
9942
|
+
build: (b) => b.setDescription("Track a subscription, domain or warranty: I ask before each renewal").addStringOption((o) => o.setName("name").setDescription("What renews, e.g. Netflix").setRequired(true).setMaxLength(MAX_TITLE)).addNumberOption((o) => o.setName("amount").setDescription("What one period costs").setRequired(true).setMinValue(0).setMaxValue(MAX_AMOUNT)).addStringOption((o) => o.setName("currency").setDescription("Currency code, e.g. USD").setRequired(true).setMinLength(CURRENCY_LENGTH).setMaxLength(CURRENCY_LENGTH)).addStringOption((o) => o.setName("renews").setDescription("The next renewal or expiry date, YYYY-MM-DD").setRequired(true).setMaxLength(DATE_LENGTH)).addStringOption((o) => o.setName("unit").setDescription("How it renews (default: yearly)").addChoices({ name: "yearly", value: "year" }, { name: "monthly", value: "month" }, { name: "weekly", value: "week" }, { name: "daily", value: "day" })).addIntegerOption((o) => o.setName("every").setDescription("Every how many of those, e.g. 2 for every two years (default 1)").setMinValue(1).setMaxValue(MAX_EVERY)).addIntegerOption((o) => o.setName("lead").setDescription(`Days before the date to ask (default ${DEFAULT_LEAD_DAYS})`).setMinValue(0).setMaxValue(MAX_LEAD_DAYS)).addStringOption((o) => o.setName("note").setDescription("Carried on every ask: where to cancel, which account").setMaxLength(MAX_NOTE)),
|
|
9928
9943
|
handle: (interaction) => run(interaction, "registered", (d, user) => {
|
|
9929
9944
|
const every = interaction.options.getInteger("every");
|
|
9930
9945
|
const lead = interaction.options.getInteger("lead");
|
|
@@ -10026,7 +10041,7 @@ function createSurface(w) {
|
|
|
10026
10041
|
},
|
|
10027
10042
|
{
|
|
10028
10043
|
name: "task",
|
|
10029
|
-
build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(MAX_WHEN))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("resume").setDescription("Resume a paused task; one paused for failed DMs goes on without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
|
|
10044
|
+
build: (b) => b.setDescription("Answer, look back at, or share one of your tasks").addSubcommand((s) => s.setName("done").setDescription("Mark the task's latest reminder done").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("snooze").setDescription("Snooze the task's latest reminder (default: an hour)").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("until").setDescription('Until when: "in 2h", "tomorrow 9am"').setMaxLength(MAX_WHEN))).addSubcommand((s) => s.setName("decide").setDescription("Answer a renewal's latest ask, with what you paid if it changed").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addStringOption((o) => o.setName("choice").setDescription("Keep it, cancel it, or it has renewed").setRequired(true).addChoices({ name: "keep", value: "keep" }, { name: "cancel", value: "cancel" }, { name: "renewed", value: "renewed" })).addNumberOption((o) => o.setName("amount").setDescription("What you paid this time, if not the usual").setMinValue(0).setMaxValue(MAX_AMOUNT))).addSubcommand((s) => s.setName("history").setDescription("Every run of the task, how it was answered, and its changes").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("resume").setDescription("Resume a paused task; one paused for failed DMs goes on without them").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID))).addSubcommand((s) => s.setName("share").setDescription("Ask someone to receive this task's messages too").addStringOption((o) => o.setName("task").setDescription("The task id from /tasks, e.g. t3").setRequired(true).setMaxLength(MAX_TASK_ID)).addUserOption((o) => o.setName("user").setDescription("Who to invite").setRequired(true))),
|
|
10030
10045
|
handle: (interaction) => {
|
|
10031
10046
|
const sub = interaction.options.getSubcommand();
|
|
10032
10047
|
const taskId = interaction.options.getString("task", true);
|
|
@@ -11801,7 +11816,7 @@ function fieldsFor(type, mode, surface = "web") {
|
|
|
11801
11816
|
case "renewal":
|
|
11802
11817
|
return [
|
|
11803
11818
|
{ name: "name", label: "What renews", kind: "text", required: true, maxlength: MAX_TITLE, help: "A subscription, a domain, a warranty." },
|
|
11804
|
-
{ name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true },
|
|
11819
|
+
{ name: "amount", label: "What one period costs", kind: "text", required: true, decimal: true, min: 0, max: MAX_AMOUNT, help: `0 to ${MAX_AMOUNT.toLocaleString("en-US")}.` },
|
|
11805
11820
|
{ name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
|
|
11806
11821
|
{ name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
|
|
11807
11822
|
{ name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
|
|
@@ -12279,10 +12294,13 @@ ${hidden(v)}
|
|
|
12279
12294
|
</form>
|
|
12280
12295
|
</section>
|
|
12281
12296
|
${o.usrUnlinked === undefined || o.usrUnlinked === null ? null : html`<section>
|
|
12282
|
-
<h2>
|
|
12283
|
-
<p class="rb-muted">${o.usrUnlinked === 0 ? "Everyone on the list is linked to usr." : `${o.usrUnlinked} on the list ${o.usrUnlinked === 1 ? "is" : "are"} not linked to usr yet.`}
|
|
12284
|
-
${o.usrRun?.running ? html`<p>
|
|
12297
|
+
<h2>usr links</h2>
|
|
12298
|
+
<p class="rb-muted">${o.usrUnlinked === 0 ? "Everyone on the list is linked to usr." : `${o.usrUnlinked} on the list ${o.usrUnlinked === 1 ? "is" : "are"} not linked to usr yet.`}</p>
|
|
12299
|
+
${o.usrUnlinked === 0 || o.usrRun?.running ? null : html`<p class="rb-muted">Link everyone to usr checks each one as a member of the server and allows them in usr as a member, with you as the admin who allowed them, as <code>/allow</code> does in the server.</p>`}
|
|
12300
|
+
${!o.usrLinked || o.usrRun?.running ? null : html`<p class="rb-muted">Refresh usr links asks usr again, as <code>/allow</code> does, for the ${String(o.usrLinked)} on the list ${o.usrLinked === 1 ? "who is" : "who are"} linked, and keeps the account usr has for each now: someone linked before they finished signing up on usr with a usr account they already had is linked to the account usr dropped, and cannot sign in through usr until then. It changes usr: it puts the tracker's member role back for everyone still linked and in the server, undoing a usr admin's removal of it, and someone whose usr link a usr admin removed gets a new usr account.</p>`}
|
|
12301
|
+
${o.usrRun?.running ? html`<p>${o.usrRun.kind === "link" ? "Linking everyone to usr" : "Refreshing usr links"}: ${String(o.usrRun.done)} of ${String(o.usrRun.total)} done. Reload this page to see how far it has got.</p>` : o.usrRun?.result ? html`<div class="rb-alert ${o.usrRun.result.ok ? "rb-alert--success" : "rb-alert--warning"}" role="status"><p>Last run: ${o.usrRun.result.text}</p></div>` : null}
|
|
12285
12302
|
${o.usrUnlinked === 0 || o.usrRun?.running ? null : html`<form method="post" action="${v.base}/admin/usr-link">${hidden(v)}<button class="rb-btn rb-btn--primary" type="submit">Link everyone to usr</button></form>`}
|
|
12303
|
+
${!o.usrLinked || o.usrRun?.running ? null : html`<form method="post" action="${v.base}/admin/usr-refresh">${hidden(v)}<button class="rb-btn rb-btn--ghost" type="submit">Refresh usr links</button></form>`}
|
|
12286
12304
|
</section>`}
|
|
12287
12305
|
<section>
|
|
12288
12306
|
<h2>Decline blocks</h2>
|
|
@@ -12471,8 +12489,16 @@ async function peoplePage(a, result, status, allow) {
|
|
|
12471
12489
|
const data = { people, blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
|
|
12472
12490
|
const usr = a.d.usr;
|
|
12473
12491
|
const usrUnlinked = usr ? people.filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) === null).length : null;
|
|
12492
|
+
const usrLinked = usr ? people.filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) !== null).length : null;
|
|
12474
12493
|
const usrRun = usr ? usrLinkRuns.get(usr) : undefined;
|
|
12475
|
-
return htmlResponse(adminPage(a.v, data, {
|
|
12494
|
+
return htmlResponse(adminPage(a.v, data, {
|
|
12495
|
+
result,
|
|
12496
|
+
unlimited: a.d.budgetUnlimited === true,
|
|
12497
|
+
usrUnlinked,
|
|
12498
|
+
usrLinked,
|
|
12499
|
+
...usrRun ? { usrRun } : {},
|
|
12500
|
+
...allow !== undefined ? { allow } : {}
|
|
12501
|
+
}), status);
|
|
12476
12502
|
}
|
|
12477
12503
|
async function onePerson(a, id, result = null) {
|
|
12478
12504
|
const everyone = a.d.roster.people();
|
|
@@ -12533,11 +12559,12 @@ async function adminPost(a, r, form) {
|
|
|
12533
12559
|
const ok = text.startsWith("Allowed") || text.includes("already on the list");
|
|
12534
12560
|
return peoplePage(a, { ok, text }, ok ? 200 : 400);
|
|
12535
12561
|
}
|
|
12536
|
-
if (r.kind === "admin-usr-link") {
|
|
12562
|
+
if (r.kind === "admin-usr-link" || r.kind === "admin-usr-refresh") {
|
|
12537
12563
|
if (!a.d.usr)
|
|
12538
12564
|
return unknownPage(base);
|
|
12539
12565
|
const usr = a.d.usr;
|
|
12540
|
-
const
|
|
12566
|
+
const kind = r.kind === "admin-usr-link" ? "link" : "refresh";
|
|
12567
|
+
const result = await fresh(a, true, async (me) => startUsrRun(a, usr, me, kind));
|
|
12541
12568
|
if (result === null)
|
|
12542
12569
|
return unknownPage(base);
|
|
12543
12570
|
if (result === "started")
|
|
@@ -12606,9 +12633,18 @@ async function adminPost(a, r, form) {
|
|
|
12606
12633
|
return redirect(`${base}/`);
|
|
12607
12634
|
return onePerson({ ...a, v: { ...a.v, deliveryPaused: a.d.health.isPaused(a.v.user.id) } }, r.id, said2(done));
|
|
12608
12635
|
}
|
|
12636
|
+
var RUN_WORDS = {
|
|
12637
|
+
link: { doing: "linking everyone to usr", stopped: "Linking to usr stopped", soFar: "Linked", none: "Everyone on the list is already linked to usr." },
|
|
12638
|
+
refresh: {
|
|
12639
|
+
doing: "refreshing usr links",
|
|
12640
|
+
stopped: "Refreshing usr links stopped",
|
|
12641
|
+
soFar: "Refreshed",
|
|
12642
|
+
none: "No one on the list is linked to usr yet, so there is nothing to refresh."
|
|
12643
|
+
}
|
|
12644
|
+
};
|
|
12609
12645
|
var usrLinkRuns = new WeakMap;
|
|
12610
12646
|
var usrLinkPending = new Set;
|
|
12611
|
-
function
|
|
12647
|
+
function startUsrRun(a, usr, me, kind) {
|
|
12612
12648
|
if (a.usrGuildId === null) {
|
|
12613
12649
|
return { ok: false, text: "usr needs a server for each link, and TRACKER_GUILD_ID is unset: run /allow on each person in the server instead." };
|
|
12614
12650
|
}
|
|
@@ -12616,15 +12652,15 @@ function startLinkEveryone(a, usr, me) {
|
|
|
12616
12652
|
return { ok: false, text: "You have no Discord id here, and usr asks who is allowing them by it." };
|
|
12617
12653
|
const current = usrLinkRuns.get(usr);
|
|
12618
12654
|
if (current?.running)
|
|
12619
|
-
return { ok: false, text: `Already
|
|
12620
|
-
const waiting = a.d.roster.people().filter((p) => p.discordId !== null && usr.links.subjectOf(p.id)
|
|
12655
|
+
return { ok: false, text: `Already ${RUN_WORDS[current.kind].doing}: ${current.done} of ${current.total} done.` };
|
|
12656
|
+
const waiting = a.d.roster.people().filter((p) => p.discordId !== null && wanted(kind, usr.links.subjectOf(p.id)));
|
|
12621
12657
|
if (waiting.length === 0)
|
|
12622
|
-
return { ok: true, text:
|
|
12623
|
-
const run = { running: true, done: 0, total: waiting.length, result: null };
|
|
12658
|
+
return { ok: true, text: RUN_WORDS[kind].none };
|
|
12659
|
+
const run = { kind, running: true, done: 0, total: waiting.length, result: null };
|
|
12624
12660
|
usrLinkRuns.set(usr, run);
|
|
12625
|
-
const pending =
|
|
12661
|
+
const pending = usrRun(a, usr, me.discordId, nameOf(me), a.usrGuildId, waiting.map((p) => p.id), run).catch((err) => {
|
|
12626
12662
|
a.d.log.error("usr link run failed", err);
|
|
12627
|
-
run.result = { ok: false, text:
|
|
12663
|
+
run.result = { ok: false, text: `${RUN_WORDS[kind].stopped}: something went wrong. ${RUN_WORDS[kind].soFar} ${run.done} so far; press again to go on.` };
|
|
12628
12664
|
}).finally(() => {
|
|
12629
12665
|
run.running = false;
|
|
12630
12666
|
usrLinkPending.delete(pending);
|
|
@@ -12635,20 +12671,32 @@ function startLinkEveryone(a, usr, me) {
|
|
|
12635
12671
|
function personsOwn400(err) {
|
|
12636
12672
|
return err.status === 400 && /^(discord_user_id|display_name)\b/.test(err.reason);
|
|
12637
12673
|
}
|
|
12638
|
-
|
|
12674
|
+
function wanted(kind, subject) {
|
|
12675
|
+
return kind === "link" ? subject === null : subject !== null;
|
|
12676
|
+
}
|
|
12677
|
+
async function usrRun(a, usr, invoker, by, guildId, ids, run) {
|
|
12639
12678
|
const member = `${usr.app}:${MEMBER_ROLE}`;
|
|
12679
|
+
const words = RUN_WORDS[run.kind];
|
|
12640
12680
|
let linked = 0;
|
|
12681
|
+
let changed = 0;
|
|
12682
|
+
let created = 0;
|
|
12683
|
+
let skipped = 0;
|
|
12641
12684
|
const problems = [];
|
|
12685
|
+
const counts = () => run.kind === "link" ? "" : ` (${changed} changed, ${created} new usr ${created === 1 ? "account" : "accounts"}, ${problems.length} failed, ${skipped} skipped)`;
|
|
12686
|
+
const soFar = () => `${words.soFar} ${linked} so far${counts()}`;
|
|
12642
12687
|
const end = (ok, stopped) => {
|
|
12643
|
-
const
|
|
12688
|
+
const tally = run.kind === "link" ? `Linked ${linked} of ${ids.length} to usr.` : `Refreshed ${linked} of ${ids.length} usr links: ${changed} changed, ${created} new usr ${created === 1 ? "account" : "accounts"}, ${problems.length} failed, ${skipped} skipped.`;
|
|
12689
|
+
const parts = [stopped ?? tally];
|
|
12690
|
+
if (run.kind === "link" && skipped > 0)
|
|
12691
|
+
parts.push(`Skipped ${skipped}: left the tracker or linked meanwhile.`);
|
|
12644
12692
|
if (problems.length > 0)
|
|
12645
|
-
parts.push(
|
|
12693
|
+
parts.push(`${run.kind === "link" ? "Not linked" : "Not refreshed"}: ${problems.join("; ")}.`);
|
|
12646
12694
|
run.result = { ok: ok && problems.length === 0, text: parts.join(" ") };
|
|
12647
12695
|
};
|
|
12648
|
-
const notAdmin = () => end(false,
|
|
12696
|
+
const notAdmin = () => end(false, `${words.stopped}: ${by}, who started it, is no longer an admin here. ${soFar()}.`);
|
|
12649
12697
|
const stillWaiting = (id) => fresh(a, true, async () => {
|
|
12650
12698
|
const p = await a.d.store.getUser(id);
|
|
12651
|
-
return p && p.discordId !== null && usr.links.subjectOf(id)
|
|
12699
|
+
return p && p.discordId !== null && wanted(run.kind, usr.links.subjectOf(id)) ? p : "skip";
|
|
12652
12700
|
});
|
|
12653
12701
|
for (const id of ids) {
|
|
12654
12702
|
const first = await stillWaiting(id);
|
|
@@ -12656,6 +12704,7 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
|
|
|
12656
12704
|
return notAdmin();
|
|
12657
12705
|
if (first === "skip") {
|
|
12658
12706
|
run.done++;
|
|
12707
|
+
skipped++;
|
|
12659
12708
|
continue;
|
|
12660
12709
|
}
|
|
12661
12710
|
const membership = await a.memberOf(first.discordId);
|
|
@@ -12669,6 +12718,7 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
|
|
|
12669
12718
|
return notAdmin();
|
|
12670
12719
|
if (p === "skip") {
|
|
12671
12720
|
run.done++;
|
|
12721
|
+
skipped++;
|
|
12672
12722
|
continue;
|
|
12673
12723
|
}
|
|
12674
12724
|
const answer = await usr.client.allow({ discordId: p.discordId, guildId, invokerDiscordId: invoker, roles: [member], ...p.displayName ? { displayName: p.displayName } : {} }).catch((err) => err);
|
|
@@ -12681,11 +12731,11 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
|
|
|
12681
12731
|
if (!(answer instanceof UsrError))
|
|
12682
12732
|
a.d.log.error("usr allow failed", answer);
|
|
12683
12733
|
const why = answer instanceof UsrError ? answer.message : "something went wrong";
|
|
12684
|
-
return end(false,
|
|
12734
|
+
return end(false, `${words.stopped}, since it would fail for everyone: ${why}. ${soFar()}; fix that, then press again.`);
|
|
12685
12735
|
}
|
|
12686
12736
|
const allowed = answer;
|
|
12687
12737
|
if (!allowed.roles.includes(member)) {
|
|
12688
|
-
return end(false,
|
|
12738
|
+
return end(false, `${words.stopped}: usr did not give ${nameOf(p)} ${member}. Check that the tracker's usr key's Discord service is set to app ${usr.app} (TRACKER_USR_APP). ${soFar()}.`);
|
|
12689
12739
|
}
|
|
12690
12740
|
const outcome = await fresh(a, true, async () => {
|
|
12691
12741
|
try {
|
|
@@ -12698,9 +12748,15 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
|
|
|
12698
12748
|
if (outcome === null)
|
|
12699
12749
|
return notAdmin();
|
|
12700
12750
|
run.done++;
|
|
12701
|
-
if (outcome === "linked" || outcome === "relinked" || outcome === "unchanged")
|
|
12751
|
+
if (outcome === "linked" || outcome === "relinked" || outcome === "unchanged") {
|
|
12702
12752
|
linked++;
|
|
12703
|
-
|
|
12753
|
+
if (outcome !== "unchanged") {
|
|
12754
|
+
if (allowed.created)
|
|
12755
|
+
created++;
|
|
12756
|
+
else
|
|
12757
|
+
changed++;
|
|
12758
|
+
}
|
|
12759
|
+
} else if (outcome === "taken")
|
|
12704
12760
|
problems.push(`${nameOf(p)}: their usr account is already linked to someone else here`);
|
|
12705
12761
|
else if (outcome === "gone")
|
|
12706
12762
|
problems.push(`${nameOf(p)}: left the tracker meanwhile`);
|
|
@@ -13844,6 +13900,8 @@ function route2(path) {
|
|
|
13844
13900
|
return { kind: "admin-allow" };
|
|
13845
13901
|
if (path === "/admin/usr-link")
|
|
13846
13902
|
return { kind: "admin-usr-link" };
|
|
13903
|
+
if (path === "/admin/usr-refresh")
|
|
13904
|
+
return { kind: "admin-usr-refresh" };
|
|
13847
13905
|
const ceiling = PERSON_CEILING.exec(path);
|
|
13848
13906
|
if (ceiling)
|
|
13849
13907
|
return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
|
|
@@ -13873,7 +13931,7 @@ function route2(path) {
|
|
|
13873
13931
|
return { kind: "act", id, action };
|
|
13874
13932
|
}
|
|
13875
13933
|
function methodsOf(r) {
|
|
13876
|
-
if (r.kind === "logout" || r.kind === "resume-dms" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-usr-link" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
|
|
13934
|
+
if (r.kind === "logout" || r.kind === "resume-dms" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-usr-link" || r.kind === "admin-usr-refresh" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
|
|
13877
13935
|
return "POST";
|
|
13878
13936
|
}
|
|
13879
13937
|
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
|
|
@@ -14034,7 +14092,7 @@ var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
|
|
|
14034
14092
|
var USR_NO_COOKIE = "I could not confirm your usr sign-in. Try again in a minute, or run /web in Discord for a one-time link. If it keeps happening, tell an admin.";
|
|
14035
14093
|
var USR_RECHECK_FAILED = "I could not check that you are a member of this tracker's server. Try again in a minute.";
|
|
14036
14094
|
var USR_NOT_MEMBER = "Your usr account does not have this tracker's member role yet: ask an admin to run /allow for you in the server.";
|
|
14037
|
-
var USR_NOT_LINKED = "Your usr account is not linked to anyone on this tracker yet: ask an admin to run /allow for you in the server, then run /register if you have not. If you were allowed before you finished signing up on usr with a usr account you already had, usr moved you to that account, and a second /allow links it.";
|
|
14095
|
+
var USR_NOT_LINKED = "Your usr account is not linked to anyone on this tracker yet: ask an admin to run /allow for you in the server, then run /register if you have not. If you were allowed before you finished signing up on usr with a usr account you already had, usr moved you to that account, and a second /allow links it, as does an admin's Refresh usr links.";
|
|
14038
14096
|
var USR_BACK = "usr";
|
|
14039
14097
|
var USR_GO = "go";
|
|
14040
14098
|
var USR_IN = "in";
|
|
@@ -14380,6 +14438,7 @@ function createWebHandler(w) {
|
|
|
14380
14438
|
return tokenRevokePost(d, v, r.id);
|
|
14381
14439
|
case "admin-allow":
|
|
14382
14440
|
case "admin-usr-link":
|
|
14441
|
+
case "admin-usr-refresh":
|
|
14383
14442
|
case "admin-act":
|
|
14384
14443
|
case "admin-ceiling":
|
|
14385
14444
|
case "admin-lift":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rackbops/plugin-tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops, one-off research (on the web, or on pages your own browser gathers and sends in through the task API, with a fuller pass on pages the research could not open) and an interest scout (through city-hall, with their findings), a want-list watcher (listing pages you paste, BoardGameGeek through its API once its token is set, and eBay and BoardGameGeek listings you send in through the task API or that eBay's saved-search alert emails forwarded to your alert address carry, which the watcher never opens; 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",
|