From 9910c78709f0c42d68deff562873afebef354a4a Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 15:13:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(limbo):=20=E7=99=BB=E5=BD=95=E9=97=A8?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=92=8C=20/link=20=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E7=BD=91=E5=9D=80=E5=8F=AF=E4=BB=A5=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E7=82=B9=E5=87=BB=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lolicon/felis/limbo/FelisLimboPlugin.java | 2 +- .../best/lolicon/felis/limbo/LoginBook.java | 25 ++++++- .../best/lolicon/felis/limbo/LoginFlow.java | 2 +- .../lolicon/felis/limbo/LoginFlowTest.java | 30 ++++++++ plugins/test.sh | 13 +++- .../felis/velocity/FelisVelocityPlugin.java | 17 +---- .../lolicon/felis/velocity/LinkCodeCard.java | 44 ++++++++++++ .../felis/velocity/LinkCodeCardTest.java | 71 +++++++++++++++++++ 8 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java create mode 100644 plugins/velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java index 37844c2..862686c 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java @@ -230,7 +230,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener { @Override public void chat(String line) { - player.sendMessage(line); + player.sendMessage(LoginBook.chatLine(line)); } @Override diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java index 68cf91f..531372d 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginBook.java @@ -4,14 +4,28 @@ import net.kyori.adventure.inventory.Book; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +import java.util.regex.Pattern; /** - * LoginBook is the book the gate opens on join: the bind code, a line that opens the - * login page when clicked, and the reminder to use the system browser, where - * WebAuthn/passkey works (the WeChat/QQ in-app browsers break it). + * LoginBook renders what the gate shows a player: the book it opens on join (the bind + * code, a line that opens the login page when clicked, and the reminder to use the + * system browser, where WebAuthn/passkey works; the WeChat/QQ in-app browsers break + * it), and the chat lines, whose web addresses open when clicked. */ final class LoginBook { + // A web address in a chat line runs to the next space. The client makes nothing + // in server chat clickable by itself, so a player who closed the book had to type + // the login URL out by hand. + private static final LegacyComponentSerializer CHAT = LegacyComponentSerializer.builder() + .character(LegacyComponentSerializer.SECTION_CHAR) + .extractUrls(Pattern.compile("https?://\\S+"), Style.style(NamedTextColor.AQUA, TextDecoration.UNDERLINED)) + .build(); + private LoginBook() { } @@ -26,4 +40,9 @@ final class LoginBook { .color(NamedTextColor.GRAY)); return Book.book(Component.text("Felis Login"), Component.text("Felis"), page); } + + /** chatLine renders one chat line with § colour codes, its web addresses clickable. */ + static Component chatLine(String line) { + return CHAT.deserialize(line); + } } diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java index 1becb14..1b17c36 100644 --- a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/LoginFlow.java @@ -49,7 +49,7 @@ final class LoginFlow { interface Seat { String name(); - /** chat sends one line, with § colour codes. */ + /** chat sends one line, with § colour codes; a web address in it opens when clicked. */ void chat(String line); /** showCode opens the login book. It may throw for a client that refuses it. */ diff --git a/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java index 945ddb0..a01ed64 100644 --- a/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java +++ b/plugins/limbo/test/best/lolicon/felis/limbo/LoginFlowTest.java @@ -14,6 +14,7 @@ import net.kyori.adventure.inventory.Book; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.TextDecoration; import java.io.IOException; import java.io.OutputStream; @@ -119,6 +120,13 @@ public final class LoginFlowTest { List.of(CODE + " " + stub.panelUrl), seat.books); assertTrue("chat carries the code", seat.chat.get(0).contains(CODE)); assertTrue("chat names the panel URL", seat.chat.get(1).contains(stub.panelUrl) && seat.chat.get(2).contains(stub.panelUrl)); + // The client makes nothing in server chat clickable by itself: a player who + // closed the book had to type the URL out. + for (int i = 1; i <= 2; i++) { + List clicks = new ArrayList<>(); + collectClicks(LoginBook.chatLine(seat.chat.get(i)), clicks); + assertEq("chat line " + i + " opens the panel URL", List.of(ClickEvent.openUrl(stub.panelUrl)), clicks); + } assertEq("no release before the link", 0, seat.releases.size()); int before = stub.hits("/link/status/"); @@ -357,6 +365,19 @@ public final class LoginFlowTest { collectClicks(page, clicks); assertEq("one click, opening the login page", List.of(ClickEvent.openUrl(CONSOLE + "/link")), clicks); assertTrue("the page says to use the system browser", plain(page).contains("SYSTEM browser")); + + // A chat line keeps its text; only the address in it turns into a link. + Component line = LoginBook.chatLine("§e[Felis] 打开 §b" + CONSOLE + "/link §e完成登录(勿用微信/QQ内置浏览器)。"); + assertEq("chat line text", "[Felis] 打开 " + CONSOLE + "/link 完成登录(勿用微信/QQ内置浏览器)。", plain(line)); + List links = new ArrayList<>(); + collectLinks(line, links); + assertEq("one link in the line", 1, links.size()); + assertEq("the link is the address alone", CONSOLE + "/link", plain(links.get(0))); + assertEq("the link opens it", ClickEvent.openUrl(CONSOLE + "/link"), links.get(0).clickEvent()); + assertEq("the link looks like one", TextDecoration.State.TRUE, links.get(0).decoration(TextDecoration.UNDERLINED)); + List none = new ArrayList<>(); + collectClicks(LoginBook.chatLine(LoginFlow.SIGNED_IN), none); + assertEq("a line without an address has no link", List.of(), none); } // ---- fakes ---- @@ -642,6 +663,15 @@ public final class LoginFlowTest { return sb.toString(); } + private static void collectLinks(Component c, List out) { + if (c.clickEvent() != null) { + out.add(c); + } + for (Component child : c.children()) { + collectLinks(child, out); + } + } + private static void collectClicks(Component c, List out) { if (c.clickEvent() != null) { out.add(c.clickEvent()); diff --git a/plugins/test.sh b/plugins/test.sh index cdd6f40..e79e343 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -26,8 +26,9 @@ # prompt, the op-login approval card names the account and leaves its # name for the admin to type, and the loader mods' shared /link refuses the # console and an offline-mode server before minting, fetches the code off the -# server thread and answers on it, and turns any failure into one line. -# InviteCardTest and OpApprovalCardTest need the +# server thread and answers on it, and turns any failure into one line; the +# /link reply makes the panel address a link the player can click open. +# InviteCardTest, OpApprovalCardTest and LinkCodeCardTest need the # adventure jars the velocity plugin compiles # against; they are fetched from Maven Central below, pinned by version and # checked by digest (a test run against silently-substituted bytes is not a @@ -213,6 +214,14 @@ javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/opcard-clas java -cp "$work/opcard-classes:$adventure_api:$adventure_key:$examination_api" \ best.lolicon.felis.velocity.OpApprovalCardTest +echo "==> LinkCodeCardTest (/link reply, velocity)" +mkdir -p "$work/linkcard-classes" +javac -cp "$adventure_api:$adventure_key:$examination_api" -d "$work/linkcard-classes" \ + plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java \ + plugins/velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java +java -cp "$work/linkcard-classes:$adventure_api:$adventure_key:$examination_api" \ + best.lolicon.felis.velocity.LinkCodeCardTest + # --- 2. production compile gates ------------------------------------------------ for module in velocity paper; do diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 137411d..36aabcd 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -397,21 +397,8 @@ public final class FelisVelocityPlugin { async(player, () -> { try { LinkCode code = linkClient.requestCode(player.getUniqueId()); - player.sendMessage(Component.text( - zh ? "你的绑定码:" : "Your link code: ", NamedTextColor.GREEN) - .append(Component.text(code.code(), NamedTextColor.YELLOW))); - String panelUrl = code.panelUrl(); - if (panelUrl != null) { - player.sendMessage(Component.text( - zh ? "在这里输入它完成绑定(几分钟内有效):" - : "Enter it here to finish linking (valid a few minutes):", - NamedTextColor.GRAY)); - player.sendMessage(Component.text(" " + panelUrl, NamedTextColor.WHITE)); - } else { - player.sendMessage(Component.text( - zh ? "在网页控制台 → 账户 中输入它完成绑定(几分钟内有效)。" - : "Enter it on the web console → Account to finish linking (valid a few minutes).", - NamedTextColor.GRAY)); + for (Component line : LinkCodeCard.lines(code.code(), code.panelUrl(), zh)) { + player.sendMessage(line); } } catch (LinkException e) { logger.warn("link code request failed for {} (status={}, code={}): {}", diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java new file mode 100644 index 0000000..92a6d32 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java @@ -0,0 +1,44 @@ +package best.lolicon.felis.velocity; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; + +import java.util.List; + +/** + * LinkCodeCard builds the reply to {@code /link}: the bind code, then where to enter + * it. The panel address is a click-to-open link, because the client makes nothing in + * server chat clickable by itself and a bare address had to be typed out by hand. + * + *

Like {@link InviteCard} it holds no Velocity types, so {@link LinkCodeCardTest} + * can check the link without a live proxy. Sending is left to the caller. + */ +final class LinkCodeCard { + + private LinkCodeCard() { + } + + /** lines renders the reply; panelUrl is null when felis-api minted none. */ + static List lines(String code, String panelUrl, boolean zh) { + Component codeLine = Component.text(zh ? "你的绑定码:" : "Your link code: ", NamedTextColor.GREEN) + .append(Component.text(code, NamedTextColor.YELLOW)); + if (panelUrl == null) { + return List.of(codeLine, Component.text( + zh ? "在网页控制台 → 账户 中输入它完成绑定(几分钟内有效)。" + : "Enter it on the web console → Account to finish linking (valid a few minutes).", + NamedTextColor.GRAY)); + } + Component link = Component.text(panelUrl, NamedTextColor.AQUA, TextDecoration.UNDERLINED) + .clickEvent(ClickEvent.openUrl(panelUrl)) + .hoverEvent(HoverEvent.showText(Component.text( + zh ? "点击在浏览器中打开" : "Click to open it in your browser"))); + return List.of(codeLine, + Component.text(zh ? "在这里输入它完成绑定(几分钟内有效):" + : "Enter it here to finish linking (valid a few minutes):", + NamedTextColor.GRAY), + Component.text(" ").append(link)); + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java new file mode 100644 index 0000000..823bad6 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java @@ -0,0 +1,71 @@ +package best.lolicon.felis.velocity; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.TextDecoration; + +import java.util.List; + +/** + * LinkCodeCardTest checks the {@code /link} reply: the code, and a panel address the + * player can click open instead of typing it out. + * + *

Hermetic and framework-free like {@link InviteCardTest}, and run the same way: + * {@code javac -cp :: -d + * velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java + * velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java && java -cp + * ::: + * best.lolicon.felis.velocity.LinkCodeCardTest}. + */ +public final class LinkCodeCardTest { + + private static final String URL = "https://console.example.test/link"; + + private static int checks; + + public static void main(String[] args) { + for (boolean zh : new boolean[] {true, false}) { + List card = LinkCodeCard.lines("K7P2QX", URL, zh); + String lang = zh ? "zh: " : "en: "; + assertEq(lang + "three lines", 3, card.size()); + assertEq(lang + "the code line", zh ? "你的绑定码:K7P2QX" : "Your link code: K7P2QX", flat(card.get(0))); + assertEq(lang + "the address line", " " + URL, flat(card.get(2))); + List links = card.get(2).children(); + assertEq(lang + "one link", 1, links.size()); + Component link = links.get(0); + // Checked by equals alone: printing a ClickEvent needs examination-string, + // which this classpath leaves out, so a mismatch would die in toString. + assertTrue(lang + "the link opens the panel", ClickEvent.openUrl(URL).equals(link.clickEvent())); + assertEq(lang + "the link looks like one", TextDecoration.State.TRUE, link.decoration(TextDecoration.UNDERLINED)); + assertTrue(lang + "the link says what a click does", link.hoverEvent() != null); + } + List bare = LinkCodeCard.lines("K7P2QX", null, true); + assertEq("no panel URL: two lines", 2, bare.size()); + assertTrue("no panel URL: points at the console", flat(bare.get(1)).contains("网页控制台")); + assertTrue("no panel URL: nothing to click", bare.get(1).clickEvent() == null); + System.out.println("LinkCodeCardTest OK (" + checks + " checks)"); + } + + private static String flat(Component c) { + StringBuilder sb = new StringBuilder(c instanceof TextComponent ? ((TextComponent) c).content() : ""); + for (Component child : c.children()) { + sb.append(flat(child)); + } + return sb.toString(); + } + + private static void assertTrue(String what, boolean ok) { + if (!ok) { + throw new AssertionError(what); + } + checks++; + } + + private static void assertEq(String what, Object want, Object got) { + if (want == null ? got != null : !want.equals(got)) { + throw new AssertionError(what + " = " + got + ", want " + want); + } + checks++; + } +}