From e059ca5bfe45104e8a52615c98d845e8490858f4 Mon Sep 17 00:00:00 2001 From: saopig1 Date: Mon, 30 Mar 2026 00:28:46 +0800 Subject: [PATCH] feat: add TS3 command encoding/decoding with escape handling Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ts-protocol/commands.test.ts | 55 +++++++++++++++++++++++ src/ts-protocol/commands.ts | 77 ++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/ts-protocol/commands.test.ts create mode 100644 src/ts-protocol/commands.ts diff --git a/src/ts-protocol/commands.test.ts b/src/ts-protocol/commands.test.ts new file mode 100644 index 0000000..50486f6 --- /dev/null +++ b/src/ts-protocol/commands.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { + encodeCommand, + decodeResponse, + escapeValue, + unescapeValue, +} from "./commands.js"; + +describe("TS3 Commands", () => { + it("encodes a simple command", () => { + const encoded = encodeCommand("login", { + client_login_name: "bot", + client_login_password: "pass", + }); + expect(encoded).toBe( + "login client_login_name=bot client_login_password=pass\n" + ); + }); + + it("escapes special characters in values", () => { + expect(escapeValue("hello world")).toBe("hello\\sworld"); + expect(escapeValue("foo|bar")).toBe("foo\\pbar"); + expect(escapeValue("a/b")).toBe("a\\/b"); + expect(escapeValue("line\nnew")).toBe("line\\nnew"); + }); + + it("unescapes special characters", () => { + expect(unescapeValue("hello\\sworld")).toBe("hello world"); + expect(unescapeValue("foo\\pbar")).toBe("foo|bar"); + expect(unescapeValue("a\\/b")).toBe("a/b"); + }); + + it("decodes a single response", () => { + const response = + "virtualserver_name=My\\sServer virtualserver_port=9987"; + const result = decodeResponse(response); + expect(result).toHaveLength(1); + expect(result[0].virtualserver_name).toBe("My Server"); + expect(result[0].virtualserver_port).toBe("9987"); + }); + + it("decodes a piped multi-entry response", () => { + const response = + "clid=1 client_nickname=User1|clid=2 client_nickname=User2"; + const result = decodeResponse(response); + expect(result).toHaveLength(2); + expect(result[0].client_nickname).toBe("User1"); + expect(result[1].client_nickname).toBe("User2"); + }); + + it("handles command with no params", () => { + const encoded = encodeCommand("quit", {}); + expect(encoded).toBe("quit\n"); + }); +}); diff --git a/src/ts-protocol/commands.ts b/src/ts-protocol/commands.ts new file mode 100644 index 0000000..e89f2e3 --- /dev/null +++ b/src/ts-protocol/commands.ts @@ -0,0 +1,77 @@ +const ESCAPE_MAP: [string, string][] = [ + ["\\", "\\\\"], + ["/", "\\/"], + [" ", "\\s"], + ["|", "\\p"], + ["\n", "\\n"], + ["\r", "\\r"], + ["\t", "\\t"], + ["\x07", "\\a"], + ["\x08", "\\b"], + ["\x0C", "\\f"], + ["\x0B", "\\v"], +]; + +const UNESCAPE_MAP: [string, string][] = ESCAPE_MAP.map( + ([plain, escaped]) => [escaped, plain] as [string, string] +).reverse(); + +export function escapeValue(value: string): string { + let result = value; + for (const [plain, escaped] of ESCAPE_MAP) { + result = result.replaceAll(plain, escaped); + } + return result; +} + +export function unescapeValue(value: string): string { + let result = value; + for (const [escaped, plain] of UNESCAPE_MAP) { + result = result.replaceAll(escaped, plain); + } + return result; +} + +export function encodeCommand( + command: string, + params: Record +): string { + const parts = [command]; + for (const [key, value] of Object.entries(params)) { + parts.push(`${key}=${escapeValue(String(value))}`); + } + return parts.join(" ") + "\n"; +} + +export function decodeResponse(raw: string): Record[] { + const entries = raw.split("|"); + return entries.map((entry) => { + const result: Record = {}; + const pairs = entry.trim().split(" "); + for (const pair of pairs) { + const eqIndex = pair.indexOf("="); + if (eqIndex === -1) { + result[pair] = ""; + } else { + const key = pair.substring(0, eqIndex); + const value = unescapeValue(pair.substring(eqIndex + 1)); + result[key] = value; + } + } + return result; + }); +} + +export interface TS3Response { + errorId: number; + errorMessage: string; + data: Record[]; +} + +export function parseErrorLine(line: string): { id: number; msg: string } { + const decoded = decodeResponse(line.replace(/^error\s+/, ""))[0]; + return { + id: parseInt(decoded.id ?? "0", 10), + msg: decoded.msg ?? "ok", + }; +}