@rackbops/plugin-tracker 0.30.0 → 0.31.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -8
  2. package/dist/plugin.js +174 -60
  3. 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` 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. |
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. |
@@ -51,8 +51,8 @@ as in a server (plan 5.5, item 39).
51
51
  | `/tasks` | registered | Your active tasks and the ones you receive, each with its next run; then your paused ones, each naming up to three people it could not DM (then how many more). A list longer than one message (Discord's 2000 characters) is cut between whole lines and keeps your paused tasks in preference to the active ones that do not fit, since their ids are what `/task resume` needs (any paused ones that still do not fit are counted); it says how many active ones it left out and, when the web area is set up, points to `/web`, which lists them all. |
52
52
  | `/task done task` / `/task snooze task [until]` | the task's owner | Answers the task's latest reminder (snooze: an hour, or until `until`). |
53
53
  | `/task decide task choice [amount]` | the renewal's owner | Answers the renewal's latest ask -- keep, cancel or renewed -- with the amount actually paid when it changed (a button cannot carry one). Use it instead of the button, not after it: a run is answered once. |
54
- | `/task history task` | the owner, an accepted recipient, or an admin | The newest 10 runs -- due, status, how it was answered, text replies -- and the newest 5 of the task's changes, older ones counted; sent with Discord's suppress-embeds flag, so a link in a reply, a finding or an error does not unfurl into a preview; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high; for a want-list watch, how many listings it has kept, and once there are any, a second message with the newest of them, up to 20 and as many as fit in one message, then how many more there are (and, while the web area is set up, that the task's page there shows the newest 50). |
55
- | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. |
54
+ | `/task history task` | the owner, an accepted recipient, or an admin | The newest 10 runs -- due, status, how it was answered, text replies -- and the newest 5 of the task's changes, older ones counted (only the shown rows are loaded; SQLite still counts the rest, and for the runs sorts them too); sent with Discord's suppress-embeds flag, so a link in a reply, a finding or an error does not unfurl into a preview; for a renewal, what each period cost and the total; for a price, the last check and the readings with the low and the high; for a want-list watch, how many listings it has kept, and once there are any, a second message with the newest of them, up to 20 and as many as fit in one message, then how many more there are (and, while the web area is set up, that the task's page there shows the newest 50). |
55
+ | `/task share task user` | the task's owner | Invites an admitted, registered member who can be DMed: they get one consent DM with accept and decline. An invitation still open when the task is deleted or finishes is withdrawn, with a history event; a late Accept or Decline is refused with "That task was deleted." or "That task is done." and records nothing (one on a paused task still counts). |
56
56
  | `/task resume task` | the task's owner | Resumes a paused task: one paused because a recipient could not be DMed goes on without that recipient; one its owner paused on the web simply goes on. |
57
57
  | `/settings hour hour` | registered | The preferred hour; recurring reminders with no time of their own move to it. |
58
58
  | `/web` | registered | A one-time link to sign in to the web area (below), good for 10 minutes and one use. Says so when `TRACKER_WEB_URL` is unset. |
@@ -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. |
@@ -702,7 +703,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
702
703
  |---|---|---|
703
704
  | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. Schema 5 (0.9.0) is docket 0.4.0's: a run's `record`, a series point's `key`, `deliveries` (from `delivery_claims`), `usage` and `notices`. Schema 6 (0.11.0) is docket 0.5.0's, all additive: `findings`, a charge's `usage.key`, and the Executor's `executor_jobs`. Forget-me erases a person's findings and their runs' Job records too. Schema 8 (0.19.0) adds `want_inbox`, the listings sent in for an inbox watch. Schema 9 (0.20.0) adds `users.usr_subject`, a person's usr user id, null until linked, unique when set. Schema 10 (0.25.0) adds `mail_addresses`. Schema 11 (0.28.0) adds `research_sources`, the pages a browser sent in for a research request (at most 10 per request, one per address, 99,800 characters of text in all), dropped once the request ends (by the execute tick's housekeeping, so only while the execute lane is configured) and always erased by forget-me. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
704
705
  | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, one pass per task with work (a due run, a run in flight, a DM owed or claimed), each under that task's lock through a view of the store limited to that task: `notify` runs every type but the page readers, `poll` runs only them (`price`, `wantlist`) with the Fetch port, so a slow page never holds up a reminder. A task the execute tick holds is left for the next notify tick, never waited on. Registers `reminder`, `renewal`, `price`, `research`, `scout`, `wantlist` and `wantjudge`. |
705
- | Want-list watcher | `src/want.ts`, `src/inbox.ts`, `src/web/api-listings.ts`; the types and sources are `@rackbops/docket-types`' (0.7.0: `wantlist`, `wantjudge`, `pageSource`, `bggSource`, `inboxSource`) | `/want`'s rules; the `wantlist` type over a `Source` port; the `inbox` source over the `want_inbox` table (schema 8, 0.19.0: the listings sent in per watch, keyed `ebay-` or `bgg-` and 24 random hex digits, the newest 100 kept, as many as one look reads; forget-me erases them with the person's tasks), filled by the task API's listings route; the `page` source (JSON-LD listings through the fenced Fetch port, no HTML guessing) and the `bgg` source (BGG's XML API with the Bearer token, on `boardgamegeek.com`, never `www.`, at least 5 s between requests (a read due sooner is put back for the next tick, never waited on), read by a bounded `indexOf` parser, never a pattern over the whole body; a 401 or 403 is the token refused, a 202, 429 or 5xx a miss). The BGG fixture is hand-written to BGG's documented shape: BGG declined the application, so there is no token to capture a real response with. The judged watch (`wantjudge`) is an execute-lane type: its `prepare` reads the source in plain code (an uncharged `NoJob` when nothing is new), keeps new listings in the task's state as `pending` and asks for a follow-up, whose `prepare` makes the judge Job; `finish` maps the verdicts back by number, so a Job collected after a restart still has its listings. On this lane a BGG read due too soon waits out the spacing once. |
706
+ | Want-list watcher | `src/want.ts`, `src/inbox.ts`, `src/web/api-listings.ts`; the types and sources are `@rackbops/docket-types`' (0.7.0: `wantlist`, `wantjudge`, `pageSource`, `bggSource`, `inboxSource`) | `/want`'s rules; the `wantlist` type over a `Source` port; the `inbox` source over the `want_inbox` table (schema 8, 0.19.0: the listings sent in per watch, keyed `ebay-` or `bgg-` and 24 random hex digits, the newest 100 kept, as many as one look reads; a watch's rows go when it is deleted or finishes (#208), and forget-me erases them with the person's tasks), filled by the task API's listings route; the `page` source (JSON-LD listings through the fenced Fetch port, no HTML guessing) and the `bgg` source (BGG's XML API with the Bearer token, on `boardgamegeek.com`, never `www.`, at least 5 s between requests (a read due sooner is put back for the next tick, never waited on), read by a bounded `indexOf` parser, never a pattern over the whole body; a 401 or 403 is the token refused, a 202, 429 or 5xx a miss). The BGG fixture is hand-written to BGG's documented shape: BGG declined the application, so there is no token to capture a real response with. The judged watch (`wantjudge`) is an execute-lane type: its `prepare` reads the source in plain code (an uncharged `NoJob` when nothing is new), keeps new listings in the task's state as `pending` and asks for a follow-up, whose `prepare` makes the judge Job; `finish` maps the verdicts back by number, so a Job collected after a restart still has its listings. On this lane a BGG read due too soon waits out the spacing once. |
706
707
  | Execute lane | `src/execute-lane.ts`, `src/executor.ts`, `src/research.ts`, `src/research-sources.ts`, `src/web/api-sources.ts`, `src/scout.ts` (the `scout` and `research` types are docket-types'; `research` over `researchType({ readSources })`, 0.8.0, reading a browser request's pages from `research_sources`, and since 0.29.0 a fuller pass's, given only with the web area) | Only when the city-hall Executor is configured: a third host tick, `execute`, that starts docket's `Lanes.tickExecute` in the background (one at a time, at most once per host tick, about a minute) over every task with a due execute-lane run, reserving those tasks and then holding their locks in id order, through one docket `Lanes` kept while the plugin is active (docket keeps its usage-limit pause on it). The Executor is city-hall#18's source pair (above), its I/O injected; `executor_jobs` keeps each Job key's city-hall id. `/research`'s and `/scout`'s rules, and the scout type. |
707
708
  | Budgets, usage | `src/ceilings.ts`, `src/usage.ts`; `src/web/ceiling-pages.ts`, `src/web/usage-pages.ts` | The execute lane's `BudgetPolicy` (docket's defaults plus each person's raise, or no ceiling at all while `TRACKER_BUDGET_UNLIMITED` is on), the ceiling changes, and the pure usage report `/admin/usage` renders from docket's `usage` table. |
708
709
  | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price` and the want-list's `page` source: 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; a redirect to something that is not a web address is refused), 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 -- at most 200 JSON-LD blocks and 300 meta tags, the first ones in page order (room for a shop search page with one JSON-LD block per product beside its other blocks; the `page` source keeps at most 100 listings a page, docket-types' `MAX_LISTINGS`, and a look takes at most 20 new ones, the rest on the next look), 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. Tag names are matched with only `A`-`Z` folded to lower case, so a letter that changes length when lower-cased (the Turkish `İ`) does not shift the tags after it out of reach. 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. A refusal says why -- the name did not resolve, or it is not on the public internet -- so anyone who may make a price tracker or page watch (a command, the web editor or the task API) can learn which names the bot host's resolver maps to private addresses; one read at a time per person and the API's rate limit slow that, they do not stop it. A want-list page read (docket-types' `page` source) carries `NEVER_EBAY` and is refused on any hop to eBay; a price read is not. |
@@ -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): run `/allow` for that person again.
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
@@ -120,13 +120,6 @@ async function visibleOccurrences(store, actor, taskId) {
120
120
  const runs = await store.listOccurrences({ taskId });
121
121
  return seesAll(actor, task) ? runs : runs.map((o) => ({ ...o, record: null }));
122
122
  }
