Skip to content

Commit 2246447

Browse files
committed
discord: persist component registries best-effort
1 parent d3bb5ce commit 2246447

6 files changed

Lines changed: 343 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
- 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.
1212
- 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.
1313
- 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.
14+
- Discord/plugins: persist component and modal registry entries with best-effort SDK state behind the in-memory registry, so valid buttons, selects, and forms can survive restarts while SQLite failures fall back to process-local behavior. Thanks @amknight.
1415
- 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.
1516
- 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.
1617
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.

docs/channels/discord.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ Supported blocks:
348348

349349
By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire.
350350

351+
Discord component and modal registries use best-effort SDK-backed persistent state behind the in-memory registry, so valid buttons, selects, and forms can survive a Gateway restart until their normal TTL expires. If the persistent store is unavailable or fails, Discord logs the failure and keeps the previous process-local registry behavior.
352+
351353
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
352354

353355
The `/model` and `/models` slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. `/models add` is deprecated and now returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only the invoking user can use it.

extensions/discord/src/components-registry.ts

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,36 @@
11
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
22
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
3+
import { getOptionalDiscordRuntime } from "./runtime.js";
34

45
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
6+
const PERSISTENT_COMPONENT_NAMESPACE = "discord.components";
7+
const PERSISTENT_MODAL_NAMESPACE = "discord.modals";
8+
const PERSISTENT_COMPONENT_MAX_ENTRIES = 500;
9+
const PERSISTENT_MODAL_MAX_ENTRIES = 500;
510
const DISCORD_COMPONENT_ENTRIES_KEY = Symbol.for("openclaw.discord.componentEntries");
611
const DISCORD_MODAL_ENTRIES_KEY = Symbol.for("openclaw.discord.modalEntries");
712

13+
type PersistedDiscordRegistryEntry<T extends { id: string }> = {
14+
version: 1;
15+
entry: T;
16+
};
17+
18+
type DiscordPersistentStore<T> = {
19+
register(key: string, value: T, opts?: { ttlMs?: number }): Promise<void>;
20+
lookup(key: string): Promise<T | undefined>;
21+
consume(key: string): Promise<T | undefined>;
22+
delete(key: string): Promise<boolean>;
23+
};
24+
25+
type DiscordRegistryStore<T extends { id: string }> = DiscordPersistentStore<
26+
PersistedDiscordRegistryEntry<T>
27+
>;
28+
829
let componentEntries: Map<string, DiscordComponentEntry> | undefined;
930
let modalEntries: Map<string, DiscordModalEntry> | undefined;
31+
let persistentComponentStore: DiscordRegistryStore<DiscordComponentEntry> | undefined;
32+
let persistentModalStore: DiscordRegistryStore<DiscordModalEntry> | undefined;
33+
let persistentRegistryDisabled = false;
1034

