diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts index ce7014aba75..3ee9aca32f0 100644 --- a/extensions/discord/src/components-registry.ts +++ b/extensions/discord/src/components-registry.ts @@ -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(); const modalEntries = new Map(); +let hasLoadedRegistryStore = false; + +type DiscordComponentRegistryStore = { + components: Record; + modals: Record; +}; + +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 | 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(params: { + entries: Map; + 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(); } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 44350b4fc4b..cd9d0daa4b2 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -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(); + }); }); });