Skip to content

Commit 72cf43f

Browse files
authored
refactor(discord): unify custom-id value codecs into one shared module (#104334)
1 parent 3f2e918 commit 72cf43f

8 files changed

Lines changed: 156 additions & 89 deletions

extensions/discord/src/approval-handler.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import { logDebug, logError } from "openclaw/plugin-sdk/logging-core";
2121
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
2222
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
23+
import { encodeCustomIdComponent } from "./custom-id-codec.js";
2324
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
2425
import {
2526
Button,
@@ -385,7 +386,7 @@ export function buildExecApprovalCustomId(
385386
approvalId: string,
386387
action: ExecApprovalDecision,
387388
): string {
388-
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
389+
return [`execapproval:id=${encodeCustomIdComponent(approvalId)}`, `action=${action}`].join(";");
389390
}
390391

391392
async function updateMessage(params: {

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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
// Discord plugin module implements component custom id behavior.
2+
import {
3+
escapeCustomIdFieldValue,
4+
needsCustomIdFieldEscaping,
5+
unescapeCustomIdFieldValue,
6+
} from "./custom-id-codec.js";
27
import { parseCustomId, type ComponentParserResult } from "./internal/discord.js";
38

49
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
510
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
611
const ENCODED_CUSTOM_ID_VERSION = "1";
712

8-
function encodeCustomIdValue(value: string): string {
9-
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
10-
}
11-
12-
function needsCustomIdEncoding(value: string): boolean {
13-
return /[%;]/.test(value);
14-
}
15-
16-
function decodeCustomIdValue(value: string): string {
17-
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
18-
}
19-
2013
function decodeParsedCustomIdData(
2114
data: ComponentParserResult["data"],
2215
): ComponentParserResult["data"] {
@@ -26,7 +19,7 @@ function decodeParsedCustomIdData(
2619
return Object.fromEntries(
2720
Object.entries(data).map(([key, value]) => [
2821
key,
29-
typeof value === "string" ? decodeCustomIdValue(value) : value,
22+
typeof value === "string" ? unescapeCustomIdFieldValue(value) : value,
3023
]),
3124
) as ComponentParserResult["data"];
3225
}
@@ -36,21 +29,22 @@ export function buildDiscordComponentCustomId(params: {
3629
modalId?: string;
3730
}): string {
3831
const encoded =
39-
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
40-
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
32+
needsCustomIdFieldEscaping(params.componentId) ||
33+
needsCustomIdFieldEscaping(params.modalId ?? "");
34+
const componentId = encoded ? escapeCustomIdFieldValue(params.componentId) : params.componentId;
4135
const base = encoded
4236
? `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};cid=${componentId}`
4337
: `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${componentId}`;
4438
const modalId = params.modalId;
4539
if (!modalId) {
4640
return base;
4741
}
48-
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
42+
return `${base};mid=${encoded ? escapeCustomIdFieldValue(modalId) : modalId}`;
4943
}
5044

5145
export function buildDiscordModalCustomId(modalId: string): string {
52-
return needsCustomIdEncoding(modalId)
53-
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
46+
return needsCustomIdFieldEscaping(modalId)
47+
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${escapeCustomIdFieldValue(modalId)}`
5448
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
5549
}
5650

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
decodeCustomIdComponent,
4+
encodeCustomIdComponent,
5+
escapeCustomIdFieldValue,
6+
needsCustomIdFieldEscaping,
7+
unescapeCustomIdFieldValue,
8+
} from "./custom-id-codec.js";
9+
10+
const URI_ROUND_TRIP_VALUES = [
11+
"plain",
12+
"with space",
13+
"semi;colon",
14+
"percent%value",
15+
"env|prod",
16+
"unicode-ünïcødé-🎛️",
17+
"a=b&c=d;e=f",
18+
"",
19+
];
20+
21+
describe("custom-id URI component codec", () => {
22+
it("round-trips values through encode/decode", () => {
23+
for (const value of URI_ROUND_TRIP_VALUES) {
24+
expect(decodeCustomIdComponent(encodeCustomIdComponent(value))).toBe(value);
25+
}
26+
});
27+
28+
it("never emits the ; field separator or raw %", () => {
29+
for (const value of URI_ROUND_TRIP_VALUES) {
30+
const encoded = encodeCustomIdComponent(value);
31+
expect(encoded).not.toContain(";");
32+
expect(encoded).not.toMatch(/%(?![0-9A-Fa-f]{2})/);
33+
}
34+
});
35+
36+
// Discord redelivers component ids from old messages indefinitely; values
37+
// that predate strict encoding must pass through unchanged.
38+
it("falls back to the raw value on malformed percent input", () => {
39+
expect(decodeCustomIdComponent("100%")).toBe("100%");
40+
expect(decodeCustomIdComponent("a%zzb")).toBe("a%zzb");
41+
expect(decodeCustomIdComponent("trailing%2")).toBe("trailing%2");
42+
});
43+
44+
it("decodes historical unguarded-encoded values", () => {
45+
expect(decodeCustomIdComponent("a%20b")).toBe("a b");
46+
expect(decodeCustomIdComponent("env%7Cprod")).toBe("env|prod");
47+
});
48+
});
49+
50+
describe("custom-id field escape (versioned occomp/ocmodal grammar)", () => {
51+
it("round-trips only % and the ; separator", () => {
52+
for (const value of URI_ROUND_TRIP_VALUES) {
53+
expect(unescapeCustomIdFieldValue(escapeCustomIdFieldValue(value))).toBe(value);
54+
}
55+
expect(escapeCustomIdFieldValue("a;b%c")).toBe("a%3Bb%25c");
56+
expect(escapeCustomIdFieldValue("unicode-ü 🎛️")).toBe("unicode-ü 🎛️");
57+
});
58+
59+
it("detects values that require escaping", () => {
60+
expect(needsCustomIdFieldEscaping("plain value")).toBe(false);
61+
expect(needsCustomIdFieldEscaping("has;separator")).toBe(true);
62+
expect(needsCustomIdFieldEscaping("has%percent")).toBe(true);
63+
});
64+
65+
// Wire compat: ids escaped by the pre-consolidation copies must keep
66+
// decoding byte-exactly (e=1 payloads live on old Discord messages).
67+
it("decodes historical escaped payloads case-insensitively", () => {
68+
expect(unescapeCustomIdFieldValue("a%3Bb%25c")).toBe("a;b%c");
69+
expect(unescapeCustomIdFieldValue("a%3bb%25c")).toBe("a;b%c");
70+
});
71+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Discord plugin module implements shared custom-id value codecs.
2+
3+
/**
4+
* URI-component codec for values embedded in `k=v;` custom-id grammars
5+
* (exec approvals, model picker, command args, agent components).
6+
* Decode falls back to the raw value: Discord redelivers old component ids
7+
* indefinitely and historical values may predate strict encoding.
8+
*/
9+
export function encodeCustomIdComponent(value: string): string {
10+
return encodeURIComponent(value);
11+
}
12+
13+
export function decodeCustomIdComponent(value: string): string {
14+
try {
15+
return decodeURIComponent(value);
16+
} catch {
17+
return value;
18+
}
19+
}
20+
21+
/**
22+
* Minimal field escape for the versioned `occomp`/`ocmodal` grammar: only `%`
23+
* and the `;` field separator are escaped to preserve the 100-char custom-id
24+
* budget. The wire format is versioned (`e=1`); do not swap this for the URI
25+
* codec — in-flight component ids must keep decoding byte-exactly.
26+
*/
27+
export function escapeCustomIdFieldValue(value: string): string {
28+
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
29+
}
30+
31+
export function needsCustomIdFieldEscaping(value: string): boolean {
32+
return /[%;]/.test(value);
33+
}
34+
35+
export function unescapeCustomIdFieldValue(value: string): string {
36+
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
37+
}

extensions/discord/src/monitor/agent-components-data.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
parseDiscordModalCustomId,
66
} from "../component-custom-id.js";
77
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
8+
import { decodeCustomIdComponent } from "../custom-id-codec.js";
89
import type { ComponentData, ModalInteraction } from "../internal/discord.js";
910
import type { AgentComponentInteraction } from "./agent-components.types.js";
1011
import { formatDiscordUserTag } from "./format.js";
@@ -42,21 +43,12 @@ function mapOptionLabels(
4243

4344
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
4445
const raw = readParsedComponentId(data);
45-
const decodeSafe = (value: string): string => {
46-
if (!value.includes("%")) {
47-
return value;
48-
}
49-
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
50-
return value;
51-
}
52-
try {
53-
return decodeURIComponent(value);
54-
} catch {
55-
return value;
56-
}
57-
};
5846
const componentId =
59-
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
47+
typeof raw === "string"
48+
? decodeCustomIdComponent(raw)
49+
: typeof raw === "number"
50+
? String(raw)
51+
: null;
6052
if (!componentId) {
6153
return null;
6254
}

extensions/discord/src/monitor/exec-approvals.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,12 @@ import type {
66
DiscordExecApprovalConfig,
77
OpenClawConfig,
88
} from "openclaw/plugin-sdk/config-contracts";
9+
import { decodeCustomIdComponent } from "../custom-id-codec.js";
910
import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js";
1011
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
1112
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
1213

1314
export { extractDiscordChannelId } from "../approval-native.js";
14-
function decodeCustomIdValue(value: string): string {
15-
try {
16-
return decodeURIComponent(value);
17-
} catch {
18-
return value;
19-
}
20-
}
2115

2216
export function parseExecApprovalData(
2317
data: ComponentData,
@@ -37,7 +31,7 @@ export function parseExecApprovalData(
3731
return null;
3832
}
3933
return {
40-
approvalId: decodeCustomIdValue(rawId),
34+
approvalId: decodeCustomIdComponent(rawId),
4135
action,
4236
};
4337
}

extensions/discord/src/monitor/model-picker.state.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
44
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
55
import { parseStrictInteger, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
66
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
7+
import { decodeCustomIdComponent, encodeCustomIdComponent } from "../custom-id-codec.js";
78
import type { ComponentData } from "../internal/discord.js";
89

910
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
@@ -110,18 +111,6 @@ const loadModelsProviderRuntime = createLazyRuntimeModule(
110111
() => import("openclaw/plugin-sdk/models-provider-runtime"),
111112
);
112113

113-
function encodeCustomIdValue(value: string): string {
114-
return encodeURIComponent(value);
115-
}
116-
117-
function decodeCustomIdValue(value: string): string {
118-
try {
119-
return decodeURIComponent(value);
120-
} catch {
121-
return value;
122-
}
123-
}
124-
125114
function isValidCommandContext(value: string): value is DiscordModelPickerCommandContext {
126115
return (COMMAND_CONTEXTS as readonly string[]).includes(value);
127116
}
@@ -236,18 +225,18 @@ export function buildDiscordModelPickerCustomId(params: {
236225
: undefined;
237226

238227
const parts = [
239-
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`,
240-
`a=${encodeCustomIdValue(params.action)}`,
241-
`v=${encodeCustomIdValue(params.view)}`,
242-
`u=${encodeCustomIdValue(userId)}`,
228+
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdComponent(params.command)}`,
229+
`a=${encodeCustomIdComponent(params.action)}`,
230+
`v=${encodeCustomIdComponent(params.view)}`,
231+
`u=${encodeCustomIdComponent(userId)}`,
243232
`g=${String(page)}`,
244233
];
245234
if (normalizedProvider) {
246-
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
235+
parts.push(`p=${encodeCustomIdComponent(normalizedProvider)}`);
247236
}
248237
const runtime = params.runtime?.trim();
249238
if (runtime) {
250-
parts.push(`r=${encodeCustomIdValue(runtime)}`);
239+
parts.push(`r=${encodeCustomIdComponent(runtime)}`);
251240
}
252241
const runtimeIndex =
253242
typeof params.runtimeIndex === "number" && Number.isFinite(params.runtimeIndex)
@@ -267,11 +256,11 @@ export function buildDiscordModelPickerCustomId(params: {
267256
}
268257
const providerBucket = params.providerBucket?.trim().toLowerCase();
269258
if (providerBucket) {
270-
parts.push(`pb=${encodeCustomIdValue(providerBucket)}`);
259+
parts.push(`pb=${encodeCustomIdComponent(providerBucket)}`);
271260
}
272261
const modelBucket = params.modelBucket?.trim().toLowerCase();
273262
if (modelBucket) {
274-
parts.push(`mb=${encodeCustomIdValue(modelBucket)}`);
263+
parts.push(`mb=${encodeCustomIdComponent(modelBucket)}`);
275264
}
276265

277266
const customId = parts.join(";");
@@ -313,19 +302,19 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi
313302
return null;
314303
}
315304

316-
const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd));
317-
const action = decodeCustomIdValue(coerceString(data.a ?? data.act));
318-
const view = decodeCustomIdValue(coerceString(data.v ?? data.view));
319-
const userId = decodeCustomIdValue(coerceString(data.u));
320-
const providerRaw = decodeCustomIdValue(coerceString(data.p));
321-
const runtimeRaw = decodeCustomIdValue(coerceString(data.r));
305+
const command = decodeCustomIdComponent(coerceString(data.c ?? data.cmd));
306+
const action = decodeCustomIdComponent(coerceString(data.a ?? data.act));
307+
const view = decodeCustomIdComponent(coerceString(data.v ?? data.view));
308+
const userId = decodeCustomIdComponent(coerceString(data.u));
309+
const providerRaw = decodeCustomIdComponent(coerceString(data.p));
310+
const runtimeRaw = decodeCustomIdComponent(coerceString(data.r));
322311
const runtimeIndex = parseRawPositiveInt(data.ri);
323312
const page = parseRawPage(data.g ?? data.pg);
324313
const providerPage = parseRawPositiveInt(data.pp);
325314
const modelIndex = parseRawPositiveInt(data.mi);
326315
const recentSlot = parseRawPositiveInt(data.rs);
327-
const providerBucketRaw = decodeCustomIdValue(coerceString(data.pb)).trim().toLowerCase();
328-
const modelBucketRaw = decodeCustomIdValue(coerceString(data.mb)).trim().toLowerCase();
316+
const providerBucketRaw = decodeCustomIdComponent(coerceString(data.pb)).trim().toLowerCase();
317+
const modelBucketRaw = decodeCustomIdComponent(coerceString(data.mb)).trim().toLowerCase();
329318

330319
if (!isValidCommandContext(command) || !isValidPickerAction(action) || !isValidPickerView(view)) {
331320
return null;

0 commit comments

Comments
 (0)