Merge f684c0ecb12c727d7ab15e411eb44a0898d628ad into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
rendrag-git 2026-03-21 11:12:19 +07:00 committed by GitHub
commit 74578e28a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 219 additions and 63 deletions

View File

@ -1,9 +1,70 @@
import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../../../src/config/paths.js";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const COMPONENT_REGISTRY_FILENAME = "components-registry.json";
const componentEntries = new Map<string, DiscordComponentEntry>();
const modalEntries = new Map<string, DiscordModalEntry>();
let hasLoadedRegistryStore = false;
type DiscordComponentRegistryStore = {
components: Record<string, DiscordComponentEntry>;
modals: Record<string, DiscordModalEntry>;
};
function resolveDiscordComponentRegistryPath(): string {
return path.join(resolveStateDir(), "discord", COMPONENT_REGISTRY_FILENAME);
}
function createEmptyStore(): DiscordComponentRegistryStore {
return {
components: {},
modals: {},
};
}
function writeStoreAtomically(filePath: string, store: DiscordComponentRegistryStore): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
const serialized = `${JSON.stringify(store, null, 2)}\n`;
try {
fs.writeFileSync(tempPath, serialized, { encoding: "utf8", mode: 0o600 });
fs.renameSync(tempPath, filePath);
} finally {
try {
fs.rmSync(tempPath, { force: true });
} catch {
// best-effort
}
}
}
function persistRegistryStore(): void {
writeStoreAtomically(resolveDiscordComponentRegistryPath(), {
components: Object.fromEntries(componentEntries),
modals: Object.fromEntries(modalEntries),
});
}
function readStoreFromDisk(): DiscordComponentRegistryStore {
try {
const raw = fs.readFileSync(resolveDiscordComponentRegistryPath(), "utf8");
const parsed = JSON.parse(raw) as Partial<DiscordComponentRegistryStore> | null;
if (!parsed || typeof parsed !== "object") {
return createEmptyStore();
}
return {
components:
parsed.components && typeof parsed.components === "object" ? parsed.components : {},
modals: parsed.modals && typeof parsed.modals === "object" ? parsed.modals : {},
};
} catch {
return createEmptyStore();
}
}
function isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
@ -19,12 +80,58 @@ function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: nu
return { ...entry, createdAt, expiresAt };
}
function ensureRegistryStoreLoaded(): void {
if (hasLoadedRegistryStore) {
return;
}
const store = readStoreFromDisk();
const now = Date.now();
componentEntries.clear();
modalEntries.clear();
for (const [id, entry] of Object.entries(store.components)) {
if (isExpired(entry, now)) {
continue;
}
componentEntries.set(id, entry);
}
for (const [id, entry] of Object.entries(store.modals)) {
if (isExpired(entry, now)) {
continue;
}
modalEntries.set(id, entry);
}
hasLoadedRegistryStore = true;
}
function resolveEntry<T extends { expiresAt?: number }>(params: {
entries: Map<string, T>;
id: string;
consume?: boolean;
}): T | null {
ensureRegistryStoreLoaded();
const entry = params.entries.get(params.id);
if (!entry) {
return null;
}
if (isExpired(entry, Date.now())) {
params.entries.delete(params.id);
persistRegistryStore();
return null;
}
if (params.consume !== false) {
params.entries.delete(params.id);
persistRegistryStore();
}
return entry;
}
export function registerDiscordComponentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs?: number;
messageId?: string;
}): void {
ensureRegistryStoreLoaded();
const now = Date.now();
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
for (const entry of params.entries) {
@ -43,47 +150,34 @@ export function registerDiscordComponentEntries(params: {
);
modalEntries.set(modal.id, normalized);
}
persistRegistryStore();
}
export function resolveDiscordComponentEntry(params: {
id: string;
consume?: boolean;
}): DiscordComponentEntry | null {
const entry = componentEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
componentEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
componentEntries.delete(params.id);
}
return entry;
return resolveEntry({
entries: componentEntries,
id: params.id,
consume: params.consume,
});
}
export function resolveDiscordModalEntry(params: {
id: string;
consume?: boolean;
}): DiscordModalEntry | null {
const entry = modalEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
modalEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
modalEntries.delete(params.id);
}
return entry;
return resolveEntry({
entries: modalEntries,
id: params.id,
consume: params.consume,
});
}
export function clearDiscordComponentEntries(): void {
componentEntries.clear();
modalEntries.clear();
hasLoadedRegistryStore = true;
persistRegistryStore();
}

