refactor(discord): unify custom-id value codecs into one shared module (#104334)

This commit is contained in:
Peter Steinberger
2026-07-11 02:12:37 -07:00
committed by GitHub
parent 3f2e9184f6
commit 72cf43fa80
8 changed files with 156 additions and 89 deletions
@@ -20,6 +20,7 @@ import type {
import { logDebug, logError } from "openclaw/plugin-sdk/logging-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
import { encodeCustomIdComponent } from "./custom-id-codec.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import {
Button,
@@ -385,7 +386,7 @@ export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
return [`execapproval:id=${encodeCustomIdComponent(approvalId)}`, `action=${action}`].join(";");
}
async function updateMessage(params: {
+12 -18
View File
@@ -1,22 +1,15 @@
// Discord plugin module implements component custom id behavior.
import {
escapeCustomIdFieldValue,
needsCustomIdFieldEscaping,
unescapeCustomIdFieldValue,
} from "./custom-id-codec.js";
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"] {
@@ -26,7 +19,7 @@ function decodeParsedCustomIdData(
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
typeof value === "string" ? decodeCustomIdValue(value) : value,
typeof value === "string" ? unescapeCustomIdFieldValue(value) : value,
]),
) as ComponentParserResult["data"];
}
@@ -36,8 +29,9 @@ export function buildDiscordComponentCustomId(params: {
modalId?: string;
}): string {
const encoded =
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
needsCustomIdFieldEscaping(params.componentId) ||
needsCustomIdFieldEscaping(params.modalId ?? "");
const componentId = encoded ? escapeCustomIdFieldValue(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}`;
@@ -45,12 +39,12 @@ export function buildDiscordComponentCustomId(params: {
if (!modalId) {
return base;
}
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
return `${base};mid=${encoded ? escapeCustomIdFieldValue(modalId) : modalId}`;
}
export function buildDiscordModalCustomId(modalId: string): string {
return needsCustomIdEncoding(modalId)
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
return needsCustomIdFieldEscaping(modalId)
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${escapeCustomIdFieldValue(modalId)}`
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
decodeCustomIdComponent,
encodeCustomIdComponent,
escapeCustomIdFieldValue,
needsCustomIdFieldEscaping,
unescapeCustomIdFieldValue,
} from "./custom-id-codec.js";
const URI_ROUND_TRIP_VALUES = [
"plain",
"with space",
"semi;colon",
"percent%value",
"env|prod",
"unicode-ünïcødé-🎛️",
"a=b&c=d;e=f",
"",
];
describe("custom-id URI component codec", () => {
it("round-trips values through encode/decode", () => {
for (const value of URI_ROUND_TRIP_VALUES) {
expect(decodeCustomIdComponent(encodeCustomIdComponent(value))).toBe(value);
}
});
it("never emits the ; field separator or raw %", () => {
for (const value of URI_ROUND_TRIP_VALUES) {
const encoded = encodeCustomIdComponent(value);
expect(encoded).not.toContain(";");
expect(encoded).not.toMatch(/%(?![0-9A-Fa-f]{2})/);
}
});
// Discord redelivers component ids from old messages indefinitely; values
// that predate strict encoding must pass through unchanged.
it("falls back to the raw value on malformed percent input", () => {
expect(decodeCustomIdComponent("100%")).toBe("100%");
expect(decodeCustomIdComponent("a%zzb")).toBe("a%zzb");
expect(decodeCustomIdComponent("trailing%2")).toBe("trailing%2");
});
it("decodes historical unguarded-encoded values", () => {
expect(decodeCustomIdComponent("a%20b")).toBe("a b");
expect(decodeCustomIdComponent("env%7Cprod")).toBe("env|prod");
});
});
describe("custom-id field escape (versioned occomp/ocmodal grammar)", () => {
it("round-trips only % and the ; separator", () => {
for (const value of URI_ROUND_TRIP_VALUES) {
expect(unescapeCustomIdFieldValue(escapeCustomIdFieldValue(value))).toBe(value);
}
expect(escapeCustomIdFieldValue("a;b%c")).toBe("a%3Bb%25c");
expect(escapeCustomIdFieldValue("unicode-ü 🎛️")).toBe("unicode-ü 🎛️");
});
it("detects values that require escaping", () => {
expect(needsCustomIdFieldEscaping("plain value")).toBe(false);
expect(needsCustomIdFieldEscaping("has;separator")).toBe(true);
expect(needsCustomIdFieldEscaping("has%percent")).toBe(true);
});
// Wire compat: ids escaped by the pre-consolidation copies must keep
// decoding byte-exactly (e=1 payloads live on old Discord messages).
it("decodes historical escaped payloads case-insensitively", () => {
expect(unescapeCustomIdFieldValue("a%3Bb%25c")).toBe("a;b%c");
expect(unescapeCustomIdFieldValue("a%3bb%25c")).toBe("a;b%c");
});
});
+37
View File
@@ -0,0 +1,37 @@
// Discord plugin module implements shared custom-id value codecs.
/**
* URI-component codec for values embedded in `k=v;` custom-id grammars
* (exec approvals, model picker, command args, agent components).
* Decode falls back to the raw value: Discord redelivers old component ids
* indefinitely and historical values may predate strict encoding.
*/
export function encodeCustomIdComponent(value: string): string {
return encodeURIComponent(value);
}
export function decodeCustomIdComponent(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/**
* Minimal field escape for the versioned `occomp`/`ocmodal` grammar: only `%`
* and the `;` field separator are escaped to preserve the 100-char custom-id
* budget. The wire format is versioned (`e=1`); do not swap this for the URI
* codec — in-flight component ids must keep decoding byte-exactly.
*/
export function escapeCustomIdFieldValue(value: string): string {
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
}
export function needsCustomIdFieldEscaping(value: string): boolean {
return /[%;]/.test(value);
}
export function unescapeCustomIdFieldValue(value: string): string {
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
}
@@ -5,6 +5,7 @@ import {
parseDiscordModalCustomId,
} from "../component-custom-id.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
import { decodeCustomIdComponent } from "../custom-id-codec.js";
import type { ComponentData, ModalInteraction } from "../internal/discord.js";
import type { AgentComponentInteraction } from "./agent-components.types.js";
import { formatDiscordUserTag } from "./format.js";
@@ -42,21 +43,12 @@ function mapOptionLabels(
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
const raw = readParsedComponentId(data);
const decodeSafe = (value: string): string => {
if (!value.includes("%")) {
return value;
}
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
return value;
}
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const componentId =
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
typeof raw === "string"
? decodeCustomIdComponent(raw)
: typeof raw === "number"
? String(raw)
: null;
if (!componentId) {
return null;
}
@@ -6,18 +6,12 @@ import type {
DiscordExecApprovalConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { decodeCustomIdComponent } from "../custom-id-codec.js";
import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js";
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
export { extractDiscordChannelId } from "../approval-native.js";
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseExecApprovalData(
data: ComponentData,
@@ -37,7 +31,7 @@ export function parseExecApprovalData(
return null;
}
return {
approvalId: decodeCustomIdValue(rawId),
approvalId: decodeCustomIdComponent(rawId),
action,
};
}
@@ -4,6 +4,7 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
import { parseStrictInteger, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { decodeCustomIdComponent, encodeCustomIdComponent } from "../custom-id-codec.js";
import type { ComponentData } from "../internal/discord.js";
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
@@ -110,18 +111,6 @@ const loadModelsProviderRuntime = createLazyRuntimeModule(
() => import("openclaw/plugin-sdk/models-provider-runtime"),
);
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isValidCommandContext(value: string): value is DiscordModelPickerCommandContext {
return (COMMAND_CONTEXTS as readonly string[]).includes(value);
}
@@ -236,18 +225,18 @@ export function buildDiscordModelPickerCustomId(params: {
: undefined;
const parts = [
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`,
`a=${encodeCustomIdValue(params.action)}`,
`v=${encodeCustomIdValue(params.view)}`,
`u=${encodeCustomIdValue(userId)}`,
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdComponent(params.command)}`,
`a=${encodeCustomIdComponent(params.action)}`,
`v=${encodeCustomIdComponent(params.view)}`,
`u=${encodeCustomIdComponent(userId)}`,
`g=${String(page)}`,
];
if (normalizedProvider) {
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
parts.push(`p=${encodeCustomIdComponent(normalizedProvider)}`);
}
const runtime = params.runtime?.trim();
if (runtime) {
parts.push(`r=${encodeCustomIdValue(runtime)}`);
parts.push(`r=${encodeCustomIdComponent(runtime)}`);
}
const runtimeIndex =
typeof params.runtimeIndex === "number" && Number.isFinite(params.runtimeIndex)
@@ -267,11 +256,11 @@ export function buildDiscordModelPickerCustomId(params: {
}
const providerBucket = params.providerBucket?.trim().toLowerCase();
if (providerBucket) {
parts.push(`pb=${encodeCustomIdValue(providerBucket)}`);
parts.push(`pb=${encodeCustomIdComponent(providerBucket)}`);
}
const modelBucket = params.modelBucket?.trim().toLowerCase();
if (modelBucket) {
parts.push(`mb=${encodeCustomIdValue(modelBucket)}`);
parts.push(`mb=${encodeCustomIdComponent(modelBucket)}`);
}
const customId = parts.join(";");
@@ -313,19 +302,19 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi
return null;
}
const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd));
const action = decodeCustomIdValue(coerceString(data.a ?? data.act));
const view = decodeCustomIdValue(coerceString(data.v ?? data.view));
const userId = decodeCustomIdValue(coerceString(data.u));
const providerRaw = decodeCustomIdValue(coerceString(data.p));
const runtimeRaw = decodeCustomIdValue(coerceString(data.r));
const command = decodeCustomIdComponent(coerceString(data.c ?? data.cmd));
const action = decodeCustomIdComponent(coerceString(data.a ?? data.act));
const view = decodeCustomIdComponent(coerceString(data.v ?? data.view));
const userId = decodeCustomIdComponent(coerceString(data.u));
const providerRaw = decodeCustomIdComponent(coerceString(data.p));
const runtimeRaw = decodeCustomIdComponent(coerceString(data.r));
const runtimeIndex = parseRawPositiveInt(data.ri);
const page = parseRawPage(data.g ?? data.pg);
const providerPage = parseRawPositiveInt(data.pp);
const modelIndex = parseRawPositiveInt(data.mi);
const recentSlot = parseRawPositiveInt(data.rs);
const providerBucketRaw = decodeCustomIdValue(coerceString(data.pb)).trim().toLowerCase();
const modelBucketRaw = decodeCustomIdValue(coerceString(data.mb)).trim().toLowerCase();
const providerBucketRaw = decodeCustomIdComponent(coerceString(data.pb)).trim().toLowerCase();
const modelBucketRaw = decodeCustomIdComponent(coerceString(data.mb)).trim().toLowerCase();
if (!isValidCommandContext(command) || !isValidPickerAction(action) || !isValidPickerView(view)) {
return null;
@@ -12,6 +12,7 @@ import {
type CommandArgs,
} from "openclaw/plugin-sdk/command-auth-native";
import { chunkItems } from "openclaw/plugin-sdk/text-chunking";
import { decodeCustomIdComponent, encodeCustomIdComponent } from "../custom-id-codec.js";
import {
Button,
Row,
@@ -33,18 +34,6 @@ function createCommandArgsWithValue(params: { argName: string; value: string }):
return { values };
}
function encodeDiscordCommandArgValue(value: string): string {
return encodeURIComponent(value);
}
function decodeDiscordCommandArgValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function buildDiscordCommandArgCustomId(params: {
command: string;
arg: string;
@@ -52,10 +41,10 @@ export function buildDiscordCommandArgCustomId(params: {
userId: string;
}): string {
return [
`${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`,
`arg=${encodeDiscordCommandArgValue(params.arg)}`,
`value=${encodeDiscordCommandArgValue(params.value)}`,
`user=${encodeDiscordCommandArgValue(params.userId)}`,
`${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeCustomIdComponent(params.command)}`,
`arg=${encodeCustomIdComponent(params.arg)}`,
`value=${encodeCustomIdComponent(params.value)}`,
`user=${encodeCustomIdComponent(params.userId)}`,
].join(";");
}
@@ -75,10 +64,10 @@ function parseDiscordCommandArgData(
return null;
}
return {
command: decodeDiscordCommandArgValue(rawCommand),
arg: decodeDiscordCommandArgValue(rawArg),
value: decodeDiscordCommandArgValue(rawValue),
userId: decodeDiscordCommandArgValue(rawUser),
command: decodeCustomIdComponent(rawCommand),
arg: decodeCustomIdComponent(rawArg),
value: decodeCustomIdComponent(rawValue),
userId: decodeCustomIdComponent(rawUser),
};
}