fix(limbo): 登录门聊天和 /link 回复里的网址可以直接点击打开
This commit is contained in:
8 files changed
+182
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<ClickEvent> 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<Component> 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<ClickEvent> 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<Component> out) {
|
||||
if (c.clickEvent() != null) {
|
||||
out.add(c);
|
||||
}
|
||||
for (Component child : c.children()) {
|
||||
collectLinks(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectClicks(Component c, List<ClickEvent> out) {
|
||||
if (c.clickEvent() != null) {
|
||||
out.add(c.clickEvent());
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
+2
-15
@@ -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={}): {}",
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Component> 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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Hermetic and framework-free like {@link InviteCardTest}, and run the same way:
|
||||
* {@code javac -cp <adventure-api.jar>:<adventure-key.jar>:<examination-api.jar> -d <out>
|
||||
* velocity/src/main/java/best/lolicon/felis/velocity/LinkCodeCard.java
|
||||
* velocity/test/best/lolicon/felis/velocity/LinkCodeCardTest.java && java -cp
|
||||
* <out>:<adventure-api.jar>:<adventure-key.jar>:<examination-api.jar>
|
||||
* 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<Component> 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<Component> 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<Component> 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++;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user