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, signal?: AbortSignal, ): Promise { const commands = new Map(); 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> { 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> { if (depth > 8) throw new Error("Complex usage"); const alternatives: Array> = []; let sequence: Array> = [[]]; 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(); 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, }; }