Skip to content

Commit fa9901c

Browse files
committed
fix(discord): escape component custom id delimiters
1 parent ed36f42 commit fa9901c

2 files changed

Lines changed: 102 additions & 10 deletions

File tree

extensions/discord/src/component-custom-id.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,55 @@ import { parseCustomId, type ComponentParserResult } from "./internal/discord.js
22

33
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
44
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
5+
const ENCODED_CUSTOM_ID_VERSION = "1";
6+
7+
function encodeCustomIdValue(value: string): string {
8+
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
9+
}
10+
11+
function needsCustomIdEncoding(value: string): boolean {
12+
return /[%;]/.test(value);
13+
}
14+
15+
function decodeCustomIdValue(value: string): string {
16+
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
17+
}
18+
19+
function decodeParsedCustomIdData(
20+
data: ComponentParserResult["data"],
21+
): ComponentParserResult["data"] {
22+
if (data.e !== ENCODED_CUSTOM_ID_VERSION) {
23+
return data;
24+
}
25+
return Object.fromEntries(
26+
Object.entries(data).map(([key, value]) => [
27+
key,
28+
typeof value === "string" ? decodeCustomIdValue(value) : value,
29+
]),
30+
) as ComponentParserResult["data"];
31+
}
532

633
export function buildDiscordComponentCustomId(params: {
734
componentId: string;
835
modalId?: string;
936
}): string {
10-
const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`;
11-
return params.modalId ? `${base};mid=${params.modalId}` : base;
37+
const encoded =
38+
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
39+
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
40+
const base = encoded
41+
? `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};cid=${componentId}`
42+
: `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${componentId}`;
43+
const modalId = params.modalId;
44+
if (!modalId) {
45+
return base;
46+
}
47+
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
1248
}
1349

1450
export function buildDiscordModalCustomId(modalId: string): string {
15-
return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
51+
return needsCustomIdEncoding(modalId)
52+
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
53+
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
1654
}
1755

1856
export function parseDiscordComponentCustomId(
@@ -22,11 +60,12 @@ export function parseDiscordComponentCustomId(
2260
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
2361
return null;
2462
}
25-
const componentId = parsed.data.cid;
63+
const data = decodeParsedCustomIdData(parsed.data);
64+
const componentId = data.cid;
2665
if (typeof componentId !== "string" || !componentId.trim()) {
2766
return null;
2867
}
29-
const modalId = parsed.data.mid;
68+
const modalId = data.mid;
3069
return {
3170
componentId,
3271
modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined,
@@ -38,7 +77,8 @@ export function parseDiscordModalCustomId(id: string): string | null {
3877
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
3978
return null;
4079
}
41-
const modalId = parsed.data.mid;
80+
const data = decodeParsedCustomIdData(parsed.data);
81+
const modalId = data.mid;
4282
if (typeof modalId !== "string" || !modalId.trim()) {
4383
return null;
4484
}
@@ -57,7 +97,7 @@ export function parseDiscordComponentCustomIdForInteraction(id: string): Compone
5797
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
5898
return parsed;
5999
}
60-
return { key: "*", data: parsed.data };
100+
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
61101
}
62102

63103
export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult {
@@ -68,5 +108,5 @@ export function parseDiscordModalCustomIdForInteraction(id: string): ComponentPa
68108
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
69109
return parsed;
70110
}
71-
return { key: "*", data: parsed.data };
111+
return { key: "*", data: decodeParsedCustomIdData(parsed.data) };
72112
}

extensions/discord/src/components.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ let resolveDiscordComponentEntry: typeof import("./components-registry.js").reso
77
let resolveDiscordComponentEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordComponentEntryWithPersistence;
88
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
99
let resolveDiscordModalEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordModalEntryWithPersistence;
10+
let buildDiscordComponentCustomId: typeof import("./components.js").buildDiscordComponentCustomId;
1011
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
1112
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
13+
let buildDiscordModalCustomId: typeof import("./components.js").buildDiscordModalCustomId;
14+
let parseDiscordComponentCustomId: typeof import("./components.js").parseDiscordComponentCustomId;
15+
let parseDiscordComponentCustomIdForInteraction: typeof import("./components.js").parseDiscordComponentCustomIdForInteraction;
16+
let parseDiscordModalCustomId: typeof import("./components.js").parseDiscordModalCustomId;
17+
let parseDiscordModalCustomIdForInteraction: typeof import("./components.js").parseDiscordModalCustomIdForInteraction;
1218
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
1319

1420
beforeAll(async () => {
@@ -20,11 +26,57 @@ beforeAll(async () => {
2026
resolveDiscordModalEntry,
2127
resolveDiscordModalEntryWithPersistence,
2228
} = await import("./components-registry.js"));
23-
({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } =
24-
await import("./components.js"));
29+
({
30+
buildDiscordComponentCustomId,
31+
buildDiscordComponentMessage,
32+
buildDiscordComponentMessageFlags,
33+
buildDiscordModalCustomId,
34+
parseDiscordComponentCustomId,
35+
parseDiscordComponentCustomIdForInteraction,
36+
parseDiscordModalCustomId,
37+
parseDiscordModalCustomIdForInteraction,
38+
readDiscordComponentSpec,
39+
} = await import("./components.js"));
2540
});
2641

2742
describe("discord components", () => {
43+
it("round-trips custom id values that contain separators", () => {
44+
const componentId = "button=a;two space%3B";
45+
const modalId = "modal=x;y space%3D";
46+
47+
const componentCustomId = buildDiscordComponentCustomId({ componentId, modalId });
48+
expect(componentCustomId).not.toContain(componentId);
49+
expect(componentCustomId).toContain("space");
50+
expect(parseDiscordComponentCustomId(componentCustomId)).toEqual({ componentId, modalId });
51+
expect(parseDiscordComponentCustomIdForInteraction(componentCustomId).data).toMatchObject({
52+
cid: componentId,
53+
mid: modalId,
54+
});
55+
56+
const modalCustomId = buildDiscordModalCustomId(modalId);
57+
expect(modalCustomId).not.toContain(modalId);
58+
expect(modalCustomId).toContain("space");
59+
expect(parseDiscordModalCustomId(modalCustomId)).toBe(modalId);
60+
expect(parseDiscordModalCustomIdForInteraction(modalCustomId).data).toMatchObject({
61+
mid: modalId,
62+
});
63+
});
64+
65+
it("keeps legacy percent-like custom id values raw", () => {
66+
expect(buildDiscordComponentCustomId({ componentId: "button_v1" })).toBe(
67+
"occomp:cid=button_v1",
68+
);
69+
expect(buildDiscordComponentCustomId({ componentId: "button=v1" })).toBe(
70+
"occomp:cid=button=v1",
71+
);
72+
expect(buildDiscordModalCustomId("modal_v1")).toBe("ocmodal:mid=modal_v1");
73+
expect(buildDiscordModalCustomId("modal=v1")).toBe("ocmodal:mid=modal=v1");
74+
expect(parseDiscordComponentCustomId("occomp:cid=button%3Bv1")).toEqual({
75+
componentId: "button%3Bv1",
76+
});
77+
expect(parseDiscordModalCustomId("ocmodal:mid=modal%3Dv1")).toBe("modal%3Dv1");
78+
});
79+
2880
it("builds v2 containers with modal trigger", () => {
2981
const spec = readDiscordComponentSpec({
3082
text: "Choose a path",

0 commit comments

Comments
 (0)