1135
function getComponentEntries(): Map<string, DiscordComponentEntry> {
1236
componentEntries ??= resolveGlobalMap<string, DiscordComponentEntry>(
@@ -20,6 +44,75 @@ function getModalEntries(): Map<string, DiscordModalEntry> {
2044
return modalEntries;
2145
}
2246

47+
function reportPersistentComponentRegistryError(error: unknown): void {
48+
try {
49+
getOptionalDiscordRuntime()
50+
?.logging.getChildLogger({ plugin: "discord", feature: "component-registry-state" })
51+
.warn("Discord persistent component registry state failed", { error: String(error) });
52+
} catch {
53+
// Best effort only: persistent state must never break Discord interactions.
54+
}
55+
}
56+
57+
function disablePersistentComponentRegistry(error: unknown): void {
58+
persistentRegistryDisabled = true;
59+
persistentComponentStore = undefined;
60+
persistentModalStore = undefined;
61+
reportPersistentComponentRegistryError(error);
62+
}
63+
64+
function getPersistentComponentStore(): DiscordRegistryStore<DiscordComponentEntry> | undefined {
65+
if (persistentRegistryDisabled) {
66+
return undefined;
67+
}
68+
if (persistentComponentStore) {
69+
return persistentComponentStore;
70+
}
71+
const runtime = getOptionalDiscordRuntime();
72+
if (!runtime) {
73+
return undefined;
74+
}
75+
try {
76+
persistentComponentStore = runtime.state.openKeyedStore<
77+
PersistedDiscordRegistryEntry<DiscordComponentEntry>
78+
>({
79+
namespace: PERSISTENT_COMPONENT_NAMESPACE,
80+
maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES,
81+
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
82+
});
83+
return persistentComponentStore;
84+
} catch (error) {
85+
disablePersistentComponentRegistry(error);
86+
return undefined;
87+
}
88+
}
89+
90+
function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | undefined {
91+
if (persistentRegistryDisabled) {
92+
return undefined;
93+
}
94+
if (persistentModalStore) {
95+
return persistentModalStore;
96+
}
97+
const runtime = getOptionalDiscordRuntime();
98+
if (!runtime) {
99+
return undefined;
100+
}
101+
try {
102+
persistentModalStore = runtime.state.openKeyedStore<
103+
PersistedDiscordRegistryEntry<DiscordModalEntry>
104+
>({
105+
namespace: PERSISTENT_MODAL_NAMESPACE,
106+
maxEntries: PERSISTENT_MODAL_MAX_ENTRIES,
107+
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
108+
});
109+
return persistentModalStore;
110+
} catch (error) {
111+
disablePersistentComponentRegistry(error);
112+
return undefined;
113+
}
114+
}
115+
23116
function isExpired(entry: { expiresAt?: number }, now: number) {
24117
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
25118
}
@@ -40,15 +133,18 @@ function registerEntries<
40133
entries: T[],
41134
store: Map<string, T>,
42135
params: { now: number; ttlMs: number; messageId?: string },
43-
): void {
136+
): T[] {
137+
const normalizedEntries: T[] = [];
44138
for (const entry of entries) {
45139
const normalized = normalizeEntryTimestamps(
46140
{ ...entry, messageId: params.messageId ?? entry.messageId },
47141
params.now,
48142
params.ttlMs,
49143
);
50144
store.set(entry.id, normalized);
145+
normalizedEntries.push(normalized);
51146
}
147+
return normalizedEntries;
52148
}
53149

54150
function resolveEntry<T extends { expiresAt?: number }>(
@@ -70,6 +166,81 @@ function resolveEntry<T extends { expiresAt?: number }>(
70166
return entry;
71167
}
72168

169+
function readPersistedRegistryEntry<T extends { id: string }>(
170+
persisted: PersistedDiscordRegistryEntry<T> | undefined,
171+
): T | null {
172+
if (persisted?.version !== 1 || typeof persisted.entry?.id !== "string") {
173+
return null;
174+
}
175+
return persisted.entry;
176+
}
177+
178+
function registerPersistentRegistryEntries<T extends { id: string }>(params: {
179+
entries: T[];
180+
ttlMs: number;
181+
openStore: () => DiscordRegistryStore<T> | undefined;
182+
}): void {
183+
if (params.entries.length === 0) {
184+
return;
185+
}
186+
const store = params.openStore();
187+
if (!store) {
188+
return;
189+
}
190+
for (const entry of params.entries) {
191+
void store
192+
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
193+
.catch(disablePersistentComponentRegistry);
194+
}
195+
}
196+
197+
function registerPersistentEntries(params: {
198+
entries: DiscordComponentEntry[];
199+
modals: DiscordModalEntry[];
200+
ttlMs: number;
201+
}): void {
202+
registerPersistentRegistryEntries({
203+
entries: params.entries,
204+
ttlMs: params.ttlMs,
205+
openStore: getPersistentComponentStore,
206+
});
207+
registerPersistentRegistryEntries({
208+
entries: params.modals,
209+
ttlMs: params.ttlMs,
210+
openStore: getPersistentModalStore,
211+
});
212+
}
213+
214+
function deletePersistentEntry<T extends { id: string }>(params: {
215+
id: string;
216+
openStore: () => DiscordRegistryStore<T> | undefined;
217+
}): void {
218+
const store = params.openStore();
219+
if (!store) {
220+
return;
221+
}
222+
void store.delete(params.id).catch(disablePersistentComponentRegistry);
223+
}
224+
225+
async function resolvePersistentRegistryEntry<T extends { id: string }>(params: {
226+
id: string;
227+
consume?: boolean;
228+
openStore: () => DiscordRegistryStore<T> | undefined;
229+
}): Promise<T | null> {
230+
const store = params.openStore();
231+
if (!store) {
232+
return null;
233+
}
234+
try {
235+
const value =
236+
params.consume === false ? await store.lookup(params.id) : await store.consume(params.id);
237+
return readPersistedRegistryEntry(value);
238+
} catch (error) {
239+
disablePersistentComponentRegistry(error);
240+
return null;
241+
}
242+
}
243+
73244
export function registerDiscordComponentEntries(params: {
74245
entries: DiscordComponentEntry[];
75246
modals: DiscordModalEntry[];
@@ -78,12 +249,21 @@ export function registerDiscordComponentEntries(params: {
78249
}): void {
79250
const now = Date.now();
80251
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
81-
registerEntries(params.entries, getComponentEntries(), {
252+
const normalizedEntries = registerEntries(params.entries, getComponentEntries(), {
253+
now,
254+
ttlMs,
255+
messageId: params.messageId,
256+
});
257+
const normalizedModals = registerEntries(params.modals, getModalEntries(), {
82258
now,
83259
ttlMs,
84260
messageId: params.messageId,
85261
});
86-
registerEntries(params.modals, getModalEntries(), { now, ttlMs, messageId: params.messageId });
262+
registerPersistentEntries({
263+
entries: normalizedEntries,
264+
modals: normalizedModals,
265+
ttlMs,
266+
});
87267
}
88268

89269
export function resolveDiscordComponentEntry(params: {
@@ -93,14 +273,51 @@ export function resolveDiscordComponentEntry(params: {
93273
return resolveEntry(getComponentEntries(), params);
94274
}
95275

276+
export async function resolveDiscordComponentEntryWithPersistence(params: {
277+
id: string;
278+
consume?: boolean;
279+
}): Promise<DiscordComponentEntry | null> {
280+
const inMemory = resolveDiscordComponentEntry(params);
281+
if (inMemory) {
282+
if (params.consume !== false) {
283+
deletePersistentEntry({ ...params, openStore: getPersistentComponentStore });
284+
}
285+
return inMemory;
286+
}
287+
return await resolvePersistentRegistryEntry({
288+
...params,
289+
openStore: getPersistentComponentStore,
290+
});
291+
}
292+
96293
export function resolveDiscordModalEntry(params: {
97294
id: string;
98295
consume?: boolean;
99296
}): DiscordModalEntry | null {
100297
return resolveEntry(getModalEntries(), params);
101298
}
102299

300+
export async function resolveDiscordModalEntryWithPersistence(params: {
301+
id: string;
302+
consume?: boolean;
303+
}): Promise<DiscordModalEntry | null> {
304+
const inMemory = resolveDiscordModalEntry(params);
305+
if (inMemory) {
306+
if (params.consume !== false) {
307+
deletePersistentEntry({ ...params, openStore: getPersistentModalStore });
308+
}
309+
return inMemory;
310+
}
311+
return await resolvePersistentRegistryEntry({
312+
...params,
313+
openStore: getPersistentModalStore,
314+
});
315+
}
316+
103317
export function clearDiscordComponentEntries(): void {
104318
getComponentEntries().clear();
105319
getModalEntries().clear();
320+
persistentComponentStore = undefined;
321+
persistentModalStore = undefined;
322+
persistentRegistryDisabled = false;
106323
}

0 commit comments

Comments
 (0)