discord: persist component registries best-effort (#75584)

This commit is contained in:
Alex Knight
2026-05-01 22:17:51 +10:00
committed by GitHub
parent bf8bdcb064
commit 6c55106c80
5 changed files with 341 additions and 13 deletions

View File

@@ -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.

View File

@@ -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<T extends { id: string }> = {
version: 1;
entry: T;
};
type DiscordPersistentStore<T> = {
register(key: string, value: T, opts?: { ttlMs?: number }): Promise<void>;
lookup(key: string): Promise<T | undefined>;
consume(key: string): Promise<T | undefined>;
delete(key: string): Promise<boolean>;
};
type DiscordRegistryStore<T extends { id: string }> = DiscordPersistentStore<
PersistedDiscordRegistryEntry<T>
>;
let componentEntries: Map<string, DiscordComponentEntry> | undefined;
let modalEntries: Map<string, DiscordModalEntry> | undefined;
let persistentComponentStore: DiscordRegistryStore<DiscordComponentEntry> | undefined;
let persistentModalStore: DiscordRegistryStore<DiscordModalEntry> | undefined;
let persistentRegistryDisabled = false;
function getComponentEntries(): Map<string, DiscordComponentEntry> {
componentEntries ??= resolveGlobalMap<string, DiscordComponentEntry>(
@@ -20,6 +44,75 @@ function getModalEntries(): Map<string, DiscordModalEntry> {
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<DiscordComponentEntry> | undefined {
if (persistentRegistryDisabled) {
return undefined;
}
if (persistentComponentStore) {
return persistentComponentStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
try {
persistentComponentStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordComponentEntry>
>({
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<DiscordModalEntry> | undefined {
if (persistentRegistryDisabled) {
return undefined;
}
if (persistentModalStore) {
return persistentModalStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
try {
persistentModalStore = runtime.state.openKeyedStore<
PersistedDiscordRegistryEntry<DiscordModalEntry>
>({
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<string, T>,
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<T extends { expiresAt?: number }>(
@@ -70,6 +166,81 @@ function resolveEntry<T extends { expiresAt?: number }>(
return entry;
}
function readPersistedRegistryEntry<T extends { id: string }>(
persisted: PersistedDiscordRegistryEntry<T> | undefined,
): T | null {
if (persisted?.version !== 1 || typeof persisted.entry?.id !== "string") {
return null;
}
return persisted.entry;
}
function registerPersistentRegistryEntries<T extends { id: string }>(params: {
entries: T[];
ttlMs: number;
openStore: () => DiscordRegistryStore<T> | 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<T extends { id: string }>(params: {
id: string;
openStore: () => DiscordRegistryStore<T> | undefined;
}): void {
const store = params.openStore();
if (!store) {
return;
}
void store.delete(params.id).catch(disablePersistentComponentRegistry);
}
async function resolvePersistentRegistryEntry<T extends { id: string }>(params: {
id: string;
consume?: boolean;
openStore: () => DiscordRegistryStore<T> | undefined;
}): Promise<T | null> {
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<DiscordComponentEntry | null> {
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<DiscordModalEntry | null> {
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;
}

View File

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

View File

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

View File

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