123
- async function visibleReplies(store, actor, taskId) {
124
- const task = await visibleTask(store, actor, taskId);
125
- if (!task)
126
- return null;
127
- const replies = await store.listReplies(taskId);
128
- return seesAll(actor, task) ? replies : replies.filter((r) => r.userId === actor.userId);
129
- }
130
123
  var ABOUT_A_RECIPIENT = new Set([
131
124
  "recipient_invited",
132
125
  "recipient_accepted",
@@ -136,15 +129,6 @@ var ABOUT_A_RECIPIENT = new Set([
136
129
  "blocked",
137
130
  "block_lifted"
138
131
  ]);
139
- async function visibleHistory(store, actor, taskId) {
140
- const task = await visibleTask(store, actor, taskId);
141
- if (!task)
142
- return null;
143
- const events = await store.listTaskEvents(taskId);
144
- if (seesAll(actor, task))
145
- return events;
146
- return events.filter((e) => !ABOUT_A_RECIPIENT.has(e.kind) || e.detail.split(" ")[0] === actor.userId);
147
- }
148
132
  async function visibleSeries(store, actor, taskId, filter) {
149
133
  if (!await visibleTask(store, actor, taskId))
150
134
  return null;
@@ -5014,6 +4998,17 @@ function toDelivery(r) {
5014
4998
  settledAt: str2(r.settled_at)
5015
4999
  };
5016
5000
  }
5001
+ var ABOUT_A_RECIPIENT2 = ["recipient_invited", "recipient_accepted", "recipient_declined", "recipient_opted_out", "recipient_removed", "blocked", "block_lifted"];
5002
+ var PREV_PAUSE_OR_RESUME = "(SELECT x.kind FROM task_events x WHERE x.task_id = e.task_id AND x.kind IN ('paused', 'resumed') AND x.seq < e.seq ORDER BY x.seq DESC LIMIT 1)";
5003
+ function recipientShown(taskId, viewerId) {
5004
+ const about = ABOUT_A_RECIPIENT2.map((k) => `'${k}'`).join(", ");
5005
+ const sql = `e.task_id = ? AND (
5006
+ (e.kind NOT IN ('paused', 'resumed') AND (e.kind NOT IN (${about}) OR e.detail = ? OR instr(e.detail, ?) = 1))
5007
+ OR (e.kind = 'resumed' AND ${PREV_PAUSE_OR_RESUME} = 'paused')
5008
+ OR (e.kind = 'paused' AND NOT (${PREV_PAUSE_OR_RESUME} IS 'paused' AND instr(e.detail, 'delivery to ') = 1 AND instr(e.detail, ?) <> 1))
5009
+ )`;
5010
+ return [sql, [taskId, viewerId, `${viewerId} `, `delivery to {${viewerId}} paused:`]];
5011
+ }
5017
5012
  function deleteDeliveriesOf(db, userId) {
5018
5013
  return db.query("DELETE FROM deliveries WHERE user_id = ?").run(userId).changes;
5019
5014
  }
@@ -5111,6 +5106,21 @@ class SqliteStore {
5111
5106
  };
5112
5107
  this.run(`UPDATE tasks SET title = ?, config = ?, state = ?, schedule = ?, capabilities = ?, status = ?, updated_at = ?
5113
5108
  WHERE seq = ?`, next.title, json(next.config), json(next.state), json(next.schedule), json(next.capabilities), next.status, next.updatedAt, seqOf("t", id));
5109
+ const ended = (status) => status === "done" || status === "archived";
5110
+ if (ended(next.status) && !ended(cur.status)) {
5111
+ this.run("DELETE FROM want_inbox WHERE task_id = ?", id);
5112
+ const why = next.status === "archived" ? "deleted" : "finished";
5113
+ for (const r of this.all("SELECT * FROM task_recipients WHERE task_id = ? AND state = 'invited' ORDER BY seq", id).map(toRecipient)) {
5114
+ this.run("DELETE FROM task_recipients WHERE task_id = ? AND user_id = ?", id, r.userId);
5115
+ this.insertTaskEvent({
5116
+ taskId: id,
5117
+ actorId: null,
5118
+ kind: "recipient_removed",
5119
+ detail: `${r.userId}: the task was ${why}, so the invitation is withdrawn`,
5120
+ at: patch.at
5121
+ });
5122
+ }
5123
+ }
5114
5124
  return toTask(this.mustGet("tasks", "t", id, "task"));
5115
5125
  }
5116
5126
  async listRecipients(taskId) {
@@ -5228,6 +5238,9 @@ class SqliteStore {
5228
5238
  return this.all("SELECT * FROM events WHERE occurrence_id = ? AND seq > ? ORDER BY seq", occurrenceId, cursor ? Number(cursor.seq) : 0).map(toEvent);
5229
5239
  }
5230
5240
  async addTaskEvent(e) {
5241
+ return this.insertTaskEvent(e);
5242
+ }
5243
+ insertTaskEvent(e) {
5231
5244
  const { lastInsertRowid } = this.run("INSERT INTO task_events (task_id, actor_id, kind, detail, at) VALUES (?, ?, ?, ?, ?)", e.taskId, e.actorId, e.kind, e.detail, e.at);
5232
5245
  return toTaskEvent(this.mustGet("task_events", "h", idOf("h", lastInsertRowid), "task event"));
5233
5246
  }
@@ -5241,6 +5254,33 @@ class SqliteStore {
5241
5254
  async listReplies(taskId) {
5242
5255
  return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
5243
5256
  }
5257
+ async runsTail(taskId, now, limit) {
5258
+ const past = "task_id = ? AND NOT (status = 'queued' AND due_at > ?)";
5259
+ const total = Number(this.one(`SELECT COUNT(*) AS n FROM occurrences WHERE ${past}`, taskId, now)?.n ?? 0);
5260
+ const rows = this.all(`SELECT * FROM occurrences WHERE ${past} ORDER BY due_at DESC, created_at DESC, seq DESC LIMIT ?`, taskId, now, limit);
5261
+ const next = this.one("SELECT * FROM occurrences WHERE task_id = ? AND status = 'queued' AND due_at > ? ORDER BY due_at, created_at, seq LIMIT 1", taskId, now);
5262
+ return { rows: rows.reverse().map(toOccurrence), total, next: next ? toOccurrence(next) : null };
5263
+ }
5264
+ async repliesTo(taskId, occurrenceIds, userId) {
5265
+ if (occurrenceIds.length === 0)
5266
+ return [];
5267
+ const ids = `occurrence_id IN (${occurrenceIds.map(() => "?").join(", ")})`;
5268
+ const own = userId === null ? "" : " AND user_id = ?";
5269
+ return this.all(`SELECT * FROM replies WHERE task_id = ? AND ${ids}${own} ORDER BY seq`, taskId, ...occurrenceIds, ...userId === null ? [] : [userId]).map(toReply);
5270
+ }
5271
+ async changesTail(taskId, viewerId, limit) {
5272
+ if (viewerId === null) {
5273
+ const total = Number(this.one("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ?", taskId)?.n ?? 0);
5274
+ const rows = this.all("SELECT * FROM task_events WHERE task_id = ? ORDER BY seq DESC LIMIT ?", taskId, limit).reverse();
5275
+ return { rows: rows.map(toTaskEvent), total, before: null };
5276
+ }
5277
+ const [shown, params] = recipientShown(taskId, viewerId);
5278
+ const total = Number(this.one(`SELECT COUNT(*) AS n FROM task_events e WHERE ${shown}`, ...params)?.n ?? 0);
5279
+ const rows = this.all(`SELECT e.* FROM task_events e WHERE ${shown} ORDER BY e.seq DESC LIMIT ?`, ...params, limit).reverse();
5280
+ const first = rows[0];
5281
+ const before = first ? this.one(`SELECT e.* FROM task_events e WHERE ${shown} AND e.kind IN ('paused', 'resumed') AND e.seq < ? ORDER BY e.seq DESC LIMIT 1`, ...params, Number(first.seq)) : null;
5282
+ return { rows: rows.map(toTaskEvent), total, before: before ? toTaskEvent(before) : null };
5283
+ }
5244
5284
  async addSeriesPoint(p) {
5245
5285
  const key = p.key ?? null;
5246
5286
  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);
@@ -6811,8 +6851,14 @@ function parseCityHallConfig(env) {
6811
6851
  if (capability !== undefined && (capability.length > MAX_CAPABILITY || !CAPABILITY.test(capability))) {
6812
6852
  throw new Error(`${ENV.capability}: "${capability}" is not a capability tag (lowercase words joined by ":" or "-", e.g. claude-cli:subscription)`);
6813
6853
  }
6814
- if (key !== undefined && /\s/.test(key))
6815
- throw new Error(`${ENV.key} has whitespace in it`);
6854
+ for (const [name, v] of [[ENV.key, key], [ENV.accessId, accessId], [ENV.accessSecret, accessSecret]]) {
6855
+ if (v === undefined)
6856
+ continue;
6857
+ if (/\s/.test(v))
6858
+ throw new Error(`${name} has whitespace in it`);
6859
+ if (!/^[!-~]+$/.test(v))
6860
+ throw new Error(`${name} has a character in it that is not visible ASCII (letters, digits and punctuation only)`);
6861
+ }
6816
6862
  const missing = [
6817
6863
  ...origin === undefined ? [ENV.url] : [],
6818
6864
  ...key === undefined ? [ENV.key] : [],
@@ -7168,6 +7214,7 @@ var MAX_WHEN = 100;
7168
7214
  var MAX_NOTE = 300;
7169
7215
  var MAX_URL = 1000;
7170
7216
  var MAX_NEAR = 100;
7217
+ var MAX_AMOUNT = 1e6;
7171
7218
  var CURRENCY_LENGTH = 3;
7172
7219
  var DATE_LENGTH = 10;
7173
7220
  var MAX_ZONE = 64;
@@ -8316,6 +8363,12 @@ var MAX_EVERY = 100;
8316
8363
  function plural3(n, word) {
8317
8364
  return `${n} ${word}${n === 1 ? "" : "s"}`;
8318
8365
  }
8366
+ function amountProblem(amount) {
8367
+ if (!Number.isFinite(amount) || amount < 0 || amount > MAX_AMOUNT) {
8368
+ return `The amount has to be a number from 0 to ${MAX_AMOUNT.toLocaleString("en-US")}.`;
8369
+ }
8370
+ return null;
8371
+ }
8319
8372
  var PERIOD_UNITS = ["year", "month", "week", "day"];
8320
8373
  function renewalPlan(d, user, input) {
8321
8374
  const bad = (error) => ({ ok: false, error });
@@ -8324,8 +8377,9 @@ function renewalPlan(d, user, input) {
8324
8377
  return bad("Say what renews: a subscription, a domain, a warranty.");
8325
8378
  if (name.length > MAX_TITLE)
8326
8379
  return bad(`That name is longer than ${MAX_TITLE} characters.`);
8327
- if (!Number.isFinite(input.amount) || input.amount < 0)
8328
- return bad("The amount has to be zero or more.");
8380
+ const amountBad = amountProblem(input.amount);
8381
+ if (amountBad)
8382
+ return bad(amountBad);
8329
8383
  const currency = input.currency.trim().toUpperCase();
8330
8384
  if (!new RegExp(`^[A-Z]{${CURRENCY_LENGTH}}$`).test(currency))
8331
8385
  return bad("The currency is a three-letter code, such as USD or EUR.");
@@ -8406,8 +8460,9 @@ async function decideRenewal(d, user, input) {
8406
8460
  return NO_SUCH_TASK;
8407
8461
  if (task.type !== "renewal")
8408
8462
  return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
8409
- if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
8410
- return "The amount has to be zero or more.";
8463
+ const amountBad = input.amount === undefined ? null : amountProblem(input.amount);
8464
+ if (amountBad)
8465
+ return amountBad;
8411
8466
  const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
8412
8467
  const refused = await d.locks.turn(task.id, async () => {
8413
8468
  const latest = await latestRun(d, task.id);
@@ -9135,7 +9190,22 @@ async function press(d, user, ref) {
9135
9190
  const taskId = await taskOfRef(d, ref);
9136
9191
  return taskId === null ? pressLocked(d, user, ref) : d.locks.turn(taskId, () => pressLocked(d, user, ref), () => ({ ok: false, error: TASK_BUSY }));
9137
9192
  }
9193
+ async function consentRefusal(d, ref) {
9194
+ const decoded = decodeReplyRef(ref);
9195
+ if (decoded?.kind !== "accept" && decoded?.kind !== "decline")
9196
+ return null;
9197
+ const taskId = await taskOfRef(d, ref);
9198
+ const task = taskId === null ? null : await d.store.getTask(taskId);
9199
+ if (!task || task.status === "archived")
9200
+ return "That task was deleted.";
9201
+ if (task.status === "done")
9202
+ return "That task is done.";
9203
+ return null;
9204
+ }
9138
9205
  async function pressLocked(d, user, ref) {
9206
+ const refused = await consentRefusal(d, ref);
9207
+ if (refused)
9208
+ return { ok: false, error: refused };
9139
9209
  const verdict = await replyForRef(d.store, ref, user.id);
9140
9210
  if (!verdict.ok)
9141
9211
  return { ok: false, error: verdict.error };
@@ -9601,14 +9671,14 @@ async function loadHistory(d, user, taskId) {
9601
9671
  if (!task)
9602
9672
  return null;
9603
9673
  const now = d.clock.now();
9604
- const all = await visibleOccurrences(d.store, actor, task.id) ?? [];
9605
- const upcoming = (o) => o.status === "queued" && Date.parse(o.dueAt) > now.getTime();
9606
- const runs = all.filter((o) => !upcoming(o));
9607
- const next = all.find(upcoming);
9608
- const replies = await visibleReplies(d.store, actor, task.id) ?? [];
9609
9674
  const seesEveryone = user.admin || task.ownerId === user.id;
9610
- const visible = await visibleHistory(d.store, actor, task.id) ?? [];
9611
- const changes = seesEveryone ? visible : changesForRecipient(visible, user.id);
9675
+ const tailed = await d.store.runsTail(task.id, now.toISOString(), HISTORY_RUNS);
9676
+ const runs = seesEveryone ? tailed : { ...tailed, rows: tailed.rows.map((o) => ({ ...o, record: null })) };
9677
+ const next = runs.next;
9678
+ const replies = await d.store.repliesTo(task.id, runs.rows.map((o) => o.id), seesEveryone ? null : user.id);
9679
+ const tail = await d.store.changesTail(task.id, seesEveryone ? null : user.id, HISTORY_CHANGES);
9680
+ const lead = tail.before?.kind === "paused" ? [tail.before] : [];
9681
+ const changes = seesEveryone ? tail.rows : changesForRecipient([...lead, ...tail.rows], user.id).slice(lead.length);
9612
9682
  const people = new Map;
9613
9683
  for (const r of replies) {
9614
9684
  if (!people.has(r.userId)) {
@@ -9633,10 +9703,10 @@ async function loadHistory(d, user, taskId) {
9633
9703
  task,
9634
9704
  cadence: task.schedule && owner ? describeSchedule(task.schedule, owner, user.timeZone, now) : "no schedule",
9635
9705
  next: next ? formatInstant(next.dueAt, user.timeZone, now) : null,
9636
- runs: runs.slice(-HISTORY_RUNS).map((o) => runView(o, replies, people, user, now, seesEveryone)),
9637
- earlierRuns: Math.max(0, runs.length - HISTORY_RUNS),
9638
- changes: changes.slice(-HISTORY_CHANGES).map((e) => changeView(e, user, now, names)),
9639
- earlierChanges: Math.max(0, changes.length - HISTORY_CHANGES),
9706
+ runs: runs.rows.map((o) => runView(o, replies, people, user, now, seesEveryone)),
9707
+ earlierRuns: Math.max(0, runs.total - HISTORY_RUNS),
9708
+ changes: changes.map((e) => changeView(e, user, now, names)),
9709
+ earlierChanges: Math.max(0, tail.total - HISTORY_CHANGES),
9640
9710
  findings: findings.map((f) => findingView(f, user, now))
9641
9711
  };
9642
9712
  }
@@ -9924,7 +9994,7 @@ function createSurface(w) {
9924
9994
  },
9925
9995
  {
9926
9996
  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)),
9997
+ 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
9998
  handle: (interaction) => run(interaction, "registered", (d, user) => {
9929
9999
  const every = interaction.options.getInteger("every");
9930
10000
  const lead = interaction.options.getInteger("lead");
@@ -10026,7 +10096,7 @@ function createSurface(w) {
10026
10096
  },
10027
10097
  {
10028
10098
  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))),
10099
+ 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
10100
  handle: (interaction) => {
10031
10101
  const sub = interaction.options.getSubcommand();
10032
10102
  const taskId = interaction.options.getString("task", true);
@@ -11801,7 +11871,7 @@ function fieldsFor(type, mode, surface = "web") {
11801
11871
  case "renewal":
11802
11872
  return [
11803
11873
  { 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 },
11874
+ { 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
11875
  { name: "currency", label: "Currency", kind: "text", required: true, maxlength: CURRENCY_LENGTH, help: "A three-letter code, such as USD or EUR." },
11806
11876
  { name: "renews", label: "Next renewal or expiry date", kind: "date", required: true },
11807
11877
  { name: "unit", label: "Renews", kind: "select", options: UNIT_OPTIONS },
@@ -12279,10 +12349,13 @@ ${hidden(v)}
12279
12349
  </form>
12280
12350
  </section>
12281
12351
  ${o.usrUnlinked === undefined || o.usrUnlinked === null ? null : html`<section>
12282
- <h2>Link everyone to usr</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.`} Each one is checked as a member of the server and allowed in usr as a member, with you as the admin who allowed them, as <code>/allow</code> does in the server. 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: run <code>/allow</code> for them again.</p>
12284
- ${o.usrRun?.running ? html`<p>Linking everyone to usr: ${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}
12352
+ <h2>usr links</h2>
12353
+ <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>
12354
+ ${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>`}
12355
+ ${!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>`}
12356
+ ${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
12357
  ${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>`}
12358
+ ${!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
12359
  </section>`}
12287
12360
  <section>
12288
12361
  <h2>Decline blocks</h2>
@@ -12471,8 +12544,16 @@ async function peoplePage(a, result, status, allow) {
12471
12544
  const data = { people, blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
12472
12545
  const usr = a.d.usr;
12473
12546
  const usrUnlinked = usr ? people.filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) === null).length : null;
12547
+ const usrLinked = usr ? people.filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) !== null).length : null;
12474
12548
  const usrRun = usr ? usrLinkRuns.get(usr) : undefined;
12475
- return htmlResponse(adminPage(a.v, data, { result, unlimited: a.d.budgetUnlimited === true, usrUnlinked, ...usrRun ? { usrRun } : {}, ...allow !== undefined ? { allow } : {} }), status);
12549
+ return htmlResponse(adminPage(a.v, data, {
12550
+ result,
12551
+ unlimited: a.d.budgetUnlimited === true,
12552
+ usrUnlinked,
12553
+ usrLinked,
12554
+ ...usrRun ? { usrRun } : {},
12555
+ ...allow !== undefined ? { allow } : {}
12556
+ }), status);
12476
12557
  }
12477
12558
  async function onePerson(a, id, result = null) {
12478
12559
  const everyone = a.d.roster.people();
@@ -12533,11 +12614,12 @@ async function adminPost(a, r, form) {
12533
12614
  const ok = text.startsWith("Allowed") || text.includes("already on the list");
12534
12615
  return peoplePage(a, { ok, text }, ok ? 200 : 400);
12535
12616
  }
12536
- if (r.kind === "admin-usr-link") {
12617
+ if (r.kind === "admin-usr-link" || r.kind === "admin-usr-refresh") {
12537
12618
  if (!a.d.usr)
12538
12619
  return unknownPage(base);
12539
12620
  const usr = a.d.usr;
12540
- const result = await fresh(a, true, async (me) => startLinkEveryone(a, usr, me));
12621
+ const kind = r.kind === "admin-usr-link" ? "link" : "refresh";
12622
+ const result = await fresh(a, true, async (me) => startUsrRun(a, usr, me, kind));
12541
12623
  if (result === null)
12542
12624
  return unknownPage(base);
12543
12625
  if (result === "started")
@@ -12606,9 +12688,18 @@ async function adminPost(a, r, form) {
12606
12688
  return redirect(`${base}/`);
12607
12689
  return onePerson({ ...a, v: { ...a.v, deliveryPaused: a.d.health.isPaused(a.v.user.id) } }, r.id, said2(done));
12608
12690
  }
12691
+ var RUN_WORDS = {
12692
+ link: { doing: "linking everyone to usr", stopped: "Linking to usr stopped", soFar: "Linked", none: "Everyone on the list is already linked to usr." },
12693
+ refresh: {
12694
+ doing: "refreshing usr links",
12695
+ stopped: "Refreshing usr links stopped",
12696
+ soFar: "Refreshed",
12697
+ none: "No one on the list is linked to usr yet, so there is nothing to refresh."
12698
+ }
12699
+ };
12609
12700
  var usrLinkRuns = new WeakMap;
12610
12701
  var usrLinkPending = new Set;
12611
- function startLinkEveryone(a, usr, me) {
12702
+ function startUsrRun(a, usr, me, kind) {
12612
12703
  if (a.usrGuildId === null) {
12613
12704
  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
12705
  }
@@ -12616,15 +12707,15 @@ function startLinkEveryone(a, usr, me) {
12616
12707
  return { ok: false, text: "You have no Discord id here, and usr asks who is allowing them by it." };
12617
12708
  const current = usrLinkRuns.get(usr);
12618
12709
  if (current?.running)
12619
- return { ok: false, text: `Already linking everyone to usr: ${current.done} of ${current.total} done.` };
12620
- const waiting = a.d.roster.people().filter((p) => p.discordId !== null && usr.links.subjectOf(p.id) === null);
12710
+ return { ok: false, text: `Already ${RUN_WORDS[current.kind].doing}: ${current.done} of ${current.total} done.` };
12711
+ const waiting = a.d.roster.people().filter((p) => p.discordId !== null && wanted(kind, usr.links.subjectOf(p.id)));
12621
12712
  if (waiting.length === 0)
12622
- return { ok: true, text: "Everyone on the list is already linked to usr." };
12623
- const run = { running: true, done: 0, total: waiting.length, result: null };
12713
+ return { ok: true, text: RUN_WORDS[kind].none };
12714
+ const run = { kind, running: true, done: 0, total: waiting.length, result: null };
12624
12715
  usrLinkRuns.set(usr, run);
12625
- const pending = linkEveryone(a, usr, me.discordId, nameOf(me), a.usrGuildId, waiting.map((p) => p.id), run).catch((err) => {
12716
+ const pending = usrRun(a, usr, me.discordId, nameOf(me), a.usrGuildId, waiting.map((p) => p.id), run).catch((err) => {
12626
12717
  a.d.log.error("usr link run failed", err);
12627
- run.result = { ok: false, text: `Linking to usr stopped: something went wrong. Linked ${run.done} so far; press again to go on.` };
12718
+ 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
12719
  }).finally(() => {
12629
12720
  run.running = false;
12630
12721
  usrLinkPending.delete(pending);
@@ -12635,20 +12726,32 @@ function startLinkEveryone(a, usr, me) {
12635
12726
  function personsOwn400(err) {
12636
12727
  return err.status === 400 && /^(discord_user_id|display_name)\b/.test(err.reason);
12637
12728
  }
12638
- async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
12729
+ function wanted(kind, subject) {
12730
+ return kind === "link" ? subject === null : subject !== null;
12731
+ }
12732
+ async function usrRun(a, usr, invoker, by, guildId, ids, run) {
12639
12733
  const member = `${usr.app}:${MEMBER_ROLE}`;
12734
+ const words = RUN_WORDS[run.kind];
12640
12735
  let linked = 0;
12736
+ let changed = 0;
12737
+ let created = 0;
12738
+ let skipped = 0;
12641
12739
  const problems = [];
12740
+ const counts = () => run.kind === "link" ? "" : ` (${changed} changed, ${created} new usr ${created === 1 ? "account" : "accounts"}, ${problems.length} failed, ${skipped} skipped)`;
12741
+ const soFar = () => `${words.soFar} ${linked} so far${counts()}`;
12642
12742
  const end = (ok, stopped) => {
12643
- const parts = [stopped ?? `Linked ${linked} of ${ids.length} to usr.`];
12743
+ 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.`;
12744
+ const parts = [stopped ?? tally];
12745
+ if (run.kind === "link" && skipped > 0)
12746
+ parts.push(`Skipped ${skipped}: left the tracker or linked meanwhile.`);
12644
12747
  if (problems.length > 0)
12645
- parts.push(`Not linked: ${problems.join("; ")}.`);
12748
+ parts.push(`${run.kind === "link" ? "Not linked" : "Not refreshed"}: ${problems.join("; ")}.`);
12646
12749
  run.result = { ok: ok && problems.length === 0, text: parts.join(" ") };
12647
12750
  };
12648
- const notAdmin = () => end(false, `Linking to usr stopped: ${by}, who started it, is no longer an admin here. Linked ${linked} so far.`);
12751
+ const notAdmin = () => end(false, `${words.stopped}: ${by}, who started it, is no longer an admin here. ${soFar()}.`);
12649
12752
  const stillWaiting = (id) => fresh(a, true, async () => {
12650
12753
  const p = await a.d.store.getUser(id);
12651
- return p && p.discordId !== null && usr.links.subjectOf(id) === null ? p : "skip";
12754
+ return p && p.discordId !== null && wanted(run.kind, usr.links.subjectOf(id)) ? p : "skip";
12652
12755
  });
12653
12756
  for (const id of ids) {
12654
12757
  const first = await stillWaiting(id);
@@ -12656,6 +12759,7 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
12656
12759
  return notAdmin();
12657
12760
  if (first === "skip") {
12658
12761
  run.done++;
12762
+ skipped++;
12659
12763
  continue;
12660
12764
  }
12661
12765
  const membership = await a.memberOf(first.discordId);
@@ -12669,6 +12773,7 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
12669
12773
  return notAdmin();
12670
12774
  if (p === "skip") {
12671
12775
  run.done++;
12776
+ skipped++;
12672
12777
  continue;
12673
12778
  }
12674
12779
  const answer = await usr.client.allow({ discordId: p.discordId, guildId, invokerDiscordId: invoker, roles: [member], ...p.displayName ? { displayName: p.displayName } : {} }).catch((err) => err);
@@ -12681,11 +12786,11 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
12681
12786
  if (!(answer instanceof UsrError))
12682
12787
  a.d.log.error("usr allow failed", answer);
12683
12788
  const why = answer instanceof UsrError ? answer.message : "something went wrong";
12684
- return end(false, `Linking to usr stopped, since it would fail for everyone: ${why}. Linked ${linked} so far; fix that, then press again.`);
12789
+ return end(false, `${words.stopped}, since it would fail for everyone: ${why}. ${soFar()}; fix that, then press again.`);
12685
12790
  }
12686
12791
  const allowed = answer;
12687
12792
  if (!allowed.roles.includes(member)) {
12688
- return end(false, `Linking to usr 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). Linked ${linked} so far.`);
12793
+ 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
12794
  }
12690
12795
  const outcome = await fresh(a, true, async () => {
12691
12796
  try {
@@ -12698,9 +12803,15 @@ async function linkEveryone(a, usr, invoker, by, guildId, ids, run) {
12698
12803
  if (outcome === null)
12699
12804
  return notAdmin();
12700
12805
  run.done++;
12701
- if (outcome === "linked" || outcome === "relinked" || outcome === "unchanged")
12806
+ if (outcome === "linked" || outcome === "relinked" || outcome === "unchanged") {
12702
12807
  linked++;
12703
- else if (outcome === "taken")
12808
+ if (outcome !== "unchanged") {
12809
+ if (allowed.created)
12810
+ created++;
12811
+ else
12812
+ changed++;
12813
+ }
12814
+ } else if (outcome === "taken")
12704
12815
  problems.push(`${nameOf(p)}: their usr account is already linked to someone else here`);
12705
12816
  else if (outcome === "gone")
12706
12817
  problems.push(`${nameOf(p)}: left the tracker meanwhile`);
@@ -13844,6 +13955,8 @@ function route2(path) {
13844
13955
  return { kind: "admin-allow" };
13845
13956
  if (path === "/admin/usr-link")
13846
13957
  return { kind: "admin-usr-link" };
13958
+ if (path === "/admin/usr-refresh")
13959
+ return { kind: "admin-usr-refresh" };
13847
13960
  const ceiling = PERSON_CEILING.exec(path);
13848
13961
  if (ceiling)
13849
13962
  return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
@@ -13873,7 +13986,7 @@ function route2(path) {
13873
13986
  return { kind: "act", id, action };
13874
13987
  }
13875
13988
  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") {
13989
+ 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
13990
  return "POST";
13878
13991
  }
13879
13992
  if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
@@ -14034,7 +14147,7 @@ var LEFT_SERVER = `You are signed out. ${NOT_MEMBER}`;
14034
14147
  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
14148
  var USR_RECHECK_FAILED = "I could not check that you are a member of this tracker's server. Try again in a minute.";
14036
14149
  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.";
14150
+ 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
14151
  var USR_BACK = "usr";
14039
14152
  var USR_GO = "go";
14040
14153
  var USR_IN = "in";
@@ -14380,6 +14493,7 @@ function createWebHandler(w) {
14380
14493
  return tokenRevokePost(d, v, r.id);
14381
14494
  case "admin-allow":
14382
14495
  case "admin-usr-link":
14496
+ case "admin-usr-refresh":
14383
14497
  case "admin-act":
14384
14498
  case "admin-ceiling":
14385
14499
  case "admin-lift":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.30.0",
3
+ "version": "0.31.2",
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",