From 6c55106c807d22961b082c649c2764df623d0b63 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Fri, 1 May 2026 22:17:51 +1000 Subject: [PATCH] discord: persist component registries best-effort (#75584) --- CHANGELOG.md | 1 + extensions/discord/src/components-registry.ts | 223 +++++++++++++++++- extensions/discord/src/components.test.ts | 97 +++++++- .../src/monitor/agent-components.handlers.ts | 24 +- .../src/monitor/agent-components.modal.ts | 9 +- 5 files changed, 341 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddbe31891c4..9b0a3f9e9e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai - Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP. - macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti. - Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc. +- Discord: keep active buttons, selects, and forms working across Gateway restarts until they expire, so multi-step Discord interactions are less likely to break during upgrades or restarts. Thanks @amknight. - Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box. - Slack: keep track of bot-participated threads across restarts, so ongoing threaded conversations can continue auto-replying after the Gateway is restarted. Thanks @amknight. - Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok. diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts index 1ed8ac4f7e2..8d581548bda 100644 --- a/extensions/discord/src/components-registry.ts +++ b/extensions/discord/src/components-registry.ts @@ -1,12 +1,36 @@ import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton"; import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; +import { getOptionalDiscordRuntime } from "./runtime.js"; const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000; +const PERSISTENT_COMPONENT_NAMESPACE = "discord.components"; +const PERSISTENT_MODAL_NAMESPACE = "discord.modals"; +const PERSISTENT_COMPONENT_MAX_ENTRIES = 500; +const PERSISTENT_MODAL_MAX_ENTRIES = 500; const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries"); const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries"); +type PersistedDiscordRegistryEntry = { + version: 1; + entry: T; +}; + +type DiscordPersistentStore = { + register(key: string, value: T, opts?: { ttlMs?: number }): Promise; + lookup(key: string): Promise; + consume(key: string): Promise; + delete(key: string): Promise; +}; + +type DiscordRegistryStore = DiscordPersistentStore< + PersistedDiscordRegistryEntry +>; + let componentEntries: Map | undefined; let modalEntries: Map | undefined; +let persistentComponentStore: DiscordRegistryStore | undefined; +let persistentModalStore: DiscordRegistryStore | undefined; +let persistentRegistryDisabled = false; function getComponentEntries(): Map { componentEntries ??= resolveGlobalMap( @@ -20,6 +44,75 @@ function getModalEntries(): Map { return modalEntries; } +function reportPersistentComponentRegistryError(error: unknown): void { + try { + getOptionalDiscordRuntime() + ?.logging.getChildLogger({ plugin: "discord", feature: "component-registry-state" }) + .warn("Discord persistent component registry state failed", { error: String(error) }); + } catch { + // Best effort only: persistent state must never break Discord interactions. + } +} + +function disablePersistentComponentRegistry(error: unknown): void { + persistentRegistryDisabled = true; + persistentComponentStore = undefined; + persistentModalStore = undefined; + reportPersistentComponentRegistryError(error); +} + +function getPersistentComponentStore(): DiscordRegistryStore | undefined { + if (persistentRegistryDisabled) { + return undefined; + } + if (persistentComponentStore) { + return persistentComponentStore; + } + const runtime = getOptionalDiscordRuntime(); + if (!runtime) { + return undefined; + } + try { + persistentComponentStore = runtime.state.openKeyedStore< + PersistedDiscordRegistryEntry + >({ + namespace: PERSISTENT_COMPONENT_NAMESPACE, + maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES, + defaultTtlMs: DEFAULT_COMPONENT_TTL_MS, + }); + return persistentComponentStore; + } catch (error) { + disablePersistentComponentRegistry(error); + return undefined; + } +} + +function getPersistentModalStore(): DiscordRegistryStore | undefined { + if (persistentRegistryDisabled) { + return undefined; + } + if (persistentModalStore) { + return persistentModalStore; + } + const runtime = getOptionalDiscordRuntime(); + if (!runtime) { + return undefined; + } + try { + persistentModalStore = runtime.state.openKeyedStore< + PersistedDiscordRegistryEntry + >({ + namespace: PERSISTENT_MODAL_NAMESPACE, + maxEntries: PERSISTENT_MODAL_MAX_ENTRIES, + defaultTtlMs: DEFAULT_COMPONENT_TTL_MS, + }); + return persistentModalStore; + } catch (error) { + disablePersistentComponentRegistry(error); + return undefined; + } +} + function isExpired(entry: { expiresAt?: number }, now: number) { return typeof entry.expiresAt === "number" && entry.expiresAt <= now; } @@ -40,7 +133,8 @@ function registerEntries< entries: T[], store: Map, params: { now: number; ttlMs: number; messageId?: string }, -): void { +): T[] { + const normalizedEntries: T[] = []; for (const entry of entries) { const normalized = normalizeEntryTimestamps( { ...entry, messageId: params.messageId ?? entry.messageId }, @@ -48,7 +142,9 @@ function registerEntries< params.ttlMs, ); store.set(entry.id, normalized); + normalizedEntries.push(normalized); } + return normalizedEntries; } function resolveEntry( @@ -70,6 +166,81 @@ function resolveEntry( return entry; } +function readPersistedRegistryEntry( + persisted: PersistedDiscordRegistryEntry | undefined, +): T | null { + if (persisted?.version !== 1 || typeof persisted.entry?.id !== "string") { + return null; + } + return persisted.entry; +} + +function registerPersistentRegistryEntries(params: { + entries: T[]; + ttlMs: number; + openStore: () => DiscordRegistryStore | undefined; +}): void { + if (params.entries.length === 0) { + return; + } + const store = params.openStore(); + if (!store) { + return; + } + for (const entry of params.entries) { + void store + .register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs }) + .catch(disablePersistentComponentRegistry); + } +} + +function registerPersistentEntries(params: { + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; + ttlMs: number; +}): void { + registerPersistentRegistryEntries({ + entries: params.entries, + ttlMs: params.ttlMs, + openStore: getPersistentComponentStore, + }); + registerPersistentRegistryEntries({ + entries: params.modals, + ttlMs: params.ttlMs, + openStore: getPersistentModalStore, + }); +} + +function deletePersistentEntry(params: { + id: string; + openStore: () => DiscordRegistryStore | undefined; +}): void { + const store = params.openStore(); + if (!store) { + return; + } + void store.delete(params.id).catch(disablePersistentComponentRegistry); +} + +async function resolvePersistentRegistryEntry(params: { + id: string; + consume?: boolean; + openStore: () => DiscordRegistryStore | undefined; +}): Promise { + const store = params.openStore(); + if (!store) { + return null; + } + try { + const value = + params.consume === false ? await store.lookup(params.id) : await store.consume(params.id); + return readPersistedRegistryEntry(value); + } catch (error) { + disablePersistentComponentRegistry(error); + return null; + } +} + export function registerDiscordComponentEntries(params: { entries: DiscordComponentEntry[]; modals: DiscordModalEntry[]; @@ -78,12 +249,21 @@ export function registerDiscordComponentEntries(params: { }): void { const now = Date.now(); const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS; - registerEntries(params.entries, getComponentEntries(), { + const normalizedEntries = registerEntries(params.entries, getComponentEntries(), { now, ttlMs, messageId: params.messageId, }); - registerEntries(params.modals, getModalEntries(), { now, ttlMs, messageId: params.messageId }); + const normalizedModals = registerEntries(params.modals, getModalEntries(), { + now, + ttlMs, + messageId: params.messageId, + }); + registerPersistentEntries({ + entries: normalizedEntries, + modals: normalizedModals, + ttlMs, + }); } export function resolveDiscordComponentEntry(params: { @@ -93,6 +273,23 @@ export function resolveDiscordComponentEntry(params: { return resolveEntry(getComponentEntries(), params); } +export async function resolveDiscordComponentEntryWithPersistence(params: { + id: string; + consume?: boolean; +}): Promise { + const inMemory = resolveDiscordComponentEntry(params); + if (inMemory) { + if (params.consume !== false) { + deletePersistentEntry({ ...params, openStore: getPersistentComponentStore }); + } + return inMemory; + } + return await resolvePersistentRegistryEntry({ + ...params, + openStore: getPersistentComponentStore, + }); +} + export function resolveDiscordModalEntry(params: { id: string; consume?: boolean; @@ -100,7 +297,27 @@ export function resolveDiscordModalEntry(params: { return resolveEntry(getModalEntries(), params); } +export async function resolveDiscordModalEntryWithPersistence(params: { + id: string; + consume?: boolean; +}): Promise { + const inMemory = resolveDiscordModalEntry(params); + if (inMemory) { + if (params.consume !== false) { + deletePersistentEntry({ ...params, openStore: getPersistentModalStore }); + } + return inMemory; + } + return await resolvePersistentRegistryEntry({ + ...params, + openStore: getPersistentModalStore, + }); +} + export function clearDiscordComponentEntries(): void { getComponentEntries().clear(); getModalEntries().clear(); + persistentComponentStore = undefined; + persistentModalStore = undefined; + persistentRegistryDisabled = false; } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 5214626bbf3..5f0a5b590e4 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -1,10 +1,12 @@ import { MessageFlags } from "discord-api-types/v10"; -import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries; let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries; let resolveDiscordComponentEntry: typeof import("./components-registry.js").resolveDiscordComponentEntry; +let resolveDiscordComponentEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordComponentEntryWithPersistence; let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry; +let resolveDiscordModalEntryWithPersistence: typeof import("./components-registry.js").resolveDiscordModalEntryWithPersistence; let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage; let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags; let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec; @@ -14,7 +16,9 @@ beforeAll(async () => { clearDiscordComponentEntries, registerDiscordComponentEntries, resolveDiscordComponentEntry, + resolveDiscordComponentEntryWithPersistence, resolveDiscordModalEntry, + resolveDiscordModalEntryWithPersistence, } = await import("./components-registry.js")); ({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } = await import("./components.js")); @@ -84,6 +88,7 @@ describe("discord components", () => { describe("discord component registry", () => { beforeEach(() => { clearDiscordComponentEntries(); + vi.restoreAllMocks(); }); const componentsRegistryModuleUrl = new URL("./components-registry.ts", import.meta.url).href; @@ -136,4 +141,94 @@ describe("discord component registry", () => { second.clearDiscordComponentEntries(); }); + + it("persists component and modal entries when runtime state is available", async () => { + const componentRegister = vi.fn().mockResolvedValue(undefined); + const modalRegister = vi.fn().mockResolvedValue(undefined); + const componentLookup = vi.fn().mockResolvedValue({ + version: 1, + entry: { id: "btn_persisted", kind: "button", label: "Persisted" }, + }); + const modalLookup = vi.fn().mockResolvedValue({ + version: 1, + entry: { id: "mdl_persisted", title: "Persisted", fields: [] }, + }); + const componentStore = { + register: componentRegister, + lookup: componentLookup, + consume: vi.fn(), + delete: vi.fn(), + entries: vi.fn(), + clear: vi.fn(), + }; + const modalStore = { + register: modalRegister, + lookup: modalLookup, + 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); + + registerDiscordComponentEntries({ + entries: [{ id: "btn_1", kind: "button", label: "Confirm" }], + modals: [{ id: "mdl_1", title: "Details", fields: [] }], + ttlMs: 1000, + }); + + await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1)); + expect(componentRegister).toHaveBeenCalledWith( + "btn_1", + { version: 1, entry: expect.objectContaining({ id: "btn_1" }) }, + { ttlMs: 1000 }, + ); + expect(modalRegister).toHaveBeenCalledWith( + "mdl_1", + { version: 1, entry: expect.objectContaining({ id: "mdl_1" }) }, + { ttlMs: 1000 }, + ); + + clearDiscordComponentEntries(); + await expect( + resolveDiscordComponentEntryWithPersistence({ id: "btn_persisted", consume: false }), + ).resolves.toMatchObject({ id: "btn_persisted" }); + await expect( + resolveDiscordModalEntryWithPersistence({ id: "mdl_persisted", consume: false }), + ).resolves.toMatchObject({ id: "mdl_persisted" }); + expect(componentLookup).toHaveBeenCalledWith("btn_persisted"); + expect(modalLookup).toHaveBeenCalledWith("mdl_persisted"); + expect(openKeyedStore).toHaveBeenCalledTimes(4); + }); + + it("falls back to the in-memory registry when persistent state cannot open", async () => { + const warn = vi.fn(); + const { setDiscordRuntime } = await import("./runtime.js"); + setDiscordRuntime({ + state: { + openKeyedStore: vi.fn(() => { + throw new Error("sqlite unavailable"); + }), + }, + logging: { getChildLogger: () => ({ warn }) }, + } as never); + + registerDiscordComponentEntries({ + entries: [{ id: "btn_fallback", kind: "button", label: "Fallback" }], + modals: [], + }); + + expect(resolveDiscordComponentEntry({ id: "btn_fallback", consume: false })).toMatchObject({ + id: "btn_fallback", + label: "Fallback", + }); + expect(warn).toHaveBeenCalled(); + }); }); diff --git a/extensions/discord/src/monitor/agent-components.handlers.ts b/extensions/discord/src/monitor/agent-components.handlers.ts index c59b71043ed..a2742700c88 100644 --- a/extensions/discord/src/monitor/agent-components.handlers.ts +++ b/extensions/discord/src/monitor/agent-components.handlers.ts @@ -1,5 +1,8 @@ import { logError } from "openclaw/plugin-sdk/text-runtime"; -import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; +import { + resolveDiscordComponentEntryWithPersistence, + resolveDiscordModalEntryWithPersistence, +} from "../components-registry.js"; import type { ButtonInteraction, ComponentData } from "../internal/discord.js"; import { type AgentComponentContext, @@ -46,7 +49,10 @@ async function handleDiscordComponentEvent(params: { return; } - const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); + const entry = await resolveDiscordComponentEntryWithPersistence({ + id: parsed.componentId, + consume: false, + }); if (!entry) { try { await params.interaction.reply({ @@ -93,7 +99,7 @@ async function handleDiscordComponentEvent(params: { if (!componentAllowed) { return; } - const consumed = resolveDiscordComponentEntry({ + const consumed = await resolveDiscordComponentEntryWithPersistence({ id: parsed.componentId, consume: !entry.reusable, }); @@ -193,7 +199,10 @@ async function handleDiscordModalTrigger(params: { } return; } - const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false }); + const entry = await resolveDiscordComponentEntryWithPersistence({ + id: parsed.componentId, + consume: false, + }); if (!entry || entry.kind !== "modal-trigger") { try { await params.interaction.reply({ @@ -246,7 +255,7 @@ async function handleDiscordModalTrigger(params: { return; } - const consumed = resolveDiscordComponentEntry({ + const consumed = await resolveDiscordComponentEntryWithPersistence({ id: parsed.componentId, consume: !entry.reusable, }); @@ -263,7 +272,10 @@ async function handleDiscordModalTrigger(params: { } const resolvedModalId = consumed.modalId ?? modalId; - const modalEntry = resolveDiscordModalEntry({ id: resolvedModalId, consume: false }); + const modalEntry = await resolveDiscordModalEntryWithPersistence({ + id: resolvedModalId, + consume: false, + }); if (!modalEntry) { try { await params.interaction.reply({ diff --git a/extensions/discord/src/monitor/agent-components.modal.ts b/extensions/discord/src/monitor/agent-components.modal.ts index 901da881c74..4bbface8d98 100644 --- a/extensions/discord/src/monitor/agent-components.modal.ts +++ b/extensions/discord/src/monitor/agent-components.modal.ts @@ -1,6 +1,6 @@ import { logError } from "openclaw/plugin-sdk/text-runtime"; import { parseDiscordModalCustomIdForInteraction } from "../component-custom-id.js"; -import { resolveDiscordModalEntry } from "../components-registry.js"; +import { resolveDiscordModalEntryWithPersistence } from "../components-registry.js"; import { Modal, type ComponentData, type ModalInteraction } from "../internal/discord.js"; import { type AgentComponentContext, @@ -41,7 +41,10 @@ export class DiscordComponentModal extends Modal { return; } - const modalEntry = resolveDiscordModalEntry({ id: modalId, consume: false }); + const modalEntry = await resolveDiscordModalEntryWithPersistence({ + id: modalId, + consume: false, + }); if (!modalEntry) { try { await interaction.reply({ @@ -94,7 +97,7 @@ export class DiscordComponentModal extends Modal { return; } - const consumed = resolveDiscordModalEntry({ + const consumed = await resolveDiscordModalEntryWithPersistence({ id: modalId, consume: !modalEntry.reusable, });