128 lines
5.5 KiB
TypeScript
128 lines
5.5 KiB
TypeScript
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,
|
|
};
|
|
}
|