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:
Merlin
2026-05-30 19:39:26 +02:00
committed by GitHub
parent 0a87f6e4ad
commit b6d253eefb
2 changed files with 108 additions and 1 deletions

View File

@@ -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);
}
}

View File

@@ -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 = {