mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(discord): omit undefined component registry fields
Prunes undefined Discord component and modal registry metadata before persisting it so SQLite-backed plugin state never receives JSON-incompatible undefined values. Adds direct regression coverage for undefined own properties on component, modal, and nested field entries.
This commit is contained in:
@@ -176,6 +176,25 @@ function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: nu
|
||||
return { ...entry, createdAt, expiresAt };
|
||||
}
|
||||
|
||||
function pruneUndefinedRegistryValues<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => pruneUndefinedRegistryValues(entry)) as T;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[key] = pruneUndefinedRegistryValues(entry);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function registerEntries<
|
||||
T extends { id: string; messageId?: string; createdAt?: number; expiresAt?: number },
|
||||
>(
|
||||
@@ -237,8 +256,9 @@ function registerPersistentRegistryEntries<T extends { id: string }>(params: {
|
||||
return;
|
||||
}
|
||||
for (const entry of params.entries) {
|
||||
const persistedEntry = pruneUndefinedRegistryValues(entry);
|
||||
void store
|
||||
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
|
||||
.register(entry.id, { version: 1, entry: persistedEntry }, { ttlMs: params.ttlMs })
|
||||
.catch(disablePersistentComponentRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
|
||||
|
||||
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
|
||||
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
|
||||
@@ -468,6 +469,92 @@ describe("discord component registry", () => {
|
||||
expect(openKeyedStore).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("omits undefined component fields before persisting registry state", async () => {
|
||||
const componentRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const modalRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const componentStore = {
|
||||
register: componentRegister,
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const modalStore = {
|
||||
register: modalRegister,
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
|
||||
opts.namespace === "discord.components" ? componentStore : modalStore,
|
||||
);
|
||||
const { setDiscordRuntime } = await import("./runtime.js");
|
||||
setDiscordRuntime({
|
||||
state: { openKeyedStore },
|
||||
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
|
||||
} as never);
|
||||
|
||||
const componentEntry = Object.assign(
|
||||
{
|
||||
id: "btn_undefined",
|
||||
kind: "button",
|
||||
label: "Approve",
|
||||
callbackData: "approve",
|
||||
} satisfies DiscordComponentEntry,
|
||||
{ modalId: undefined, sessionKey: undefined },
|
||||
);
|
||||
const modalEntry = Object.assign(
|
||||
{
|
||||
id: "mdl_undefined",
|
||||
title: "Details",
|
||||
fields: [
|
||||
Object.assign(
|
||||
{
|
||||
id: "fld_undefined",
|
||||
name: "reason",
|
||||
label: "Reason",
|
||||
type: "text",
|
||||
} satisfies DiscordModalEntry["fields"][number],
|
||||
{ description: undefined, placeholder: undefined },
|
||||
),
|
||||
],
|
||||
} satisfies DiscordModalEntry,
|
||||
{ sessionKey: undefined },
|
||||
);
|
||||
|
||||
registerDiscordComponentEntries({
|
||||
entries: [componentEntry],
|
||||
modals: [modalEntry],
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
|
||||
expect(modalRegister).toHaveBeenCalledTimes(1);
|
||||
|
||||
const persistedComponent = componentRegister.mock.calls[0]?.[1] as
|
||||
| { entry: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(persistedComponent?.entry.callbackData).toBe("approve");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("modalId");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("sessionKey");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("messageId");
|
||||
|
||||
const modalPayload = modalRegister.mock.calls[0]?.[1] as
|
||||
| { entry: { fields?: Array<Record<string, unknown>> } }
|
||||
| undefined;
|
||||
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("description");
|
||||
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("placeholder");
|
||||
expect(modalPayload?.entry).not.toHaveProperty("sessionKey");
|
||||
expect(modalPayload?.entry).not.toHaveProperty("messageId");
|
||||
|
||||
const inMemoryComponent = resolveDiscordComponentEntry({ id: "btn_undefined", consume: false });
|
||||
expect(inMemoryComponent).toHaveProperty("modalId", undefined);
|
||||
expect(inMemoryComponent).toHaveProperty("sessionKey", undefined);
|
||||
});
|
||||
|
||||
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
|
||||
const componentDelete = vi.fn().mockResolvedValue(true);
|
||||
const componentStore = {
|
||||
|
||||
Reference in New Issue
Block a user