From fa9901c78ff0f6a828cc63faacefe8a189bead8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 01:58:20 -0400 Subject: [PATCH] fix(discord): escape component custom id delimiters --- extensions/discord/src/component-custom-id.ts | 56 ++++++++++++++++--- extensions/discord/src/components.test.ts | 56 ++++++++++++++++++- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/extensions/discord/src/component-custom-id.ts b/extensions/discord/src/component-custom-id.ts index 2ce5bafb0bc..fbd62700acc 100644 --- a/extensions/discord/src/component-custom-id.ts +++ b/extensions/discord/src/component-custom-id.ts @@ -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) }; } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index d0cde305a54..3f005199f8e 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -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",