From a7e2a4eb62c0bd45d564c6f4ce82d82401016efb Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:19:13 -0700 Subject: [PATCH] Move the console to Signalbox, add describe, drop the Stationmaster names Usher 0.4.0. Console commands use the shared Signalbox format: people get plain words, tools add --json and get one ea-result line. Questions that need a yes come back as needs and are answered with --yes. apply takes plain positional arguments; the +profile/-profile, confirm=, whitelist= and via= forms and usher-result lines are gone. describe lists every command, with optional arguments marked (Signalbox 0.2.0, asked for by Leaf Juice), and "none" is reserved so it can't be a profile name. The stationmaster/smaster commands and the stationmaster.approve permission are removed, as 0.3.x promised; the old folder is still copied across on first start. Signalbox is shaded and relocated, and the shaded jar is the only one built. CI follows the Actions minutes rules, with the leak check in a workflow of its own that runs on every change. Bot suite: 120/120 on Paper 26.1.2, 26.2 and 26.3. 50 unit tests. Not tried on a real server or client. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 9 + .github/workflows/leak-check.yml | 21 + .github/workflows/release.yml | 5 + README.md | 34 +- RELEASE_NOTES.md | 36 +- build.gradle.kts | 13 +- docs/decisions.md | 1 + .../com/extantape/usher/ChangeService.java | 13 +- .../com/extantape/usher/ConsoleCommands.java | 416 +++++++++++------- .../java/com/extantape/usher/Settings.java | 2 + .../java/com/extantape/usher/UsherPlugin.java | 14 - src/main/resources/paper-plugin.yml | 3 - .../extantape/usher/ConsoleCommandsTest.java | 70 +++ .../com/extantape/usher/SettingsTest.java | 8 + test/acceptance.mjs | 31 +- test/harness.mjs | 57 ++- 16 files changed, 502 insertions(+), 231 deletions(-) create mode 100644 .github/workflows/leak-check.yml create mode 100644 src/test/java/com/extantape/usher/ConsoleCommandsTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5843601..63b0fc9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,11 +2,20 @@ name: Build on: push: + branches: [main] + paths-ignore: ['**.md', 'docs/**'] pull_request: + paths-ignore: ['**.md', 'docs/**'] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Leak check (emails and build output; private terms are only checked locally) diff --git a/.github/workflows/leak-check.yml b/.github/workflows/leak-check.yml new file mode 100644 index 0000000..9a0123b --- /dev/null +++ b/.github/workflows/leak-check.yml @@ -0,0 +1,21 @@ +name: Leak check + +# Every change, docs included: the build skips docs-only changes, and README or docs text is where a stray +# email address most often slips in. A few seconds on ubuntu, no Gradle. +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + leak-check: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - run: bash scripts/leak-check.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ac5187..a8f4ccf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,14 @@ on: permissions: contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 diff --git a/README.md b/README.md index f21cb9b..0308241 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ Usher was called Stationmaster up to 0.2.1. It was renamed in 0.3.0; nothing els - Remove `Stationmaster-0.2.1.jar` and add `Usher-0.3.0.jar`, with the server stopped. - On its first start, Usher copies `plugins/Stationmaster/` to `plugins/Usher/` if `plugins/Usher/` doesn't exist yet: your config, `changes.log` and `join-attempts.json` come across unchanged. The old folder is left as it was; delete it once you're happy. If `plugins/Usher/` already exists, nothing is copied. -- For 0.3.x only, the old names still work and log a line saying what to change: the permission `stationmaster.approve` and the console command `stationmaster` (also `smaster`). Both go in the next release. Grant `usher.approve` instead: `lp group permission set usher.approve true`. -- The console result line is now `usher-result`, and the API package is `com.extantape.usher.api` (`UsherProblemEvent` was `StationmasterProblemEvent`). Nothing published used the old ones. +- The old names went in 0.4.0, as 0.3.x said: the permission `stationmaster.approve` and the console command `stationmaster` (also `smaster`) no longer work. Grant `usher.approve` instead: `lp group permission set usher.approve true`. +- The API package is `com.extantape.usher.api` (`UsherProblemEvent` was `StationmasterProblemEvent`). ## Commands @@ -99,34 +99,40 @@ Usher was called Stationmaster up to 0.2.1. It was renamed in 0.3.0; nothing els | `/profile ` | approvers, console | Shows someone's profiles, with a button to change them. | | `/profile edit` | approvers | Opens the menu to change their profiles. | | `/usher reload` | approvers, console | Re-reads `config.yml`. | -| `usher …` | console | The commands below, for Leaf Juice and scripts. | +| `usher …` | console | The commands below, for Leaf Juice, scripts and people. | Approvers need `usher.approve`. It's `false` by default, operators included. ## Console commands -Each takes a request ID you choose (1 to 64 letters, digits, `.`, `_`, `:` or `-`). Each answers with exactly one line, `usher-result `, sometimes a moment later. Every answer has `status` (`ok`, `warn`, `refused` or `error`), `code` and a `message` that's fine to show a person. +They use the shared [Signalbox](https://github.com/ExtantApe/Signalbox) format. A person types them as they are and gets an answer in plain words. A tool adds `--json ` at the end, with an id it chooses (1 to 36 of `a-z`, `0-9` and `-`), and gets exactly one line back, `ea-result `, sometimes a moment later: + +- done: `{"id":…,"ok":true, …fields}` +- refused or failed: `{"id":…,"ok":false,"error":{"code":…,"message":…}}`, with a `message` that's fine to show a person +- needs a yes: `{"id":…,"ok":false,"needs":[{"id":…,"question":…}]}`, and nothing changed | Command | Answers with | | --- | --- | -| `profiles ` | The profiles that can be offered, restricted groups, and config problems. | -| `resolve ` | The account's `uuid`, correctly capitalized `name`, `whitelisted`, `playedBefore`, and `triedToJoin` (how many times the whitelist turned it away) with `lastTried`. Warn before approving an account that has neither played nor tried. | -| `show ` | Their `profiles`, profiles they get `throughOtherGroups`, their `circles`, and `whitelisted`. | -| `apply [+profile] [-profile] [confirm=code,code] [whitelist=no] [via=leaf-juice]` | Adds and removes profiles, then whitelists them unless `whitelist=no`. | -| `attempts ` | People turned away by the whitelist: name, uuid, count, first and last try. | -| `log [count]` | The last entries in `changes.log`, newest last. | +| `profiles` | The profiles that can be offered, restricted groups, and config problems. | +| `resolve ` | The account's `uuid`, correctly capitalized `name`, `whitelisted`, `playedBefore`, and `triedToJoin` (how many times the whitelist turned it away) with `lastTried`. Warn before approving an account that has neither played nor tried. | +| `show ` | Their `profiles`, profiles they get `throughOtherGroups`, their `circles`, and `whitelisted`. | +| `apply [whitelist] [via]` | Adds and removes profiles (comma-separated lists), then whitelists them unless `whitelist` is `false`. `via` names who asked, such as `leaf-juice`. | +| `attempts [limit] [cursor]` | People turned away by the whitelist: name, uuid, count, first and last try. `next` is the cursor for the next page. | +| `log [limit]` | The last entries in `changes.log`, newest last. | +| `reload` | Re-reads `config.yml`. | +| `describe` | Every command with its typed arguments and its questions, format 1. | How `apply` works: -- If a change needs a yes, the answer is `status: warn` with `warnings` (each has a `code` and `text`), and nothing changes. To go ahead, send the same command again with `confirm=` and those codes, such as `confirm=bridge`. -- Sending the same request ID again after it went through returns the first answer with `replayed: true`. It isn't applied twice. +- If a change needs a yes, the answer lists the questions and nothing changes. To go ahead, send the same command again with `--yes` and their ids, such as `--yes bridge` or `--yes confirm-grown-sky`. +- Sending the same `--json` id again after it went through returns the first answer with `replayed: true`. It isn't applied twice. - Changes from the console are logged with `actor: Console` and the `via` you give. Example: ``` -> usher apply r42 069a79f4-44e9-4726-a5be-fca90e38aaf5 Notch +builders via=leaf-juice -usher-result r42 {"status":"ok","code":"applied","message":"Notch is approved and can join now, with Builders.", ...} +> usher apply 069a79f4-44e9-4726-a5be-fca90e38aaf5 Notch builders none true leaf-juice --json r42 +ea-result r42 {"id":"r42","ok":true,"status":"ok","code":"applied","message":"Notch is approved and can join now, with Builders.", ...} ``` ## Events diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 46af7fb..8f6ff61 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,22 +1,36 @@ -# Usher 0.3.1 +# Usher 0.4.0 -Usher now works with Latchkey 0.2.0's invites. Nothing else changes, and Usher still runs the same without Latchkey. +Usher's console commands move to the shared Signalbox format, so Leaf Juice and scripts talk to Usher the same way as to Latchkey. The old Stationmaster names are gone, as 0.3.x said they would be. This is an early 0.x release. It has passed bot tests on throwaway servers, but it hasn't been used on a real server or seen in a real game client. -## New: profiles on Latchkey invites +## Changed -- **When an admin makes an invite that names profiles,** Usher checks them straight away with its usual rules. Unknown or restricted profiles are refused, so a bad invite is never made. Anything that needs a yes, such as joining two chat circles or a profile with a `confirm` question, comes back as a question. The admin answers it with `--yes` on the same command. -- **When the invite is used,** Usher applies the profiles through the same path as any approval, with the yeses given when the invite was made. The change log records it as "Latchkey invite". If something has changed since, nothing is applied and the server log says why. -- **An invite with no profiles** gives lobby-only access until an admin adds more. -- Usher doesn't need Latchkey to build or run. It finds Latchkey's events by name when Latchkey is installed. +- **Console commands use [Signalbox](https://github.com/ExtantApe/Signalbox) format 1.** + - A person types `usher …` and gets plain words. + - A tool adds `--json ` and gets exactly one line back, `ea-result `. + - Questions that need a yes come back as `needs` and change nothing; answer them with `--yes`, such as `--yes bridge`. + - `describe` lists every command with its typed arguments, marking the optional ones. + - The README's "Console commands" section has the full list. +- **`apply` takes plain positional arguments:** `apply [whitelist] [via]`, with profile lists comma-separated. The old `+profile`, `-profile`, `confirm=`, `whitelist=` and `via=` forms are gone. +- **The request id is the `--json` id** and still makes a change safe to resend: it's applied once and answered again with `replayed: true`. +- **Error codes use dashes:** `unknown-profile`, `luckperms-missing` and so on. +- **A profile can't be called `none`,** since commands use `none` for an empty list. A config that has one leaves that profile out and says why. -## Upgrading from 0.3.0 +## Removed -Swap the jar with the server stopped. Nothing else changes. The old names from Stationmaster (`stationmaster.approve`, the `stationmaster` and `smaster` commands) still work in 0.3.x, as promised, and go in the next release. +- `usher-result` lines. Nothing published read them. +- The `stationmaster` and `smaster` commands, and the `stationmaster.approve` permission. Grant `usher.approve` instead: `lp group permission set usher.approve true`. + +## Upgrading from 0.3.x + +- Swap the jar with the server stopped. +- If anything still grants `stationmaster.approve`, grant `usher.approve` too, or that admin can no longer approve. +- Scripts that call `usher` need the new form. +- The old `plugins/Stationmaster/` folder is still copied across on first start if `plugins/Usher/` doesn't exist. ## Tested -- 43 unit tests pass, including 9 new ones for the invite checks. -- Latchkey's bot test runs this code with Latchkey 0.2.0 on throwaway Paper 26.1.2, 26.2 and 26.3 servers, and passes 38/38 on each. It covers a refused profile, a question answered with `--yes`, and both profiles applied when the invite is used. +- 50 unit tests pass, including the argument rules for `apply`, the reserved `none` and the optional markers in `describe`. +- The bot suite drives every console command in the new format and passes 120/120 on throwaway Paper 26.1.2, 26.2 and 26.3 servers. It covers the raw `ea-result` line, `unknown-command` and `bad-argument` answers, `describe`, questions answered with `--yes`, resending an id, a kill -9 mid-change, and the old Stationmaster names being gone. - Not tested yet: a real server or client. diff --git a/build.gradle.kts b/build.gradle.kts index c114ce8..4193bb0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,10 @@ plugins { java + id("com.gradleup.shadow") version "9.6.1" } group = "com.extantape" -version = "0.3.1" +version = "0.4.0" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) @@ -12,6 +13,7 @@ java { repositories { mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") } val paperApi = "io.papermc.paper:paper-api:26.1.2.build.74-stable" @@ -20,6 +22,7 @@ val luckPermsApi = "net.luckperms:api:5.4" dependencies { compileOnly(paperApi) compileOnly(luckPermsApi) + implementation("com.github.ExtantApe:Signalbox:v0.2.0") testImplementation(paperApi) testImplementation(luckPermsApi) @@ -44,10 +47,16 @@ tasks.test { useJUnitPlatform() } -tasks.jar { +// Signalbox goes inside the jar, relocated, so it can never clash with another plugin's copy. The shaded jar is the +// only one built, so the release workflow's Usher-*.jar finds exactly one. +tasks.shadowJar { archiveBaseName.set("Usher") + archiveClassifier.set("") from("LICENSE") + relocate("com.extantape.signalbox", "com.extantape.usher.libs.signalbox") } +tasks.jar { enabled = false } +tasks.assemble { dependsOn(tasks.shadowJar) } // Test only: a tiny plugin that logs Usher's events, for the bot tests in test/. Never shipped. val spy: SourceSet = sourceSets.create("spy") { diff --git a/docs/decisions.md b/docs/decisions.md index db29728..594cac1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -36,3 +36,4 @@ Decisions the owner made for Stationmaster, with dates. Follow them without aski - **Name (2026-11-08, owner):** Stationmaster is renamed Usher, one word in every place: repo, plugin name, jar, package and prose. Released as 0.3.0. Existing servers get their `plugins/Stationmaster/` folder copied on first start (never overwritten), and the old permission and console command keep working for one release with a log warning. The `stationmaster-result` console line became `usher-result` outright, since Leaf Juice doesn't parse it yet. - **Invites (2026-11-09, owner):** when a Latchkey invite is redeemed, the username is whitelisted at once and Usher, if installed, applies the profile the invite names, through its usual safety rules. To be built with Latchkey 0.2.0 (crossing `latchkey-usher`). - **Profiles on invites (2026-11-09, owner):** a Latchkey invite carries a list of Usher profiles. Usher runs its safety checks when the invite is created, so a bad combination is refused before anyone arrives, and applies the profiles when it's redeemed. An invite with no profiles gives lobby-only access until an admin adds more. Usher's commands get a machine-readable JSON form and `/usher describe`, through the shared command library. Waystation and HouseRules need nothing new: their platforms and circles are already permissions a profile can carry. +- **Console format and the old names (2026-11-10, owner through Helm):** 0.4.0 moves the console commands to the shared Signalbox format (`--json `, `ea-result`, `describe`) and removes `stationmaster`, `smaster` and `stationmaster.approve`, as 0.3.x promised. Leaf Juice's asks (crossing plugins-leafjuice, ledger 10f277027b09): `none` is reserved and can't be a profile name, and `describe` marks optional arguments (Signalbox 0.2.0). diff --git a/src/main/java/com/extantape/usher/ChangeService.java b/src/main/java/com/extantape/usher/ChangeService.java index 952dae5..49d4dfa 100644 --- a/src/main/java/com/extantape/usher/ChangeService.java +++ b/src/main/java/com/extantape/usher/ChangeService.java @@ -31,19 +31,10 @@ public final class ChangeService { public static final String PERMISSION = "usher.approve"; - /** The node from before the rename (2026-11-09). Honoured for one release (0.3.x), then removed. */ - public static final String OLD_PERMISSION = "stationmaster.approve"; - private static final java.util.Set warnedOld = java.util.concurrent.ConcurrentHashMap.newKeySet(); - static volatile java.util.logging.Logger log; - /** True for approvers. Accepts the old node too, for one release, and logs once per name that it should move. */ + /** True for approvers. The old Stationmaster node stopped working in 0.4.0, as 0.3.x said it would. */ public static boolean mayApprove(org.bukkit.permissions.Permissible who) { - if (who.hasPermission(PERMISSION)) return true; - if (!who.hasPermission(OLD_PERMISSION)) return false; - String name = who instanceof org.bukkit.command.CommandSender cs ? cs.getName() : String.valueOf(who); - if (warnedOld.add(name) && log != null) - log.warning(name + " approves through the old permission " + OLD_PERMISSION + ". It still works in 0.3.x but goes in the next release: grant " + PERMISSION + " instead."); - return true; + return who.hasPermission(PERMISSION); } /** Who asked. {@code uuid} is null for the console. {@code via} is menu, console or leaf-juice. */ diff --git a/src/main/java/com/extantape/usher/ConsoleCommands.java b/src/main/java/com/extantape/usher/ConsoleCommands.java index 5038e32..bf56c71 100644 --- a/src/main/java/com/extantape/usher/ConsoleCommands.java +++ b/src/main/java/com/extantape/usher/ConsoleCommands.java @@ -1,35 +1,44 @@ package com.extantape.usher; -import com.google.gson.Gson; +import com.extantape.signalbox.Answer; +import com.extantape.signalbox.Args; +import com.extantape.signalbox.Confirm; +import com.extantape.signalbox.Describe; +import com.extantape.signalbox.Errors; +import com.extantape.signalbox.Request; import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import org.bukkit.Bukkit; +import com.google.gson.JsonPrimitive; import io.papermc.paper.command.brigadier.BasicCommand; import io.papermc.paper.command.brigadier.CommandSourceStack; +import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; import org.bukkit.command.ConsoleCommandSender; import org.bukkit.command.RemoteConsoleCommandSender; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; /** - * /usher: console commands for Leaf Juice and scripts. Each takes a request ID and answers with exactly one - * line, {@code usher-result }, which may come a moment later. See README for the fields. + * /usher: console commands for Leaf Juice, scripts and people. With {@code --json } each answers with exactly one + * Signalbox line, {@code ea-result }, which may come a moment later; without it, in plain words. Changes that + * need a yes answer with the questions and change nothing; send them again with {@code --yes id,id}. See the README. */ final class ConsoleCommands implements BasicCommand { - - static final String PREFIX = "usher-result "; - private static final Pattern REQUEST = Pattern.compile("[A-Za-z0-9._:-]{1,64}"); + static final List SUBS = List.of("profiles", "resolve", "show", "apply", "attempts", "log", "reload", "describe"); private static final Pattern VIA = Pattern.compile("[a-z0-9-]{1,32}"); - private static final Gson GSON = new Gson(); - private static final List SUBS = List.of("profiles", "resolve", "show", "apply", "attempts", "log", "reload"); + static final int MAX_LIMIT = 100; private final UsherPlugin plugin; @@ -41,208 +50,315 @@ final class ConsoleCommands implements BasicCommand { static boolean isConsole(CommandSender sender) { return sender instanceof ConsoleCommandSender || sender instanceof RemoteConsoleCommandSender; } + static List described() { + String a = ChangeService.PERMISSION; + return List.of( + new Describe.Command("profiles", "console", List.of(), List.of(), false), + new Describe.Command("resolve", "console", List.of(Describe.Arg.of("name", "player")), List.of(), false), + new Describe.Command("show", "console", List.of(Describe.Arg.of("player", "player")), List.of(), false), + new Describe.Command("apply", "console", List.of(Describe.Arg.of("uuid", "uuid"), Describe.Arg.of("name", "player"), + Describe.Arg.listOf("add", "string", List.of("none")), Describe.Arg.listOf("remove", "string", List.of("none")), + Describe.Arg.of("whitelist", "bool").asOptional(), Describe.Arg.of("via", "string").asOptional()), List.of("confirm-", "bridge"), false), + new Describe.Command("attempts", "console", List.of(Describe.Arg.of("limit", "int").asOptional(), Describe.Arg.of("cursor", "int").asOptional()), List.of(), false), + new Describe.Command("log", "console", List.of(Describe.Arg.of("limit", "int").asOptional()), List.of(), false), + new Describe.Command("reload", a, List.of(), List.of(), false), + new Describe.Command("describe", "console", List.of(), List.of(), false)); + } + @Override public void execute(CommandSourceStack source, String[] args) { CommandSender sender = source.getSender(); boolean console = isConsole(sender); - if (args.length == 0) { - sender.sendMessage("Usher " + plugin.getPluginMeta().getVersion() + ": " + String.join(", ", SUBS) + ". See the README for details."); + Request r; + try { + r = Request.parse(args); + } catch (Request.Bad bad) { + fail(sender, bad.id, bad.code, bad.getMessage()); return; } - String sub = args[0].toLowerCase(Locale.ROOT); - if (sub.equals("reload")) { - if (!console && !ChangeService.mayApprove(sender)) { sender.sendMessage(PlayerCommands.bad("Only approvers can do that.")); return; } - for (String line : plugin.reload()) sender.sendMessage(line); + List t = r.tokens(); + if (t.isEmpty()) { + if (r.json()) fail(sender, r.id(), Errors.UNKNOWN_COMMAND, "Give a command: " + String.join(", ", SUBS) + "."); + else sender.sendMessage("Usher " + plugin.getPluginMeta().getVersion() + ": " + String.join(", ", SUBS) + ". See the README for details."); return; } - if (!console) { - sender.sendMessage(PlayerCommands.bad("Those are console commands. In the game, use /approve or /profile .")); + String sub = t.get(0).toLowerCase(Locale.ROOT); + List rest = t.subList(1, t.size()); + if (sub.equals("reload")) { + if (!console && !ChangeService.mayApprove(sender)) { fail(sender, r.id(), Errors.NO_PERMISSION, "Only approvers can do that."); return; } + List lines = plugin.reload(); + if (r.json()) send(sender, Answer.ok(r.id(), Map.of("messages", lines, "configOk", plugin.settings().ok()))); + else for (String line : lines) sender.sendMessage(line); return; } - if (!SUBS.contains(sub)) { sender.sendMessage("Unknown: " + sub + ". Try " + String.join(", ", SUBS) + "."); return; } - if (args.length < 2 || !REQUEST.matcher(args[1]).matches()) { - reply(sender, "-", error("bad_request", "Give a request ID first: 1 to 64 letters, digits, '.', '_', ':' or '-'.")); + if (!console) { + fail(sender, r.id(), Errors.NO_PERMISSION, "Those are console commands. In the game, use /approve or /profile ."); return; } - String req = args[1]; - String[] rest = java.util.Arrays.copyOfRange(args, 2, args.length); - switch (sub) { - case "profiles" -> reply(sender, req, profiles()); - case "resolve" -> resolve(sender, req, rest); - case "show" -> show(sender, req, rest); - case "apply" -> apply(sender, req, rest); - case "attempts" -> reply(sender, req, attempts()); - case "log" -> log(sender, req, rest); - default -> { } + try { + switch (sub) { + case "profiles" -> { none(rest); profiles(sender, r); } + case "resolve" -> resolve(sender, r, rest); + case "show" -> show(sender, r, rest); + case "apply" -> apply(sender, r, rest); + case "attempts" -> attempts(sender, r, rest); + case "log" -> log(sender, r, rest); + case "describe" -> { + none(rest); + if (r.json()) send(sender, Answer.ok(r.id(), Describe.body("Usher", plugin.getPluginMeta().getVersion(), described()))); + else for (Describe.Command d : described()) sender.sendMessage("usher " + d.name()); + } + default -> fail(sender, r.id(), Errors.UNKNOWN_COMMAND, "Unknown: " + sub + ". Try " + String.join(", ", SUBS) + "."); + } + } catch (Request.Bad bad) { + fail(sender, r.id(), bad.code, bad.getMessage()); + } catch (Refusal no) { + fail(sender, r.id(), no.code, no.getMessage()); } } - private void reply(CommandSender sender, String req, JsonObject json) { - plugin.runMain(() -> sender.sendMessage(PREFIX + req + " " + GSON.toJson(json))); + /** A command refused with a Signalbox error code and plain words. */ + static final class Refusal extends Exception { + final String code; + Refusal(String code, String message) { super(message); this.code = code; } } - static JsonObject error(String code, String message) { - JsonObject o = new JsonObject(); - o.addProperty("status", "error"); - o.addProperty("code", code); - o.addProperty("message", message); - return o; + private static void none(List rest) throws Refusal { + if (!rest.isEmpty()) throw new Refusal(Errors.BAD_ARGUMENT, "That command takes no arguments."); } - private JsonObject ok() { - JsonObject o = new JsonObject(); - o.addProperty("status", "ok"); - return o; - } + // --- profiles --- - private JsonObject profiles() { + private void profiles(CommandSender sender, Request r) { Settings s = plugin.settings(); - JsonObject o = s.ok() ? ok() : error("config_broken", s.problems().get(0)); - o.addProperty("luckperms", plugin.store().ready()); - JsonArray list = new JsonArray(); + if (!s.ok()) { fail(sender, r.id(), "config-broken", s.problems().get(0)); return; } + List> list = new ArrayList<>(); for (Profile p : s.profiles().values()) { - JsonObject j = new JsonObject(); - j.addProperty("id", p.id()); - j.addProperty("label", p.label()); - j.addProperty("group", p.group()); - j.addProperty("toggle", p.toggle()); - if (p.circle() != null) j.addProperty("circle", p.circle()); - if (p.confirm() != null) j.addProperty("confirm", p.confirm()); - j.add("worlds", ChangeService.strings(p.worlds())); - j.add("description", ChangeService.strings(p.description())); + Map j = new LinkedHashMap<>(); + j.put("id", p.id()); + j.put("label", p.label()); + j.put("group", p.group()); + j.put("toggle", p.toggle()); + if (p.circle() != null) j.put("circle", p.circle()); + if (p.confirm() != null) j.put("confirm", p.confirm()); + j.put("worlds", List.copyOf(p.worlds())); + j.put("description", List.copyOf(p.description())); list.add(j); } - o.add("profiles", list); - o.add("restricted", ChangeService.strings(s.restricted())); - o.add("problems", ChangeService.strings(s.problems())); - return o; + if (r.json()) { + Map o = new LinkedHashMap<>(); + o.put("luckperms", plugin.store().ready()); + o.put("profiles", list); + o.put("restricted", List.copyOf(s.restricted())); + o.put("problems", List.copyOf(s.problems())); + send(sender, Answer.ok(r.id(), o)); + return; + } + if (!plugin.store().ready()) sender.sendMessage("LuckPerms isn't running, so no change can be made."); + for (Profile p : s.profiles().values()) sender.sendMessage(p.id() + ": " + p.label() + " (group " + p.group() + ")"); } - private void resolve(CommandSender sender, String req, String[] rest) { - if (rest.length != 1) { reply(sender, req, error("bad_request", "Use: usher resolve ")); return; } - plugin.names().resolve(rest[0], plugin.settings().lookup()).whenComplete((who, e) -> { - if (e != null) { reply(sender, req, nameError(e, rest[0])); return; } + // --- resolve and show --- + + private void resolve(CommandSender sender, Request r, List rest) throws Request.Bad, Refusal { + if (rest.size() != 1) throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher resolve "); + String typed = Args.player(rest.get(0)); + plugin.names().resolve(typed, plugin.settings().lookup()).whenComplete((who, e) -> { + if (e != null) { nameFail(sender, r, e, typed); return; } plugin.runMain(() -> { - JsonObject o = ok(); - o.addProperty("uuid", who.id().toString()); - o.addProperty("name", who.name()); var seen = Bukkit.getOfflinePlayer(who.id()); - o.addProperty("whitelisted", seen.isWhitelisted()); - // So Leaf Juice can warn before approving an account that has never come near this server. - o.addProperty("playedBefore", seen.hasPlayedBefore()); JoinAttempts.Attempt tried = plugin.attempts().of(who.id()); - o.addProperty("triedToJoin", tried == null ? 0 : tried.attempts); - if (tried != null) o.addProperty("lastTried", tried.last); - reply(sender, req, o); + Map o = new LinkedHashMap<>(); + o.put("uuid", who.id().toString()); + o.put("name", who.name()); + o.put("whitelisted", seen.isWhitelisted()); + // So Leaf Juice can warn before approving an account that has never come near this server. + o.put("playedBefore", seen.hasPlayedBefore()); + o.put("triedToJoin", tried == null ? 0 : tried.attempts); + if (tried != null) o.put("lastTried", tried.last); + if (r.json()) send(sender, Answer.ok(r.id(), o)); + else sender.sendMessage(who.name() + " is " + who.id() + (seen.isWhitelisted() ? ", whitelisted" : ", not whitelisted") + + (seen.hasPlayedBefore() ? ", has played here" : ", has never played here") + "."); }); }); } - private JsonObject nameError(Throwable e, String typed) { + private void nameFail(CommandSender sender, Request r, Throwable e, String typed) { Throwable cause = e instanceof CompletionException && e.getCause() != null ? e.getCause() : e; - if (cause instanceof NameResolver.NameProblem np) return error(np.code, np.getMessage()); - return error("lookup_failed", "Couldn't look up " + typed + "."); + if (cause instanceof NameResolver.NameProblem np) fail(sender, r.id(), np.code.replace('_', '-'), np.getMessage()); + else fail(sender, r.id(), Errors.BUSY, "Couldn't look up " + typed + ". Try again in a minute."); } - private void show(CommandSender sender, String req, String[] rest) { - if (rest.length != 1) { reply(sender, req, error("bad_request", "Use: usher show ")); return; } - if (!plugin.store().ready()) { reply(sender, req, error("luckperms_missing", "LuckPerms isn't running.")); return; } - UUID id = parseUuid(rest[0]); - var who = id != null ? java.util.concurrent.CompletableFuture.completedFuture(new NameResolver.Resolved(id, nameOf(id))) - : plugin.names().resolve(rest[0], plugin.settings().lookup()); + private void show(CommandSender sender, Request r, List rest) throws Request.Bad, Refusal { + if (rest.size() != 1) throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher show "); + if (!plugin.store().ready()) throw new Refusal("luckperms-missing", "LuckPerms isn't running."); + String typed = Args.player(rest.get(0)); + UUID id = typed.contains("-") ? UUID.fromString(typed) : null; + CompletableFuture who = id != null ? CompletableFuture.completedFuture(new NameResolver.Resolved(id, nameOf(id))) + : plugin.names().resolve(typed, plugin.settings().lookup()); who.whenComplete((w, e) -> { - if (e != null) { reply(sender, req, nameError(e, rest[0])); return; } + if (e != null) { nameFail(sender, r, e, typed); return; } plugin.store().load(w.id(), w.name()).orTimeout(30, TimeUnit.SECONDS).whenComplete((state, e2) -> plugin.runMain(() -> { - if (e2 != null) { reply(sender, req, error("luckperms_failed", "LuckPerms couldn't load " + (w.name() == null ? w.id() : w.name()) + ".")); return; } + if (e2 != null) { fail(sender, r.id(), "luckperms-failed", "LuckPerms couldn't load " + (w.name() == null ? w.id() : w.name()) + "."); return; } Settings s = plugin.settings(); - JsonObject o = ok(); - o.addProperty("uuid", w.id().toString()); - o.addProperty("name", w.name()); - o.addProperty("whitelisted", Bukkit.getOfflinePlayer(w.id()).isWhitelisted()); - JsonArray has = new JsonArray(), inherited = new JsonArray(); + List has = new ArrayList<>(), inherited = new ArrayList<>(); for (Profile p : s.profiles().values()) { if (state.direct().contains(p.group())) has.add(p.id()); else if (state.inherited().contains(p.group())) inherited.add(p.id()); } - o.add("profiles", has); - o.add("throughOtherGroups", inherited); - o.add("circles", ChangeService.strings(Planner.circles(s, state.inherited()))); - reply(sender, req, o); + boolean whitelisted = Bukkit.getOfflinePlayer(w.id()).isWhitelisted(); + List circles = List.copyOf(Planner.circles(s, state.inherited())); + if (r.json()) { + Map o = new LinkedHashMap<>(); + o.put("uuid", w.id().toString()); + o.put("name", w.name()); + o.put("whitelisted", whitelisted); + o.put("profiles", has); + o.put("throughOtherGroups", inherited); + o.put("circles", circles); + send(sender, Answer.ok(r.id(), o)); + } else { + sender.sendMessage((w.name() == null ? w.id().toString() : w.name()) + ": " + (has.isEmpty() ? "no profiles" : String.join(", ", has)) + + (inherited.isEmpty() ? "" : "; through other groups: " + String.join(", ", inherited)) + + (circles.isEmpty() ? "" : "; circles: " + String.join(", ", circles)) + (whitelisted ? "; whitelisted." : "; not whitelisted.")); + } })); }); } - private void apply(CommandSender sender, String req, String[] rest) { - if (rest.length < 2) { - reply(sender, req, error("bad_request", "Use: usher apply [+profile] [-profile] [confirm=code,code] [whitelist=no] [via=leaf-juice]")); - return; - } - UUID id = parseUuid(rest[0]); - if (id == null) { reply(sender, req, error("bad_request", rest[0] + " isn't a UUID.")); return; } - if (!NameResolver.possibleName(rest[1])) { reply(sender, req, error("bad_name", "\"" + rest[1] + "\" can't be a Minecraft name.")); return; } - Set add = new LinkedHashSet<>(), remove = new LinkedHashSet<>(), confirmed = new LinkedHashSet<>(); - boolean whitelist = true; - String via = "console"; - for (int i = 2; i < rest.length; i++) { - String a = rest[i]; - String lower = a.toLowerCase(Locale.ROOT); - if (a.startsWith("+") && a.length() > 1) add.add(lower.substring(1)); - else if (a.startsWith("-") && a.length() > 1) remove.add(lower.substring(1)); - else if (lower.startsWith("confirm=")) { for (String c : lower.substring(8).split(",")) if (!c.isBlank()) confirmed.add(c.trim()); } - else if (lower.equals("whitelist=no")) whitelist = false; - else if (lower.equals("whitelist=yes")) whitelist = true; - else if (lower.startsWith("via=") && VIA.matcher(lower.substring(4)).matches()) via = lower.substring(4); - else { reply(sender, req, error("bad_request", "Didn't understand \"" + a + "\". Profiles go as +id or -id.")); return; } + // --- apply --- + + /** The parsed arguments of apply, kept apart so the parsing can be tested without a server. */ + record ApplyArgs(UUID uuid, String name, Set add, Set remove, boolean whitelist, String via) {} + + static ApplyArgs parseApply(List rest) throws Request.Bad, Refusal { + if (rest.size() < 4 || rest.size() > 6) + throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher apply [whitelist true|false] [via]"); + UUID id = Args.uuid(rest.get(0)); + String name = rest.get(1); + if (!NameResolver.possibleName(name)) throw new Refusal(Errors.BAD_ARGUMENT, "\"" + name + "\" can't be a Minecraft name."); + Set add = profileList(rest.get(2)), remove = profileList(rest.get(3)); + boolean whitelist = rest.size() < 5 || Args.bool(rest.get(4)); + String via = rest.size() < 6 ? "console" : rest.get(5); + if (!VIA.matcher(via).matches()) throw new Refusal(Errors.BAD_ARGUMENT, "via is 1 to 32 of a-z, 0-9 and -."); + return new ApplyArgs(id, name, add, remove, whitelist, via); + } + + private static Set profileList(String token) throws Request.Bad, Refusal { + if (token.equals("none")) return Set.of(); + Set out = new LinkedHashSet<>(); + for (String p : Args.list(token)) { + String id = p.toLowerCase(Locale.ROOT); + if (!id.matches("[a-z0-9_-]{1,32}")) throw new Refusal(Errors.BAD_ARGUMENT, "Profile names are lowercase letters, numbers, dashes and underscores: " + p + " isn't one."); + out.add(id); } - var request = new ChangeService.Request(req, ChangeService.Actor.console(via), id, rest[1], add, remove, confirmed, whitelist); - plugin.changes().submit(request).whenComplete((result, e) -> { - if (e != null || result == null) reply(sender, req, error("internal", "Something went wrong inside Usher. See the server log.")); - else reply(sender, req, result.json()); - }); + return out; } - private JsonObject attempts() { - JsonObject o = ok(); - JsonArray list = new JsonArray(); - for (JoinAttempts.Attempt a : plugin.attempts().list()) { - JsonObject j = new JsonObject(); - j.addProperty("uuid", a.uuid); - j.addProperty("name", a.name); - j.addProperty("attempts", a.attempts); - j.addProperty("first", a.first); - j.addProperty("last", a.last); - list.add(j); + private void apply(CommandSender sender, Request r, List rest) throws Request.Bad, Refusal { + ApplyArgs a = parseApply(rest); + Set confirmed = LatchkeyHook.confirmedCodes(plugin.settings(), r.yes()); + var request = new ChangeService.Request(r.id(), ChangeService.Actor.console(a.via()), a.uuid(), a.name(), a.add(), a.remove(), confirmed, a.whitelist()); + plugin.changes().submit(request).whenComplete((result, e) -> plugin.runMain(() -> { + if (e != null || result == null) { fail(sender, r.id(), Errors.INTERNAL, "Something went wrong inside Usher. See the server log."); return; } + answer(sender, r, result); + })); + } + + /** A change's result as one answer: done, questions that need a yes, or a refusal with its reason. */ + private void answer(CommandSender sender, Request r, ChangeService.Result result) { + String code = result.code().replace('_', '-'); + switch (result.status()) { + case "ok" -> { + if (r.json()) send(sender, Answer.ok(r.id(), toMap(result.json()))); + else sender.sendMessage(result.message()); + } + case "warn" -> { + List qs = new ArrayList<>(); + for (Planner.Warning w : result.warnings()) qs.add(new Confirm.Question(LatchkeyHook.toYesId(w.code()), w.text())); + if (r.json()) send(sender, Answer.needs(r.id(), qs)); + else { + sender.sendMessage(result.message()); + for (Confirm.Question q : qs) sender.sendMessage(" " + q.id() + ": " + q.question()); + sender.sendMessage("If that's all right, run it again with --yes " + String.join(",", qs.stream().map(Confirm.Question::id).toList()) + " at the end."); + } + } + default -> fail(sender, r.id(), code, result.message()); } - o.add("attempts", list); - return o; } - private void log(CommandSender sender, String req, String[] rest) { - int count = 20; - if (rest.length == 1) try { count = Math.max(1, Math.min(200, Integer.parseInt(rest[0]))); } catch (NumberFormatException e) { - reply(sender, req, error("bad_request", "Use: usher log [count]")); + // --- attempts and log --- + + private void attempts(CommandSender sender, Request r, List rest) throws Request.Bad, Refusal { + if (rest.size() > 2) throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher attempts [limit] [cursor]"); + int limit = rest.isEmpty() ? 50 : Args.integer(rest.get(0)), cursor = rest.size() < 2 ? 0 : Args.integer(rest.get(1)); + if (limit < 1 || limit > MAX_LIMIT || cursor < 0) throw new Refusal(Errors.BAD_ARGUMENT, "limit is 1 to " + MAX_LIMIT + " and cursor 0 or more."); + List all = new ArrayList<>(plugin.attempts().list()); + List page = all.subList(Math.min(cursor, all.size()), Math.min(cursor + limit, all.size())); + if (!r.json()) { + if (all.isEmpty()) sender.sendMessage("Nobody has tried to join without being on the whitelist."); + for (JoinAttempts.Attempt a : page) sender.sendMessage(a.name + " (" + a.uuid + "): " + a.attempts + " tries"); return; } - if (plugin.audit() == null) { reply(sender, req, error("log_failed", "changes.log can't be opened.")); return; } - try { - JsonObject o = ok(); - JsonArray entries = new JsonArray(); - for (JsonObject e : plugin.audit().tail(count)) entries.add(e); - o.add("entries", entries); - reply(sender, req, o); - } catch (Exception e) { - reply(sender, req, error("log_failed", "changes.log couldn't be read.")); + List> list = new ArrayList<>(); + for (JoinAttempts.Attempt a : page) { + Map j = new LinkedHashMap<>(); + j.put("uuid", a.uuid); + j.put("name", a.name); + j.put("attempts", a.attempts); + j.put("first", a.first); + j.put("last", a.last); + list.add(j); } + Map o = new LinkedHashMap<>(); + o.put("attempts", list); + o.put("next", cursor + page.size() < all.size() ? cursor + page.size() : null); + send(sender, Answer.ok(r.id(), o)); } - private static UUID parseUuid(String s) { - try { - return s.length() == 32 ? NameResolver.parseUndashed(s) : s.length() == 36 ? UUID.fromString(s) : null; - } catch (IllegalArgumentException e) { - return null; + private void log(CommandSender sender, Request r, List rest) throws Request.Bad, Refusal { + if (rest.size() > 1) throw new Refusal(Errors.BAD_ARGUMENT, "Use: usher log [limit]"); + int count = rest.isEmpty() ? 20 : Args.integer(rest.get(0)); + if (count < 1 || count > MAX_LIMIT) throw new Refusal(Errors.BAD_ARGUMENT, "limit is 1 to " + MAX_LIMIT + "."); + if (plugin.audit() == null) throw new Refusal("log-failed", "changes.log can't be opened."); + List entries; + try { entries = plugin.audit().tail(count); } + catch (Exception e) { throw new Refusal("log-failed", "changes.log couldn't be read."); } + if (r.json()) { + List list = new ArrayList<>(); + for (JsonObject e : entries) list.add(toJava(e)); + send(sender, Answer.ok(r.id(), Map.of("entries", list))); + } else { + for (JsonObject e : entries) sender.sendMessage(e.toString()); } } + // --- helpers --- + + private void send(CommandSender sender, String line) { plugin.runMain(() -> Answer.send(sender::sendMessage, line)); } + + private void fail(CommandSender sender, String id, String code, String message) { + if (id != null) send(sender, Answer.error(id, code, message)); + else plugin.runMain(() -> sender.sendMessage(PlayerCommands.bad(message))); + } + + /** Gson's tree as plain maps and lists, which Signalbox writes. */ + static Map toMap(JsonObject o) { + Map m = new LinkedHashMap<>(); + for (var e : o.entrySet()) m.put(e.getKey(), toJava(e.getValue())); + return m; + } + + static Object toJava(JsonElement e) { + if (e == null || e.isJsonNull()) return null; + if (e.isJsonObject()) return toMap(e.getAsJsonObject()); + if (e.isJsonArray()) { List l = new ArrayList<>(); for (JsonElement x : (JsonArray) e) l.add(toJava(x)); return l; } + JsonPrimitive p = e.getAsJsonPrimitive(); + if (p.isBoolean()) return p.getAsBoolean(); + if (p.isNumber()) { double d = p.getAsDouble(); return d == Math.rint(d) && Math.abs(d) < 1e15 ? (Object) p.getAsLong() : (Object) d; } + return p.getAsString(); + } + /** The name the server knows for an account, or null. Null is passed on so LuckPerms never stores a made-up name. */ private static String nameOf(UUID id) { return Bukkit.getOfflinePlayer(id).getName(); diff --git a/src/main/java/com/extantape/usher/Settings.java b/src/main/java/com/extantape/usher/Settings.java index 2de469e..735103b 100644 --- a/src/main/java/com/extantape/usher/Settings.java +++ b/src/main/java/com/extantape/usher/Settings.java @@ -63,6 +63,8 @@ public static Settings from(ConfigurationSection config) { ConfigurationSection p = section.getConfigurationSection(key); if (p == null) { problems.add("Profile " + key + " isn't a section, so it's left out."); continue; } if (!ID.matcher(id).matches()) { problems.add("Profile " + key + " needs an id of letters, digits, - or _, so it's left out."); continue; } + // "none" stands for an empty list in commands (usher apply, latchkey invite create), so it can't be a profile. + if (id.equals("none")) { problems.add("Profile none can't be called that: \"none\" means no profiles in commands. Rename it; it's left out."); continue; } String group = p.getString("group", "").trim().toLowerCase(Locale.ROOT); if (!GROUP.matcher(group).matches()) { problems.add("Profile " + id + " has no usable group, so it's left out."); continue; } if (restricted.contains(group)) { problems.add("Profile " + id + " uses the restricted group " + group + ", so it's refused."); continue; } diff --git a/src/main/java/com/extantape/usher/UsherPlugin.java b/src/main/java/com/extantape/usher/UsherPlugin.java index b5b09e7..4fbc7bc 100644 --- a/src/main/java/com/extantape/usher/UsherPlugin.java +++ b/src/main/java/com/extantape/usher/UsherPlugin.java @@ -32,7 +32,6 @@ public final class UsherPlugin extends JavaPlugin { @Override public void onEnable() { - ChangeService.log = getLogger(); migrateFromStationmaster(); saveDefaultConfig(); settings = readSettings(); @@ -68,8 +67,6 @@ public void onEnable() { commands.register("approve", "Let someone in and choose their profiles.", players.approve()); commands.register("profile", "See or change someone's profiles.", players.profile()); commands.register("usher", "Console commands for Leaf Juice and scripts, and reload.", console); - // The old names, for one release (0.3.x) after the rename. Each use logs where to move. - commands.register("stationmaster", "Old name for /usher. Goes in the next release.", List.of("smaster"), deprecated(console)); }); // After startup, so listeners from other plugins hear about problems. @@ -219,17 +216,6 @@ void runMain(Runnable r) { NameResolver names() { return names; } PickerMenus menus() { return menus; } - /** Wraps the console command so the old names keep working for one release, with a nudge in the log. */ - private io.papermc.paper.command.brigadier.BasicCommand deprecated(ConsoleCommands console) { - return new io.papermc.paper.command.brigadier.BasicCommand() { - @Override public void execute(io.papermc.paper.command.brigadier.CommandSourceStack source, String[] args) { - getLogger().warning("\"stationmaster\" and \"smaster\" are the old names for \"usher\". They still work in 0.3.x but go in the next release."); - console.execute(source, args); - } - @Override public java.util.Collection suggest(io.papermc.paper.command.brigadier.CommandSourceStack source, String[] args) { return console.suggest(source, args); } - @Override public boolean canUse(org.bukkit.command.CommandSender sender) { return console.canUse(sender); } - }; - } /** * The plugin was called Stationmaster before 0.3.0. If plugins/Usher doesn't exist yet and plugins/Stationmaster diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index a377c46..de58bea 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -20,6 +20,3 @@ permissions: usher.approve: description: Approve people and change their profiles, by command or menu. Nobody can use it on themselves. default: false - stationmaster.approve: - description: The old name for usher.approve, from before the rename. Still works in 0.3.x and is removed in the next release; grant usher.approve instead. - default: false diff --git a/src/test/java/com/extantape/usher/ConsoleCommandsTest.java b/src/test/java/com/extantape/usher/ConsoleCommandsTest.java new file mode 100644 index 0000000..39db825 --- /dev/null +++ b/src/test/java/com/extantape/usher/ConsoleCommandsTest.java @@ -0,0 +1,70 @@ +package com.extantape.usher; + +import com.extantape.signalbox.Request; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** The console commands' argument rules and answer shape, without a server. The commands themselves are bot-tested. */ +class ConsoleCommandsTest { + static final String U = "00000000-0000-0000-0000-00000000000a"; + + static ConsoleCommands.ApplyArgs apply(String line) throws Exception { + return ConsoleCommands.parseApply(Request.parse(line.split(" ")).tokens()); + } + + @Test void applyReadsAddsRemovesAndDefaults() throws Exception { + var a = apply(U + " Alex builders,young none"); + assertEquals(UUID.fromString(U), a.uuid()); + assertEquals("Alex", a.name()); + assertEquals(Set.of("builders", "young"), a.add()); + assertEquals(Set.of(), a.remove()); + assertTrue(a.whitelist(), "whitelists by default, as before"); + assertEquals("console", a.via()); + var b = apply(U + " Alex none grown_sky false leaf-juice"); + assertEquals(Set.of("grown_sky"), b.remove()); + assertFalse(b.whitelist()); + assertEquals("leaf-juice", b.via()); + } + + @Test void applyRefusesWhatItCantRead() { + for (String bad : List.of(U + " Alex", U + " Al\"ex none none", "not-a-uuid Alex none none", U + " Alex Bad! none", + U + " Alex none none maybe", U + " Alex none none true LEAF", U + " Alex none none true via extra")) { + assertThrows(Exception.class, () -> apply(bad), bad); + } + } + + @Test void theOldPlusAndMinusFormIsRefusedByTheFormatItself() { + Request.Bad no = assertThrows(Request.Bad.class, () -> Request.parse((U + " Alex -young --json k1").split(" "))); + assertEquals("k1", no.id, "the refusal can still be matched to its request"); + } + + @Test void resultsBecomePlainDataSignalboxCanWrite() { + Map m = ConsoleCommands.toMap(JsonParser.parseString( + "{\"status\":\"ok\",\"added\":[\"young\"],\"count\":3,\"ratio\":0.5,\"nested\":{\"x\":true},\"none\":null}").getAsJsonObject()); + assertEquals(List.of("young"), m.get("added")); + assertEquals(3L, m.get("count")); + assertEquals(0.5, m.get("ratio")); + assertEquals(Map.of("x", true), m.get("nested")); + assertTrue(m.containsKey("none") && m.get("none") == null); + } + + @Test void describeMarksTheOptionalArgumentsAndOnlyThose() { + String line = com.extantape.signalbox.Answer.ok("d1", com.extantape.signalbox.Describe.body("Usher", "0.4.0", ConsoleCommands.described())); + assertTrue(line.contains("{\"name\":\"whitelist\",\"type\":\"bool\",\"optional\":true}"), line); + assertTrue(line.contains("{\"name\":\"via\",\"type\":\"string\",\"optional\":true}"), line); + assertTrue(line.contains("{\"name\":\"uuid\",\"type\":\"uuid\"}"), "required ones carry no optional key"); + assertTrue(line.contains("{\"name\":\"limit\",\"type\":\"int\",\"optional\":true}")); + } + + @Test void describeListsEveryCommand() { + var names = ConsoleCommands.described().stream().map(d -> d.name()).toList(); + assertEquals(ConsoleCommands.SUBS, names); + } +} diff --git a/src/test/java/com/extantape/usher/SettingsTest.java b/src/test/java/com/extantape/usher/SettingsTest.java index e9d50e9..10292e2 100644 --- a/src/test/java/com/extantape/usher/SettingsTest.java +++ b/src/test/java/com/extantape/usher/SettingsTest.java @@ -95,4 +95,12 @@ void tooManyProfilesForTheMenuIsSaidOutLoud() { assertEquals(16, s.profiles().size(), "they still load"); assertTrue(s.problems().stream().anyMatch(p -> p.startsWith("2 main profiles don't fit")), String.valueOf(s.problems())); } + + @Test + void aProfileCantBeCalledNoneSinceCommandsUseItForAnEmptyList() throws Exception { + Settings s = Settings.parse(defaults().replace(" builders:\n", " none:\n")); + assertFalse(s.profiles().containsKey("none"), "left out"); + assertTrue(s.problems().stream().anyMatch(p -> p.contains("Profile none can't be called that")), String.valueOf(s.problems())); + assertTrue(s.profiles().containsKey("young"), "the other profiles still load"); + } } diff --git a/test/acceptance.mjs b/test/acceptance.mjs index 758ee95..1fb11b9 100644 --- a/test/acceptance.mjs +++ b/test/acceptance.mjs @@ -105,8 +105,20 @@ try { && prof.profiles.find(p => p.id === 'grown_sky')?.confirm && prof.profiles.find(p => p.id === 'young')?.circle === 'young', JSON.stringify(prof).slice(0, 200)) check('every request gets exactly one result line', prof.hits === 1) check('restricted groups are listed', same(prof.restricted, ['admin', 'owner', 'moderator'])) - const badReq = (await s.ask('usher apply has!bad x y', 800)).join(' ') - check('a bad request ID is answered with an error line, not silence', /usher-result - .*bad_request/.test(badReq)) + const badReq = (await s.ask('usher profiles --json Has!Bad', 800)).join(' ') + check('a bad request id is answered in plain words, not silence', /request id must be/i.test(badReq), badReq.slice(0, 160)) + // The 0.4.0 format itself: one Signalbox line, straight after the log header, with dashed codes. + check('answers are Signalbox lines: ea-result {"id":…,"ok":…}', /\]: ea-result t[\w-]+ \{"id":"t[\w-]+","ok":true/.test(prof.line), prof.line.slice(0, 120)) + const unknownSub = await s.sm('frobnicate') + check('an unknown command still gets one answer, with code unknown-command', unknownSub.raw.error?.code === 'unknown-command' && unknownSub.hits === 1) + // Sent as typed, without the helper, which would fill in the missing arguments. + const badArgFrom = s.log.length + s.send('usher apply 00000000-0000-0000-0000-00000000000a --json badarg-1') + const badArgLine = await s.waitFor(/\]: ea-result badarg-1 /, 15000, badArgFrom) + const badArg = { raw: JSON.parse(badArgLine.slice(badArgLine.indexOf('{'))) } + check('bad arguments answer bad-argument, with how to use it', badArg.raw.error?.code === 'bad-argument' && /usher apply /.test(badArg.raw.error.message)) + const desc = await s.sm('describe') + check('describe lists every command, format 1', desc.raw.format === 1 && ['profiles', 'resolve', 'show', 'apply', 'attempts', 'log', 'reload', 'describe'].every(n => desc.raw.commands.some(c => c.name === n)), JSON.stringify(desc.raw).slice(0, 160)) // ---------- Strangers ---------- console.log('\n# Strangers stay out, and are remembered') @@ -173,7 +185,7 @@ try { let opened = false const onOpen = () => { opened = true } pleb.on('windowOpen', onOpen) - for (const c of ['/approve Someone', '/profile P_build', '/profile P_build edit', '/usher apply x 00000000-0000-0000-0000-000000000000 Someone +young', '/usher reload', '/smaster profiles x']) { + for (const c of ['/approve Someone', '/profile P_build', '/profile P_build edit', '/usher apply 00000000-0000-0000-0000-000000000000 Someone young none', '/usher reload', '/usher profiles --json x1']) { pleb.chat(c); await sleep(700) } pleb.removeListener('windowOpen', onOpen) @@ -409,7 +421,7 @@ try { await s.stop(); await sleep(1000) s = new Server(dir, { props: [`-Dusher.test.${prop}=15000`] }); await s.start() const name = prop === 'pause-after-groups' ? 'P_kill1' : 'P_kill2' - s.send(`usher apply k-${name} ${offlineUuid(name)} ${name} +builders`) + s.send(`usher apply ${offlineUuid(name)} ${name} builders none --json k-${name.toLowerCase().replace('_', '-')}`) const end = Date.now() + 10000 while (Date.now() < end && !changesLog().some(e => e.event === 'started' && e.target === name)) await sleep(100) await sleep(prop === 'pause-after-groups' ? 3000 : 500) @@ -444,7 +456,7 @@ try { check('control: once LuckPerms can save again, the same change works', (await s.sm('apply', offlineUuid('P_lpfail'), 'P_lpfail', '+builders')).status === 'ok') } - // ---------- Rename from Stationmaster (0.3.x keeps the old names for one release) ---------- + // ---------- Rename from Stationmaster (the old names went in 0.4.0; the folder still comes across) ---------- console.log('\n# The rename from Stationmaster') { await s.ask('whitelist add OldNode', 400) @@ -459,18 +471,15 @@ try { await sleep(500) const opened = await openMenu(oldBot, '/profile P_young edit').then(() => true, () => false) if (oldBot.currentWindow) oldBot.closeWindow(oldBot.currentWindow) - check('the old node stationmaster.approve still lets someone approve', opened) - check('and the log says to move to usher.approve', s.log.slice(from).some(l => /OldNode approves through the old permission stationmaster\.approve.*usher\.approve/.test(l))) + check('the old node stationmaster.approve no longer lets anyone approve (gone in 0.4.0, as 0.3.x said)', !opened) await s.ask('lp user OldNode permission unset stationmaster.approve', 800) await quit(oldBot) for (const old of ['stationmaster', 'smaster']) { - from = s.log.length const r = (await s.ask(old + ' reload', 1500)).join(' ') - check(`"${old} reload" still works`, /Reloaded: 5 profiles/.test(r), r.slice(0, 160)) - check(`"${old}" logs that it's the old name`, s.log.slice(from).some(l => /old names for "usher"/.test(l))) + check(`"${old}" is gone`, /Unknown or incomplete command|Unknown command/i.test(r) && !/Reloaded/.test(r), r.slice(0, 160)) } const r = await s.sm('profiles') - check('the console result line is now usher-result', r.status === 'ok' && r.hits === 1) + check('the console answer line is ea-result, and usher-result is gone', r.status === 'ok' && r.hits === 1 && !s.log.some(l => /usher-result /.test(l))) // A server upgrading from Stationmaster: only plugins/Stationmaster exists. await s.stop(); await sleep(1000) diff --git a/test/harness.mjs b/test/harness.mjs index 31635e2..c639e41 100644 --- a/test/harness.mjs +++ b/test/harness.mjs @@ -129,23 +129,21 @@ export class Server { await sleep(ms) return this.log.slice(from).map(strip) } - /** Runs a usher console command and returns its parsed result line. */ - async sm(sub, ...args) { - const req = 't' + (++Server.req) - const from = this.log.length - this.send(['usher', sub, req, ...args].join(' ')) - const line = await this.waitFor(new RegExp('usher-result ' + req + ' '), 30000, from) - const json = strip(line).slice(strip(line).indexOf('{')) - const hits = this.log.slice(from).filter(l => l.includes('usher-result ' + req + ' ')).length - return { req, hits, ...JSON.parse(json) } - } - /** Same, with a request ID chosen by the caller, for replays. */ + /** + * Runs a usher console command in the Signalbox format (0.4.0: `usher [--yes ids] --json `) and + * returns the answer, mapped back to the fields the checks read: status (ok, warn, refused, error), code with + * underscores, message, and warnings for questions. The raw line and answer stay on .line and .raw. + * Arguments may use the old shorthand (+profile, -profile, confirm=, whitelist=, via=); they're translated here. + */ + async sm(sub, ...args) { return this.smAs('t' + (++Server.req) + '-' + Math.random().toString(36).slice(2, 6), sub, ...args) } + /** Same, with a request id chosen by the caller, for replays. */ async smAs(req, sub, ...args) { const from = this.log.length - this.send(['usher', sub, req, ...args].join(' ')) - await sleep(300) - const line = await this.waitFor(new RegExp('usher-result ' + req.replace(/[.]/g, '\\.') + ' '), 30000, from) - return JSON.parse(strip(line).slice(strip(line).indexOf('{'))) + this.send(['usher', sub, ...toSignalbox(sub, args), '--json', req].join(' ')) + const line = await this.waitFor(new RegExp('\\]: ea-result ' + req.replace(/[.]/g, '\\.') + ' '), 30000, from) + const hits = this.log.slice(from).filter(l => l.includes('ea-result ' + req + ' ')).length + const raw = JSON.parse(line.slice(line.indexOf('{'))) + return { req, hits, line, raw, ...fromSignalbox(raw) } } errors() { return this.log.filter(l => /Usher/.test(l) && /(ERROR|Exception|SEVERE)/.test(l)) } async stop() { @@ -156,6 +154,35 @@ export class Server { kill9() { this.proc.kill('SIGKILL'); return this.exited } } Server.req = 0 + +/** The old apply shorthand as 0.4.0's positional form: [whitelist] [via], --yes. */ +function toSignalbox(sub, args) { + if (sub !== 'apply') return args + const [uuid, name, ...rest] = args + const add = [], remove = [], yes = [] + let whitelist = 'true', via = null + for (const a of rest) { + if (a.startsWith('+')) add.push(a.slice(1)) + else if (a.startsWith('-')) remove.push(a.slice(1)) + else if (a.startsWith('confirm=')) for (const c of a.slice(8).split(',')) yes.push(c.replace(':', '-').replaceAll('_', '-')) + else if (a === 'whitelist=no') whitelist = 'false' + else if (a === 'whitelist=yes') whitelist = 'true' + else if (a.startsWith('via=')) via = a.slice(4) + else throw new Error('test: unknown apply argument ' + a) + } + const out = [uuid, name, add.join(',') || 'none', remove.join(',') || 'none', whitelist, ...(via ? [via] : [])] + return yes.length ? [...out, '--yes', yes.join(',')] : out +} + +// Codes Usher answers as "error" (something failed) rather than "refused" (a rule said no). +const ERRORS = new Set(['luckperms_failed', 'internal', 'log_failed', 'server_busy', 'whitelist_failed', 'busy']) +/** A Signalbox answer in the shape the checks were written for. */ +function fromSignalbox(raw) { + if (raw.ok) return { ...raw, status: raw.status ?? 'ok' } + if (raw.needs) return { status: 'warn', code: 'needs_confirmation', warnings: raw.needs.map(q => ({ code: q.id === 'bridge' ? 'bridge' : 'confirm:' + q.id.replace(/^confirm-/, '').replaceAll('-', '_'), text: q.question })) } + const code = raw.error.code.replaceAll('-', '_') + return { status: ERRORS.has(code) ? 'error' : 'refused', code, message: raw.error.message } +} const strip = l => l.replace(/^\[[\d:]+ \w+\]: /, '').replace(/^\[[\d:]+\] \[[^\]]+\/\w+\]: /, '') /** A bot that keeps its chat. Rejects with the kick reason if it can't join. */