Merge f684c0ecb12c727d7ab15e411eb44a0898d628ad into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
74578e28a5
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user