mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
refactor(discord): unify custom-id value codecs into one shared module (#104334)
This commit is contained in:
committed by
GitHub
parent
3f2e9184f6
commit
72cf43fa80
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user