View File

@ -1,11 +1,6 @@
import { MessageFlags } from "discord-api-types/v10";
import { describe, expect, it, beforeEach } from "vitest";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "./components-registry.js";
import { describe, expect, it, vi } from "vitest";
import { withStateDirEnv } from "../../../src/test-helpers/state-dir-env.js";
import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
@ -19,13 +14,11 @@ describe("discord components", () => {
blocks: [
{
type: "actions",
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
buttons: [{ label: "Approve", style: "success" }],
},
],
modal: {
title: "Details",
callbackData: "codex:modal",
allowedUsers: ["discord:user-1"],
fields: [{ type: "text", label: "Requester" }],
},
});
@ -41,11 +34,6 @@ describe("discord components", () => {
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
expect(trigger?.modalId).toBe(result.modals[0]?.id);
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
"codex:approve",
);
expect(result.modals[0]?.callbackData).toBe("codex:modal");
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
});
it("requires options for modal select fields", () => {
@ -74,32 +62,106 @@ describe("discord components", () => {
});
describe("discord component registry", () => {
beforeEach(() => {
clearDiscordComponentEntries();
async function importRegistryModule() {
vi.resetModules();
return import("./components-registry.js");
}
it("registers and consumes component entries", async () => {
await withStateDirEnv("openclaw-discord-components-registry-", async () => {
const registry = await importRegistryModule();
registry.clearDiscordComponentEntries();
registry.registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
modals: [
{
id: "mdl_1",
title: "Details",
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
messageId: "msg_1",
ttlMs: 1000,
});
const entry = registry.resolveDiscordComponentEntry({ id: "btn_1", consume: false });
expect(entry?.messageId).toBe("msg_1");
const modal = registry.resolveDiscordModalEntry({ id: "mdl_1", consume: false });
expect(modal?.messageId).toBe("msg_1");
const consumed = registry.resolveDiscordComponentEntry({ id: "btn_1" });
expect(consumed?.id).toBe("btn_1");
expect(registry.resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
});
it("registers and consumes component entries", () => {
registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
modals: [
{
id: "mdl_1",
title: "Details",
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
messageId: "msg_1",
ttlMs: 1000,
it("rehydrates component entries after a simulated restart", async () => {
await withStateDirEnv("openclaw-discord-components-registry-", async () => {
const firstLoad = await importRegistryModule();
firstLoad.clearDiscordComponentEntries();
firstLoad.registerDiscordComponentEntries({
entries: [{ id: "btn_restart", kind: "button", label: "Rehydrate" }],
modals: [
{
id: "mdl_restart",
title: "Restart Form",
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
messageId: "msg_restart",
ttlMs: 60_000,
});
const reloaded = await importRegistryModule();
const component = reloaded.resolveDiscordComponentEntry({
id: "btn_restart",
consume: false,
});
expect(component).toMatchObject({
id: "btn_restart",
messageId: "msg_restart",
});
const modal = reloaded.resolveDiscordModalEntry({ id: "mdl_restart", consume: false });
expect(modal).toMatchObject({
id: "mdl_restart",
messageId: "msg_restart",
});
});
});
const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false });
expect(entry?.messageId).toBe("msg_1");
it("prunes expired entries after a simulated restart", async () => {
await withStateDirEnv("openclaw-discord-components-registry-", async () => {
const firstLoad = await importRegistryModule();
firstLoad.clearDiscordComponentEntries();
const expiredAt = Date.now() - 1_000;
firstLoad.registerDiscordComponentEntries({
entries: [
{
id: "btn_expired",
kind: "button",
label: "Expired",
createdAt: expiredAt - 1_000,
expiresAt: expiredAt,
},
],
modals: [
{
id: "mdl_expired",
title: "Expired Form",
createdAt: expiredAt - 1_000,
expiresAt: expiredAt,
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
});
const modal = resolveDiscordModalEntry({ id: "mdl_1", consume: false });
expect(modal?.messageId).toBe("msg_1");
const consumed = resolveDiscordComponentEntry({ id: "btn_1" });
expect(consumed?.id).toBe("btn_1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
const reloaded = await importRegistryModule();
expect(
reloaded.resolveDiscordComponentEntry({ id: "btn_expired", consume: false }),
).toBeNull();
expect(reloaded.resolveDiscordModalEntry({ id: "mdl_expired", consume: false })).toBeNull();
});
});
});