Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 999c27c7f9 feat(rcon): discover commands and autocomplete server help
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 11m23s
2026-09-14 15:52:56 -04:00
dmg eb8ef18688 fix(suggestions): preserve one-item archived pagination
CI / validate (push) Successful in 8m20s
Release / release (push) Successful in 10m52s
2026-09-10 16:00:02 -04:00
11 changed files with 801 additions and 22 deletions
@@ -1,7 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// @vitest-environment jsdom
import { createElement } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RconConsole } from "@/components/rcon-console";
const actionState = vi.hoisted(() => ({
authorized: 0,
authFailure: false,
helpResponses: {} as Record<string, string>,
requestedBeforeSend: [] as boolean[],
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
return { email: "admin@example.test", name: "Admin" };
},
}));
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
@@ -115,8 +125,12 @@ const savedServer = {
};
describe("RCON server actions", () => {
afterEach(() => cleanup());
beforeEach(() => {
actionState.authorized = 0;
actionState.authFailure = false;
actionState.helpResponses = {};
actionState.requestedBeforeSend = [];
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
actionState.gatewayResult = { ok: true, response: "private response" };
});
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
actionState.selected = [savedServer];
actionState.helpResponses = {
help: "Help: Index (1/2)\n/leaf: Private protection description",
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
"help tyrant": "Usage: /tyrant <menu|status>",
};
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
expect(actionState.authorized).toBe(3);
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
expect(actionState.audits).toHaveLength(6);
for (let index = 0; index < 6; index += 2) {
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
}
const audit = JSON.stringify(actionState.audits);
expect(audit).not.toContain("Private protection description");
expect(audit).not.toContain("/tyrant <menu|status>");
expect(audit).not.toContain("decrypted-password");
});
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
actionState.authFailure = failure === "authorization";
actionState.auditFailure = failure === "audit";
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([]);
expect(document.body.textContent).not.toContain("REDIRECT:");
});
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
render(createElement(RconConsole, { servers: [savedServer] }));
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(actionState.executions).toHaveLength(1);
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
});
it("independently authorizes every exported operation before accepting input", async () => {
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
@@ -20,7 +20,17 @@ beforeEach(() => {
vi.stubGlobal("fetch", fetcher);
fetcher.mockImplementation(async (url: string) => {
if (url.endsWith(`/channels/${forum}`)) return Response.json({ id: forum, guild_id: guild, type: 15, available_tags: [] });
if (url.includes("/threads/archived/public")) return Response.json({ threads: [thread], has_more: true });
if (url.includes("/threads/archived/public")) {
const params = new URL(url).searchParams;
if (Number(params.get("limit")) < 2) return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-09-10T00:00:00.123455+00:00" } };
if (params.has("before")) {
expect(params.get("before")).toBe("2026-09-10T00:00:00.123456Z");
return Response.json({ threads: [older], has_more: false });
}
return Response.json({ threads: [newest, older], has_more: false });
}
if (url.includes("/threads/active")) return Response.json({ threads: [thread] });
if (url.endsWith(`/channels/${id}`)) return Response.json(thread);
if (url.endsWith(`/messages/${id}`)) return Response.json(message);
@@ -48,7 +58,16 @@ it("documents the nullable deleted starter and precise archived cursor", async (
expect((await response.json()).originalPost).toBeNull();
const archived = await list.GET(new Request("https://portal.example/api/suggestions?status=archived&limit=1"));
await assertResponse("/api/suggestions", "get", archived);
expect((await archived.json()).nextCursor).toBe("2026-09-10T00:00:00.123456Z");
expect(archived.status).toBe(200);
const first = await archived.json();
expect(first.items.map((item: { id: string }) => item.id)).toEqual([id]);
expect(first.nextCursor).toBe("2026-09-10T00:00:00.123456Z");
const terminal = await list.GET(new Request(`https://portal.example/api/suggestions?status=archived&limit=1&cursor=${encodeURIComponent(first.nextCursor)}`));
expect(terminal.status).toBe(200);
await assertResponse("/api/suggestions", "get", terminal);
const last = await terminal.json();
expect(last.items.map((item: { id: string }) => item.id)).toEqual(["100000000000000008"]);
expect(last.nextCursor).toBeNull();
});
it.each(routes)("documents Retry-After on $path upstream rate limits", async ({ path, route }) => {
fetcher.mockImplementation(async () => Response.json({ retry_after: 2.1 }, { status: 429 }));
+186 -2
View File
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const actionMocks = vi.hoisted(() => ({
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
@@ -41,6 +41,190 @@ const creative = {
};
describe("RconConsole", () => {
beforeEach(() => {
actionMocks.execute.mockClear();
actionMocks.execute.mockImplementation(async (_previous, formData) => {
const command = String(formData.get("command") ?? "");
const serverId = String(formData.get("serverId") ?? "");
const message = command === "help"
? "§eHelp: Index (1/2)\n§6Leaf: §fAll commands for Leaf\n§6/leaf: §fProtection"
: command === "help 2" ? "Help: Index (2/2)\n/tyrant: Tyrant features"
: command === "help tyrant" ? "Usage: /tyrant <menu|status|armor <helmet|boots>>"
: `Executed ${command}`;
return { status: "success", message, serverId };
});
});
it("refreshes server help and accepts suggestions without submitting or polluting recall", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.getByRole("option", { name: /tyrant Tyrant features/ })).toBeTruthy();
expect(fireEvent.keyDown(input, { key: "Enter" })).toBe(false);
expect(input.value).toBe("tyrant ");
expect(screen.queryByRole("listbox")).toBeNull();
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2"]);
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyrant ");
expect(screen.getByLabelText("Terminal transcript").textContent).not.toContain("$ help");
});
it("fetches and caches usage on demand and offers contextual literal arguments", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
expect(screen.getByRole("option", { name: "menu From server help" })).toBeTruthy();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(false);
expect(input.value).toBe("tyrant status ");
fireEvent.change(input, { target: { value: "tyrant armor " } });
fireEvent.click(screen.getByRole("option", { name: "helmet From server help" }));
expect(input.value).toBe("tyrant armor helmet ");
expect(document.activeElement).toBe(input);
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant"]);
});
it("reports incomplete help safely and leaves unknown manual commands usable", async () => {
actionMocks.execute.mockRejectedValueOnce(new Error("private transport detail"));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
expect(document.body.textContent).not.toContain("private transport detail");
const input = screen.getByLabelText("Command") as HTMLInputElement;
expect(input.disabled).toBe(false);
fireEvent.change(input, { target: { value: "custom-plugin arbitrary value" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed custom-plugin arbitrary value");
});
it("isolates caches by server, invalidates edited endpoints, and discards help on remount", async () => {
const storage = vi.spyOn(Storage.prototype, "setItem");
const view = render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
await screen.findByLabelText("Command usage");
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
let input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.queryByLabelText("Command usage")).toBeNull();
fireEvent.change(screen.getByLabelText("Server"), { target: { value: server.id } });
input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
expect(screen.getByLabelText("Command usage")).toBeTruthy();
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
view.rerender(<RconConsole servers={[{ ...server, host: "replacement.example.com" }, creative]} />);
expect(screen.queryByLabelText("Command usage")).toBeNull();
expect(screen.queryByRole("listbox")).toBeNull();
view.unmount();
render(<RconConsole servers={[server]} />);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyr" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(storage).not.toHaveBeenCalled();
storage.mockRestore();
});
it("dismisses suggestions for history recall, restores drafts, and submits only intentionally", async () => {
render(<RconConsole servers={[server]} />);
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
await screen.findByText("Executed list");
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
fireEvent.change(input, { target: { value: "tyr" } });
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("tyr");
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(input.value).toBe("list");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(input.value).toBe("tyr");
fireEvent.submit(input.form!);
await screen.findByText("Executed tyr");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["list", "help", "help 2", "tyr"]);
});
it("does not offer end-of-command replacement while editing in the middle", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyr" } });
input.setSelectionRange(1, 1);
fireEvent.select(input);
expect(screen.queryByRole("listbox")).toBeNull();
expect(fireEvent.keyDown(input, { key: "Tab" })).toBe(true);
expect(input.value).toBe("tyr");
});
it("stops pagination on server change and never publishes a late result to the new selection", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server, creative]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
fireEvent.change(screen.getByLabelText("Server"), { target: { value: creative.id } });
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/secretcmd: Private", serverId: server.id }));
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "secret" } });
expect(screen.queryByRole("listbox")).toBeNull();
expect(screen.getByText("Refresh commands to discover server help")).toBeTruthy();
});
it("lets manual execution interrupt discovery and waits for the in-flight help request", async () => {
let resolve!: (value: { status: "success"; message: string; serverId: string }) => void;
actionMocks.execute.mockImplementationOnce(() => new Promise((done) => { resolve = done; }));
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "list" } });
fireEvent.submit(input.form!);
expect(actionMocks.execute).toHaveBeenCalledTimes(1);
await act(async () => resolve({ status: "success", message: "Help: Index (1/9)\n/leaf: Protection", serverId: server.id }));
await screen.findByText("Executed list");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "list"]);
expect(input.disabled).toBe(false);
});
it("caches safe usage failure without retries until refresh and keeps manual entry available", async () => {
render(<RconConsole servers={[server]} />);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("2 commands discovered · best-effort server help");
actionMocks.execute.mockRejectedValueOnce(new Error("private usage detail"));
const input = screen.getByLabelText("Command") as HTMLInputElement;
fireEvent.change(input, { target: { value: "tyrant " } });
await screen.findByText("Usage unavailable. Refresh commands to retry.");
fireEvent.change(input, { target: { value: "tyrant armor " } });
expect(actionMocks.execute).toHaveBeenCalledTimes(3);
expect(document.body.textContent).not.toContain("private usage detail");
expect(input.disabled).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("/tyrant <menu|status|armor <helmet|boots>>");
expect(actionMocks.execute.mock.calls.map(([, data]) => data.get("command"))).toEqual(["help", "help 2", "help tyrant", "help", "help 2", "help tyrant"]);
});
it("renders server help as inert text and never refreshes missing or disabled connections", async () => {
const view = render(<RconConsole servers={[]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
view.rerender(<RconConsole servers={[{ ...server, enabled: false }]} />);
expect((screen.getByRole("button", { name: "Refresh commands" }) as HTMLButtonElement).disabled).toBe(true);
expect(actionMocks.execute).not.toHaveBeenCalled();
view.rerender(<RconConsole servers={[server]} />);
actionMocks.execute.mockResolvedValueOnce({ status: "success", message: 'Help: Index (1/1)\n/leaf: <img src=x onerror="alert(1)">', serverId: server.id });
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
await screen.findByText("1 commands discovered · best-effort server help");
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "lea" } });
expect(screen.getByText('<img src=x onerror="alert(1)">')).toBeTruthy();
expect(document.querySelector("img")).toBeNull();
});
it("renders one wide terminal workspace with connection controls and modal forms", () => {
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
expect(markup).toContain('aria-label="RCON terminal"');
+80 -7
View File
@@ -1,19 +1,18 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
type RconCommandState,
updateRconServer,
} from "@/app/admin/(console)/rcon/actions";
import { AdminModalForm } from "@/components/admin-modal-form";
import { rconSuggestions, type RconSuggestion } from "@/lib/rcon-help";
import { useRconSession } from "./use-rcon-session";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
const MAX_COMMAND_HISTORY = 50;
const MAX_TRANSCRIPT_EXCHANGES = 50;
@@ -46,8 +45,9 @@ export function RconConsole({
servers: RconServerOption[];
}) {
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
const [state, action, pending] = useActionState(executeRconCommand, initialState);
const [command, setCommand] = useState("");
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [activeSuggestion, setActiveSuggestion] = useState(0);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
@@ -57,11 +57,27 @@ export function RconConsole({
const pendingExchangeIdRef = useRef<number | null>(null);
const transcriptRef = useRef<HTMLDivElement>(null);
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
const help = useRconSession(selected, command);
const { state, action, pending } = help;
const suggestions = selected?.enabled && !pending && suggestionsOpen
? rconSuggestions(command, help.catalog?.commands ?? [], help.usage?.text) : [];
const suggestionIndex = Math.min(activeSuggestion, Math.max(0, suggestions.length - 1));
function acceptSuggestion(suggestion: RconSuggestion) {
setCommand(suggestion.value);
setSuggestionsOpen(false);
setHistoryIndex(null);
inputRef.current?.focus();
}
useEffect(() => {
inputRef.current?.focus();
}, [selectedId]);
useEffect(() => {
if (suggestions.length) document.getElementById(`rcon-suggestion-${suggestionIndex}`)?.scrollIntoView?.({ block: "nearest" });
}, [suggestionIndex, suggestions.length]);
useEffect(() => {
if (!pending && state.status !== "idle") inputRef.current?.focus();
}, [pending, state.status]);
@@ -101,6 +117,8 @@ export function RconConsole({
}
function rememberSubmittedCommand() {
help.stopHelp();
setSuggestionsOpen(false);
const submitted = command.trim();
if (!submitted || !selected) return;
const exchangeId = ++nextExchangeIdRef.current;
@@ -133,7 +151,11 @@ export function RconConsole({
<select
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
id="rcon-console-server"
onChange={(event) => setSelectedId(event.target.value)}
onChange={(event) => {
help.stopHelp();
setSuggestionsOpen(false);
setSelectedId(event.target.value);
}}
value={selected?.id}
>
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
@@ -200,11 +222,39 @@ export function RconConsole({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-line bg-canvas px-4 py-2 font-mono text-[10px] text-muted">
<p aria-live="polite" role="status">{help.loading ? "Reading server help…" : help.message}</p>
<button className="border border-line px-3 py-2 font-bold uppercase tracking-wider hover:border-ink disabled:opacity-50" disabled={!selected?.enabled || pending || help.loading} onClick={() => void help.refresh()} type="button">Refresh commands</button>
</div>
{help.usage && (
<div aria-label="Command usage" className="border-t border-line bg-panel px-4 py-3 font-mono text-xs" id="rcon-usage">
<p className="mb-1 text-[9px] uppercase tracking-wider text-muted">Server usage hint</p>
<pre className="whitespace-pre-wrap break-words font-mono text-xs">{help.usage.text ?? (help.usage.failed ? "Usage unavailable. Refresh commands to retry." : "No usage supplied by server help.")}</pre>
</div>
)}
{suggestions.length > 0 && (
<div className="border-t border-line bg-panel px-4 py-3 font-mono text-xs">
<p className="mb-2 text-[9px] uppercase tracking-wider text-muted" id="rcon-suggestion-hint"> Select · Tab / Enter Insert · Esc Dismiss</p>
<ul aria-label="Command suggestions" className="max-h-48 overflow-y-auto" id="rcon-suggestions" role="listbox">
{suggestions.map((suggestion, index) => (
<li aria-selected={index === suggestionIndex} className={`flex cursor-pointer flex-wrap gap-x-4 gap-y-1 border-l-2 px-3 py-2 ${index === suggestionIndex ? "border-accent bg-canvas text-ink" : "border-transparent text-muted"}`} id={`rcon-suggestion-${index}`} key={suggestion.value} onClick={() => acceptSuggestion(suggestion)} onMouseDown={(event) => event.preventDefault()} role="option">
<span className="font-bold">{suggestion.label}</span>{" "}<span className="break-words">{suggestion.description}</span>
</li>
))}
</ul>
</div>
)}
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
<label className="sr-only" htmlFor="rcon-command">Command</label>
<input
aria-activedescendant={suggestions.length ? `rcon-suggestion-${suggestionIndex}` : undefined}
aria-autocomplete="list"
aria-controls={suggestions.length ? "rcon-suggestions" : undefined}
aria-describedby={[suggestions.length ? "rcon-suggestion-hint" : "", help.usage ? "rcon-usage" : ""].filter(Boolean).join(" ") || undefined}
aria-expanded={suggestions.length > 0}
role="combobox"
autoComplete="off"
autoFocus
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
@@ -213,8 +263,31 @@ export function RconConsole({
key={selected?.id ?? "no-server"}
maxLength={1024}
name="command"
onChange={(event) => setCommand(event.target.value)}
onSelect={(event) => {
const input = event.currentTarget;
if (input.selectionStart !== input.value.length || input.selectionEnd !== input.value.length) setSuggestionsOpen(false);
}}
onBlur={() => setSuggestionsOpen(false)}
onChange={(event) => {
setCommand(event.target.value);
setSuggestionsOpen(true);
setActiveSuggestion(0);
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Escape") { setSuggestionsOpen(false); return; }
if (suggestions.length) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
setActiveSuggestion((suggestionIndex + (event.key === "ArrowUp" ? -1 : 1) + suggestions.length) % suggestions.length);
return;
}
if ((event.key === "Tab" && !event.shiftKey) || event.key === "Enter") {
event.preventDefault();
acceptSuggestion(suggestions[suggestionIndex]!);
return;
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
@@ -0,0 +1,97 @@
"use client";
import { useActionState, useCallback, useEffect, useRef, useState } from "react";
import { executeRconCommand, type RconCommandState } from "@/app/admin/(console)/rcon/actions";
import { discoverRconCommands, parseRconHelp, type RconHelpCatalog } from "@/lib/rcon-help";
import type { RconServerOption } from "./rcon-console";
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
type UsageResult = { text: string | null; failed: boolean };
type HelpCache = RconHelpCatalog & { usage: Map<string, UsageResult> };
export function useRconSession(server: RconServerOption | undefined, command: string) {
// Endpoint edits/enable changes invalidate the old cache, not just selection changes.
const key = server ? JSON.stringify([server.id, server.host, server.port, server.enabled]) : "";
const [cache, setCache] = useState(new Map<string, HelpCache>());
const [loading, setLoading] = useState(false);
const operationRef = useRef<AbortController | null>(null);
const flightRef = useRef<Promise<string> | null>(null);
function stopHelp() { operationRef.current?.abort(); }
useEffect(() => () => { operationRef.current?.abort(); }, [key]);
const [state, action, pending] = useActionState(async (previous: RconCommandState, formData: FormData) => {
// Do not race the page's help lookup against a manual command for the gateway lock.
stopHelp();
await flightRef.current?.catch(() => undefined);
return executeRconCommand(previous, formData);
}, initialState);
const serverId = server?.id ?? "";
const requestHelp = useCallback((helpCommand: string): Promise<string> => {
const data = new FormData();
data.set("serverId", serverId);
data.set("command", helpCommand);
const flight = executeRconCommand(initialState, data).then((result) => {
if (result.status !== "success") throw new Error("Help request unavailable");
return result.message;
});
flightRef.current = flight;
return flight;
}, [serverId]);
async function refresh() {
if (!server?.enabled || pending || operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
try {
const result = await discoverRconCommands(requestHelp, controller.signal);
if (!controller.signal.aborted) {
setCache((current) => new Map(current).set(key, { ...result, usage: new Map() }));
}
} finally {
operationRef.current = null;
setLoading(false);
}
}
const catalog = cache.get(key);
const typedVerb = command.match(/^\/?([a-z0-9_.:-]+)\s/i)?.[1];
const verb = catalog?.commands.find((entry) => entry.name.toLowerCase() === typedVerb?.toLowerCase())?.name;
const usage = verb ? catalog?.usage.get(verb) : undefined;
const enabled = server?.enabled ?? false;
useEffect(() => {
if (!enabled || !verb || usage || pending || loading) return;
const timer = setTimeout(async () => {
if (operationRef.current) return;
const controller = new AbortController();
operationRef.current = controller;
setLoading(true);
let result: UsageResult;
try {
result = { text: parseRconHelp(await requestHelp(`help ${verb}`)).usage, failed: false };
} catch {
result = { text: null, failed: true };
}
if (!controller.signal.aborted) {
setCache((current) => {
const entry = current.get(key);
if (!entry) return current;
return new Map(current).set(key, { ...entry, usage: new Map(entry.usage).set(verb, result) });
});
}
operationRef.current = null;
setLoading(false);
}, 350);
return () => clearTimeout(timer);
}, [enabled, key, verb, usage, pending, loading, requestHelp]);
const message = catalog
? catalog.incomplete
? `${catalog.commands.length} commands discovered · incomplete; refresh to retry`
: `${catalog.commands.length} commands discovered · best-effort server help`
: "Refresh commands to discover server help";
return { state, action, pending, catalog, usage, loading, refresh, stopHelp, message };
}
+81 -4
View File
@@ -8,6 +8,9 @@ const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11,
function setup(responses: Record<string, unknown>) {
const fetcher = vi.fn<typeof fetch>(async (input) => {
const path = String(input).replace("https://discord.com/api/v10", "");
if (path.includes("/threads/archived/public") && Number(new URL(String(input)).searchParams.get("limit")) < 2) {
return Response.json({ code: 50035, message: "Invalid Form Body", errors: { limit: { _errors: [{ code: "NUMBER_TYPE_MIN", message: "int value should be greater than or equal to 2." }] } } }, { status: 400 });
}
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
const value = responses[path];
return value instanceof Response ? value : Response.json(value);
@@ -19,20 +22,94 @@ it("reads archived forum pages using Discord's archive timestamp cursor", async
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
});
it.each([true, false])("returns one archived post without skipping buffered posts when has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00.123456Z";
const newest = { ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: "2026-01-01T00:00:00.123456+00:00" } };
const older = { ...newest, id: "100000000000000008", thread_metadata: { ...newest.thread_metadata, archive_timestamp: "2026-01-01T00:00:00.123455+00:00" } };
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [newest, older], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [older], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.items.map(({ id }) => id)).toEqual([newest.id]);
expect(first.nextCursor).toBe(cursor);
const last = await client.list({ status: "archived", limit: 1, cursor: first.nextCursor! });
expect(last.items.map(({ id }) => id)).toEqual([older.id]);
expect(last.nextCursor).toBeNull();
});
it.each([1, 2, 25, 100])("preserves archive request bounds and terminal pages for limit=%s", async (limit) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}`]: { threads: [thread], has_more: false },
});
const result = await client.list({ status: "archived", limit });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBeNull();
});
it.each([true, false])("does not invent a cursor for an empty archive response with has_more=%s", async (hasMore) => {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [], has_more: hasMore },
});
expect(await client.list({ status: "archived", limit: 1 })).toEqual({ items: [], nextCursor: null });
});
it.each([
{ parent_id: "100000000000000099" },
{ guild_id: "100000000000000099" },
{ type: 12 },
])("filters unrelated archives before slicing and choosing a continuation: %j", async (outside) => {
const cursor = "2026-01-01T00:00:00Z";
const unrelated = { ...thread, ...outside, id: "100000000000000007", thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } };
const newerUnrelated = { ...unrelated, thread_metadata: { ...unrelated.thread_metadata, archive_timestamp: "2026-01-02T00:00:00Z" } };
for (const threads of [[newerUnrelated, thread], [thread, unrelated]]) {
for (const hasMore of [false, true]) {
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads, has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result.items.map(({ id }) => id)).toEqual([threadId]);
expect(result.nextCursor).toBe(hasMore ? cursor : null);
if (result.nextCursor) expect(await client.list({ status: "archived", limit: 1, cursor: result.nextCursor })).toEqual({ items: [], nextCursor: null });
}
}
});
it.each([true, false])("preserves progress for a fully filtered archive page with has_more=%s", async (hasMore) => {
const cursor = "2026-01-01T00:00:00Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, parent_id: "100000000000000099" }], has_more: hasMore },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archive_timestamp: "2025-12-31T00:00:00Z" } }], has_more: false },
});
const result = await client.list({ status: "archived", limit: 1 });
expect(result).toEqual({ items: [], nextCursor: hasMore ? cursor : null });
if (result.nextCursor) {
const next = await client.list({ status: "archived", limit: 1, cursor: result.nextCursor });
expect(next.items.map(({ id }) => id)).toEqual([threadId]);
expect(next.nextCursor).toBeNull();
}
});
it("normalizes offset archive timestamps without losing cursor precision", async () => {
const raw = "2026-01-01T00:00:00.123456+00:00";
const cursor = "2026-01-01T00:00:00.123456Z";
const { client } = setup({
[`/channels/${forumId}`]: forum,
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
[`/channels/${forumId}/threads/archived/public?limit=2`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true, archive_timestamp: raw } }], has_more: true },
[`/channels/${forumId}/threads/archived/public?limit=2&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
});
const first = await client.list({ status: "archived", limit: 1 });
expect(first.nextCursor).toBe(cursor);
+8 -3
View File
@@ -148,10 +148,15 @@ export function createSuggestionsClient(options: { token: string; guildId: strin
const forum = await getForum();
if (status === "archived") {
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${Math.max(2, limit)}${before}`);
const threads = data.threads.filter(belongs);
const page = threads.slice(0, limit);
// Resume after the last returned post, not the extra post fetched for
// Discord's minimum limit. Fully filtered pages must still advance.
const last = page.at(-1) ?? data.threads.at(-1);
return {
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
nextCursor: data.has_more && data.threads.length ? archiveCursor(data.threads.at(-1)!.thread_metadata.archive_timestamp) : null,
items: page.map((thread) => summary(thread, forum)),
nextCursor: (data.has_more || threads.length > limit) && last ? archiveCursor(last.thread_metadata.archive_timestamp) : null,
};
}
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { discoverRconCommands, MAX_HELP_COMMANDS, MAX_HELP_PAGES, parseRconHelp, rconSuggestions } from "./rcon-help";
describe("RCON help discovery", () => {
it("walks help pages sequentially and deduplicates commands", async () => {
const requests: string[] = [];
const result = await discoverRconCommands(async (command) => {
requests.push(command);
return command === "help"
? "Help: Index (1/2)\nLeaf: All commands for Leaf\n/leaf: Protection"
: "Help: Index (2/2)\n/leaf: Protection\n/tyrant: Tyrant features";
});
expect(requests).toEqual(["help", "help 2"]);
expect(result).toEqual({ commands: [
{ name: "leaf", description: "Protection" },
{ name: "tyrant", description: "Tyrant features" },
], incomplete: false });
});
it.each([
"Unrecognized help format",
"Help: Index (0/2)",
"Help: Index (1/0)",
"Help: Index (1/9007199254740992)",
])("stops on malformed pagination: %s", async (output) => {
let calls = 0;
const result = await discoverRconCommands(async () => { calls++; return output; });
expect(calls).toBe(1);
expect(result.incomplete).toBe(true);
});
it("stops on repeated pages, transport failure, or cancellation without losing earlier commands", async () => {
const first = "Help: Index (1/3)\n/leaf: Protection";
let calls = 0;
const repeated = await discoverRconCommands(async () => { calls++; return first; });
expect(calls).toBe(2);
expect(repeated).toEqual({ commands: [{ name: "leaf", description: "Protection" }], incomplete: true });
calls = 0;
const failed = await discoverRconCommands(async () => {
if (calls++) throw new Error("private transport detail");
return first;
});
expect(failed).toEqual(repeated);
const controller = new AbortController();
const cancelled = await discoverRconCommands(async () => { controller.abort(); return first; }, controller.signal);
expect(cancelled).toEqual({ commands: [], incomplete: true });
});
it("bounds cached command count independently of pagination", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => {
calls++;
return `Help: Index (${calls}/64)\n` + Array.from({ length: 100 }, (_, index) => `/c${calls}_${index}: A command`).join("\n");
});
expect(result.commands).toHaveLength(MAX_HELP_COMMANDS);
expect(result.incomplete).toBe(true);
expect(calls).toBeLessThan(MAX_HELP_PAGES);
});
it("caps excessive pagination and reports partial results", async () => {
let calls = 0;
const result = await discoverRconCommands(async () => `Help: Index (${++calls}/99999)\n/c${calls}: A command`);
expect(calls).toBe(MAX_HELP_PAGES);
expect(result.commands).toHaveLength(MAX_HELP_PAGES);
expect(result.incomplete).toBe(true);
});
});
describe("RCON suggestions and usage", () => {
it("filters discovered names without requiring a slash and preserves a typed slash", () => {
const commands = [{ name: "tyrant", description: "Tyrant features" }, { name: "leaf", description: "Protection" }];
expect(rconSuggestions("tyr", commands)).toEqual([{ value: "tyrant ", label: "tyrant", description: "Tyrant features" }]);
expect(rconSuggestions("/tyr", commands)[0]?.value).toBe("/tyrant ");
expect(rconSuggestions("unknown", commands)).toEqual([]);
expect(rconSuggestions("", commands)).toEqual([]);
});
it("reads wrapped Tyrant usage and offers only context-correct literal branches", () => {
const { usage } = parseRconHelp(`§e--------- §fHelp: §r/tyrant §e-------------------------------
§6Description: §fView and use Spigot Tyrant game features.
§f§6Usage: §f/tyrant <menu|status|choices|buy|armor
§f<helmet|chestplate|leggings|boots>|gear
§f<axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence
§f|optout|optin|relinquish confirm>`);
expect(usage).toContain("/tyrant <menu|status");
expect(usage).not.toContain("§");
const commands = [{ name: "tyrant", description: "Tyrant features" }];
expect(rconSuggestions("tyrant ", commands, usage).map((item) => item.label)).toEqual([
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish",
]);
expect(rconSuggestions("tyrant armor he", commands, usage).map((item) => item.value)).toEqual(["tyrant armor helmet "]);
expect(rconSuggestions("tyrant relinquish ", commands, usage).map((item) => item.label)).toEqual(["confirm"]);
});
it.each([
"/leaf <player>", "/leaf [player]", "/leaf <a|b", "/other <a|b>",
"/leaf <a||b>", "/leaf <a|b>>", "/leaf <a|b> ".repeat(300),
"/leaf " + "<a|b> ".repeat(10),
])("does not invent literals for opaque or malformed syntax: %s", (usage) => {
expect(rconSuggestions("leaf ", [{ name: "leaf", description: "Protection" }], usage)).toEqual([]);
});
});
describe("RCON help parsing", () => {
it("discovers slash commands, not help categories, from formatted paginated output", () => {
expect(parseRconHelp(`§e--------- §fHelp: §rIndex (1/31) §e--------------------------
§7Use /help [n] to get page n of help.
§7§6Aliases: §fLists command aliases
§f§6Leaf: §fAll commands for Leaf
§f§6/leaf: §fControl or administer Leaf protection.
§f§6/minecraft:help: §fProvides help
§f§6/not a command: §fIgnore this
`)).toEqual({
commands: [
{ name: "leaf", description: "Control or administer Leaf protection." },
{ name: "minecraft:help", description: "Provides help" },
],
page: 1, pages: 31, usage: null,
});
});
});
+127
View File
@@ -0,0 +1,127 @@
export type RconHelpCommand = { name: string; description: string };
export type RconHelpPage = {
commands: RconHelpCommand[];
page: number | null;
pages: number | null;
usage: string | null;
};
export type RconHelpCatalog = { commands: RconHelpCommand[]; incomplete: boolean };
export const MAX_HELP_PAGES = 64;
export const MAX_HELP_COMMANDS = 2_048;
export async function discoverRconCommands(
request: (command: string) => Promise<string>,
signal?: AbortSignal,
): Promise<RconHelpCatalog> {
const commands = new Map<string, RconHelpCommand>();
let total: number | null = null;
for (let page = 1; page <= MAX_HELP_PAGES; page += 1) {
if (signal?.aborted) break;
let parsed: RconHelpPage;
try {
parsed = parseRconHelp(await request(page === 1 ? "help" : `help ${page}`));
} catch {
break;
}
if (signal?.aborted) break;
for (const command of parsed.commands) {
if (commands.size >= MAX_HELP_COMMANDS) return { commands: [...commands.values()], incomplete: true };
commands.set(command.name.toLowerCase(), command);
}
if (total === null) total = parsed.pages;
if (parsed.page !== page || parsed.pages !== total || !Number.isSafeInteger(total) || total! < page) break;
if (page === total) return { commands: [...commands.values()], incomplete: false };
}
return { commands: [...commands.values()], incomplete: true };
}
export type RconSuggestion = { value: string; label: string; description: string };
// Only expand a small, balanced literal/alternative grammar. Single angle-bracket
// values are placeholders, not literals; unsupported syntax remains a usage hint.
function usagePaths(pattern: string): Array<Array<string | null>> {
if (pattern.length > 4_096 || /[^a-z0-9_.:<>|\s-]/i.test(pattern)) return [];
const tokens = pattern.match(/[a-z0-9_.:-]+|[<>|]/gi) ?? [];
if (tokens.length > 256) return [];
let cursor = 0;
function expression(depth: number): Array<Array<string | null>> {
if (depth > 8) throw new Error("Complex usage");
const alternatives: Array<Array<string | null>> = [];
let sequence: Array<Array<string | null>> = [[]];
let hasAlternatives = false;
while (cursor < tokens.length && tokens[cursor] !== ">") {
const token = tokens[cursor++]!;
if (token === "|") {
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
sequence = [[]];
hasAlternatives = true;
continue;
}
const values = token === "<" ? expression(depth + 1) : [[token]];
if (token === "<" && tokens[cursor++] !== ">") throw new Error("Unbalanced usage");
if (sequence.length * values.length + alternatives.length > 256) throw new Error("Complex usage");
sequence = sequence.flatMap((prefix) => values.map((suffix) => [...prefix, ...suffix]));
}
if (sequence.some((path) => !path.length)) throw new Error("Empty alternative");
alternatives.push(...sequence);
return depth > 0 && !hasAlternatives ? [[null]] : alternatives;
}
try {
const paths = expression(0);
return cursor === tokens.length ? paths : [];
} catch {
return [];
}
}
export function rconSuggestions(input: string, commands: RconHelpCommand[], usage?: string | null): RconSuggestion[] {
if (!input || input.length > 1_024) return [];
const slash = input.startsWith("/") ? "/" : "";
const parts = input.replace(/^\//, "").split(/\s+/);
const verb = parts[0]!;
if (parts.length === 1) {
return commands.filter((command) => command.name.toLowerCase().startsWith(verb.toLowerCase()))
.slice(0, 20).map((command) => ({ value: `${slash}${command.name} `, label: command.name, description: command.description }));
}
if (!commands.some((command) => command.name.toLowerCase() === verb.toLowerCase()) || !usage) return [];
const match = usage.match(/^\/?([a-z0-9_.:-]+)\s+([\s\S]+)$/i);
if (!match || match[1]!.toLowerCase() !== verb.toLowerCase()) return [];
const entered = parts.slice(1, -1);
const prefix = parts.at(-1)!;
const literals = new Set<string>();
for (const path of usagePaths(match[2]!)) {
if (!entered.every((value, index) => path[index] === value)) continue;
const next = path[entered.length];
if (next && next.startsWith(prefix)) literals.add(next);
}
const base = input.slice(0, input.length - prefix.length);
return [...literals].slice(0, 20).map((literal) => ({ value: `${base}${literal} `, label: literal, description: "From server help" }));
}
export function parseRconHelp(output: string): RconHelpPage {
const text = output.slice(0, 65_536).replace(/§[0-9a-fk-orx]/gi, "");
const pagination = text.match(/^.*Help:.*\((\d+)\/(\d+)\).*$/m);
const commands: RconHelpCommand[] = [];
for (const line of text.split(/\r?\n/)) {
const entry = line.trim().match(/^\/([a-z0-9_.:-]{1,128}):\s+(.+)$/i);
if (entry) commands.push({ name: entry[1]!, description: entry[2]!.slice(0, 512) });
}
const lines = text.split(/\r?\n/).map((line) => line.trim());
const usageStart = lines.findIndex((line) => /^Usage:\s*/i.test(line));
const usageLines: string[] = [];
if (usageStart !== -1) {
usageLines.push(lines[usageStart]!.replace(/^Usage:\s*/i, ""));
for (const line of lines.slice(usageStart + 1)) {
if (!line || /^[a-z][a-z ]+:\s|^-{3}/i.test(line)) break;
usageLines.push(line);
}
}
return {
commands,
page: pagination ? Number(pagination[1]) : null,
pages: pagination ? Number(pagination[2]) : null,
usage: usageLines.length ? usageLines.join("\n").slice(0, 4_096) : null,
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ The bot must have **View Channel** and **Read Message History** for the forum an
| `/api/suggestions/:id` | Suggestion metadata and `originalPost`; `null` when the starter message was deleted. |
| `/api/suggestions/:id/messages?limit=25` | Discussion messages, newest first, including the starter if reached. |
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Lists return `{ items, nextCursor }`. Pass `nextCursor` back as the URL-encoded `cursor` parameter with the same status. Limits are integers from 1 to 100. Active cursors are thread IDs; archived cursors are UTC archive timestamps normalized to `Z` while preserving fractional precision; message cursors are message IDs. Treat cursors as opaque. Messages may yield a final empty page because Discord does not provide a `has_more` flag for messages. Active threads are fetched via the guild active-threads endpoint, filtered to the forum, sorted, and paginated locally. Archives are paginated by Discord. For `limit=1`, the portal requests Discord's minimum of two posts but returns at most one; the cursor follows the last returned post so the extra post remains available on the next page, even when Discord reports no further upstream pages. This is a live view, not a consistent snapshot: threads can move between active and archived lists.
Suggestion fields: `id`, `title`, `authorId`, `createdAt`, `archived`, `locked`, `tags`, `messageCount`, `discordUrl`. Discord's message count is approximate, not a vote count. Detail messages include `id`, `author` (`id`, `name`), `content`, `createdAt`, `editedAt`, `reactions` (`emoji`, `count`), and `discordUrl`. Reactions remain reaction counts, not interpreted votes. Attachments, embeds, and rendered Discord Markdown are not mirrored; use Discord links for the original presentation.
+14 -1
View File
@@ -2,6 +2,19 @@
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
## Command discovery and autocomplete
Select an enabled server and choose **Refresh commands**. The portal reads `help`, then its numbered pages, stripping Minecraft formatting codes and collecting slash-prefixed command entries and descriptions. Plugin/category headings are not executable suggestions. Discovery is best-effort: plugins can omit commands, customize help, or use unsupported formatting. Unknown commands remain freely enterable.
- Refresh is sequential and capped at **64 pages** and **2,048 commands**. Inconsistent pagination, limits, cancellation, or failed requests produce incomplete results rather than a complete-coverage claim.
- Type a command prefix to open suggestions. **Arrow Up/Down** selects; **Tab/Enter** inserts without executing; **Escape** dismisses. Mouse selection also inserts. With suggestions closed, Arrow Up/Down recalls submitted commands and restores the unsent draft. Enter then submits normally; the explicit submit button remains available.
- After a discovered command followed by whitespace is entered, a **350 ms debounce** fetches `help <command>` once for usage hints. Wrapped usage remains readable. Balanced, bounded literal alternatives can suggest arguments, including nested alternatives; placeholders, optional/unsupported syntax, and overly complex usage are hints only, never invented values. This is not native Minecraft tab completion or live player-name completion.
- Commands, descriptions, and usage lookup results (including failures) stay in **page memory**, separately keyed by server ID, endpoint, and enabled state. Reload clears them; endpoint edits invalidate the matching cache. Refresh clears cached usage so failed lookups can be retried. Browsing already cached suggestions sends no RCON requests.
- Switching servers or submitting a manual command stops further discovery pages. An already-sent help request cannot be recalled; manual execution waits for that request to finish rather than racing it for the connection lock. Discovery and usage requests do not populate the console transcript or Arrow-key recall.
- Help requests use the same authorized, audited, bounded server-side command path as manual commands. Each request records a command audit event; responses and suggestion caches are not persisted. Help text is rendered as inert text, never HTML. Errors leave manual command entry available.
No additional Minecraft plugin, database migration, secret, or deployment setting is required for autocomplete.
## Application configuration
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
@@ -23,7 +36,7 @@ The password entered in the administrator connection form must match the server
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
- Responses are sanitized and limited to 64 KiB.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage, application persistence, or logs. Audit events contain the command verb and a domain-separated HMAC digest.
- Up to 50 command/response exchanges remain in a chronological page-memory transcript, and up to 50 submitted commands support Arrow Up/Arrow Down recall. Both are discarded on reload; commands and responses are never written to browser storage or application logs, and responses are not persisted. Audit events persist the complete submitted command, acting administrator and server identity, command verb, a domain-separated HMAC digest, and a correlated safe outcome. Persistent audit-backed command history is separate from page-memory recall.
- Connection passwords are never selected by page queries or returned to the browser.
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.