fix(discord): escape component custom id delimiters

This commit is contained in:
Peter Steinberger
2026-05-29 01:58:20 -04:00
parent ed36f423da
commit fa9901c78f
2 changed files with 102 additions and 10 deletions

View File

@@ -2,17 +2,55 @@ import { parseCustomId, type ComponentParserResult } from "./internal/discord.js
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
const ENCODED_CUSTOM_ID_VERSION = "1";
function encodeCustomIdValue(value: string): string {
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
}
function needsCustomIdEncoding(value: string): boolean {
return /[%;]/.test(value);
}
function decodeCustomIdValue(value: string): string {
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
}
function decodeParsedCustomIdData(
data: ComponentParserResult["data"],
): ComponentParserResult["data"] {
if (data.e !== ENCODED_CUSTOM_ID_VERSION) {
return data;
}
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
typeof value === "string" ? decodeCustomIdValue(value) : value,
]),
) as ComponentParserResult["data"];
}
export function buildDiscordComponentCustomId(params: {
componentId: string;
modalId?: string;
}): string {
const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`;
return params.modalId ? `${base};mid=${params.modalId}` : base;
const encoded =
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
const base = encoded
? `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};cid=${componentId}`
: `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${componentId}`;
const modalId = params.modalId;
if (!modalId) {
return base;
}
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
}
export function buildDiscordModalCustomId(modalId: string): string {
return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
return needsCustomIdEncoding(modalId)
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
}
export function parseDiscordComponentCustomId(
@@ -22,11 +60,12 @@ export function parseDiscordComponentCustomId(
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return null;
}
const componentId = parsed.data.cid;
const data = decodeParsedCustomIdData(parsed.data);
const componentId = data.cid;
if (typeof componentId !== "string" || !componentId.trim()) {
return null;
}
const modalId = parsed.data.mid;
const modalId = data.mid;
return {
componentId,
modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined,
@@ -38,7 +77,8 @@ export function parseDiscordModalCustomId(id: string): string | null {
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return null;
}
const modalId = parsed.data.mid;
const data = decodeParsedCustomIdData(parsed.data);
const modalId = data.mid;
if (typeof modalId !== "string" || !modalId.trim()) {
return null;
}
@@ -57,7 +97,7 @@ export function parseDiscordComponentCustomIdForInteraction(id: string): Compone
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: parsed.data };
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
}
export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult {
@@ -68,5 +108,5 @@ export function parseDiscordModalCustomIdForInteraction(id: string): ComponentPa
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: parsed.data };
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
}

View File

@@ -7,8 +7,14 @@ let resolveDiscordComponentEntry: typeof import("./components-registry.js").reso
let resolveDiscordComponentEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordComponentEntryWithPersistence;
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
let resolveDiscordModalEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordModalEntryWithPersistence;
let buildDiscordComponentCustomId: typeof import("./components.js").buildDiscordComponentCustomId;
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
let buildDiscordModalCustomId: typeof import("./components.js").buildDiscordModalCustomId;
let parseDiscordComponentCustomId: typeof import("./components.js").parseDiscordComponentCustomId;
let parseDiscordComponentCustomIdForInteraction: typeof import("./components.js").parseDiscordComponentCustomIdForInteraction;
let parseDiscordModalCustomId: typeof import("./components.js").parseDiscordModalCustomId;
let parseDiscordModalCustomIdForInteraction: typeof import("./components.js").parseDiscordModalCustomIdForInteraction;
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
beforeAll(async () => {
@@ -20,11 +26,57 @@ beforeAll(async () => {
resolveDiscordModalEntry,
resolveDiscordModalEntryWithPersistence,
} = await import("./components-registry.js"));
({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } =
await import("./components.js"));
({
buildDiscordComponentCustomId,
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
buildDiscordModalCustomId,
parseDiscordComponentCustomId,
parseDiscordComponentCustomIdForInteraction,
parseDiscordModalCustomId,
parseDiscordModalCustomIdForInteraction,
readDiscordComponentSpec,
} = await import("./components.js"));
});
describe("discord components", () => {
it("round-trips custom id values that contain separators", () => {
const componentId = "button=a;two space%3B";
const modalId = "modal=x;y space%3D";
const componentCustomId = buildDiscordComponentCustomId({ componentId, modalId });
expect(componentCustomId).not.toContain(componentId);
expect(componentCustomId).toContain("space");
expect(parseDiscordComponentCustomId(componentCustomId)).toEqual({ componentId, modalId });
expect(parseDiscordComponentCustomIdForInteraction(componentCustomId).data).toMatchObject({
cid: componentId,
mid: modalId,
});
const modalCustomId = buildDiscordModalCustomId(modalId);
expect(modalCustomId).not.toContain(modalId);
expect(modalCustomId).toContain("space");
expect(parseDiscordModalCustomId(modalCustomId)).toBe(modalId);
expect(parseDiscordModalCustomIdForInteraction(modalCustomId).data).toMatchObject({
mid: modalId,
});
});
it("keeps legacy percent-like custom id values raw", () => {
expect(buildDiscordComponentCustomId({ componentId: "button_v1" })).toBe(
"occomp:cid=button_v1",
);
expect(buildDiscordComponentCustomId({ componentId: "button=v1" })).toBe(
"occomp:cid=button=v1",
);
expect(buildDiscordModalCustomId("modal_v1")).toBe("ocmodal:mid=modal_v1");
expect(buildDiscordModalCustomId("modal=v1")).toBe("ocmodal:mid=modal=v1");
expect(parseDiscordComponentCustomId("occomp:cid=button%3Bv1")).toEqual({
componentId: "button%3Bv1",
});
expect(parseDiscordModalCustomId("ocmodal:mid=modal%3Dv1")).toBe("modal%3Dv1");
});
it("builds v2 containers with modal trigger", () => {
const spec = readDiscordComponentSpec({
text: "Choose a path",