Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Knight
f0b8ac61e9 channels: tighten persistent state cache behavior
Address draft PR review feedback for the opt-in SDK-backed channel state
migration:

- backfill Slack and MS Teams in-memory caches after persistent lookup hits
  so hot inbound paths only pay the SQLite lookup once per key after restart
- reset cached persistent store handles from existing cache clear helpers so
  tests and runtime reset paths reopen stores from the current runtime
- use a narrow Matrix plugins config lookup type instead of a double cast
- document why Slack persists agentId even though current reads only check
  participation presence

Validation: pnpm test extensions/slack/src/sent-thread-cache.test.ts extensions/discord/src/components.test.ts extensions/msteams/src/sent-message-cache.test.ts extensions/matrix/src/approval-reactions.test.ts; pnpm check:changed.
2026-04-30 19:00:39 +10:00
Alex Knight
55ecf09a70 channels: opt-in SDK-backed persistent state for slack, discord, msteams, matrix
Adds plugins.entries.<id>.config.experimentalPersistentState (default
false) to slack/discord/msteams/matrix, mirroring four in-memory caches
into the SDK-backed plugin state store while keeping the existing
process-local fast paths as the default behavior:

- Slack thread participation (sent-thread-cache)
- Discord component & modal registries (components-registry)
- MS Teams sent-message dedupe (sent-message-cache)
- Matrix approval reaction targets (approval-reactions)

Errors from the persistent path are logged via the plugin runtime and
never break the synchronous record/lookup paths. Each plugin gets a
*ForConfig async variant used by handlers that already run in async
contexts; opt-in is gated by resolvePluginConfigObject(cfg, ...).

Tests: extends each plugin's cache test with no-op-when-disabled and
persistent register/lookup with a fake runtime store. Updates relevant
docs (channels/{slack,discord,msteams,matrix}.md, plugins/sdk-runtime.md)
and the changelog.
2026-04-30 17:21:18 +10:00
30 changed files with 1069 additions and 66 deletions

View File

@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
- Providers/NVIDIA: add the NVIDIA provider with API-key onboarding, setup docs, static catalog metadata, and literal model-ref picker support so NVIDIA hosted models can be selected with their provider prefix intact. (#71204) Thanks @eleqtrizit.
- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae.
- Added SQLite-backed plugin state store (`api.runtime.state.openKeyedStore`) for restart-safe keyed registries with TTL, eviction, and automatic plugin isolation. Thanks @amknight.
- Channels/plugins: add opt-in SDK-backed persistent state for Slack thread participation, Discord component/modal registries, Microsoft Teams sent-message markers, and Matrix approval reaction targets while keeping process-local caches as the default and warming hot in-memory paths after restart lookups. Thanks @amknight.
- Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc.
- CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc.
- Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner.

View File

@@ -348,6 +348,8 @@ Supported blocks:
By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire.
Set `plugins.entries.discord.config.experimentalPersistentState: true` to opt in to the experimental SDK-backed persistent component/modal registry, so valid buttons, selects, and forms can survive a Gateway restart until their normal TTL expires. The default remains the previous process-local registry.
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.
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.

View File

@@ -699,6 +699,8 @@ Both kinds share Matrix reaction shortcuts and message updates. Approvers see re
Fallback slash commands: `/approve <id> allow-once`, `/approve <id> allow-always`, `/approve <id> deny`.
Set `plugins.entries.matrix.config.experimentalPersistentState: true` to opt in to the experimental SDK-backed persistent reaction-target cache. With the opt-in, reactions on still-pending approval prompts can resolve after a Gateway restart; by default OpenClaw keeps the previous process-local cache.
Only resolved approvers can approve or deny. Channel delivery for exec approvals includes the command text — only enable `channel` or `both` in trusted rooms.
Related: [Exec approvals](/tools/exec-approvals).

View File

@@ -710,6 +710,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.managedIdentityClientId`: client ID for user-assigned managed identity.
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
Set `plugins.entries.msteams.config.experimentalPersistentState: true` to opt in to the experimental SDK-backed persistent sent-message cache. With the opt-in, replies to recent bot-sent Teams messages can still be classified as `reply_to_bot` after a Gateway restart; by default OpenClaw keeps the previous process-local cache.
## Routing & Sessions
- Session keys follow the standard agent format (see [/concepts/session](/concepts/session)):

View File

@@ -594,6 +594,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
- Experimental restart-safe thread participation: set `plugins.entries.slack.config.experimentalPersistentState: true` to opt in to the SDK-backed persistent cache for bot-participated thread markers. The default remains the previous process-local cache.
Reply threading controls:

View File

@@ -414,6 +414,8 @@ Provider and channel execution paths must use the active runtime config snapshot
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Limits: `maxEntries` per namespace, 1,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry.
Early bundled-plugin migrations are deliberately opt-in. Set `plugins.entries.<pluginId>.config.experimentalPersistentState: true` to let Slack thread participation, Discord component/modal registries, Microsoft Teams sent-message markers, or Matrix approval reaction targets use this SDK-backed store; otherwise those plugins keep their previous process-local caches.
<Warning>
Bundled plugins only in this release.
</Warning>

View File

@@ -10,6 +10,11 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"experimentalPersistentState": {
"type": "boolean",
"description": "Opt in to the experimental SDK-backed persistent state store for Discord runtime caches."
}
}
}
}

View File

@@ -1,12 +1,42 @@
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
import type { OpenClawConfig } from "./runtime-api.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 PersistedDiscordComponentEntry = {
version: 1;
entry: DiscordComponentEntry;
};
type PersistedDiscordModalEntry = {
version: 1;
entry: DiscordModalEntry;
};
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 DiscordComponentStore = DiscordPersistentStore<PersistedDiscordComponentEntry>;
type DiscordModalStore = DiscordPersistentStore<PersistedDiscordModalEntry>;
let componentEntries: Map<string, DiscordComponentEntry> | undefined;
let modalEntries: Map<string, DiscordModalEntry> | undefined;
let persistentComponentStore: DiscordComponentStore | undefined;
let persistentModalStore: DiscordModalStore | undefined;
function getComponentEntries(): Map<string, DiscordComponentEntry> {
componentEntries ??= resolveGlobalMap<string, DiscordComponentEntry>(
@@ -20,6 +50,52 @@ function getModalEntries(): Map<string, DiscordModalEntry> {
return modalEntries;
}
function isPersistentComponentRegistryEnabled(cfg: OpenClawConfig | undefined): boolean {
return resolvePluginConfigObject(cfg, "discord")?.experimentalPersistentState === true;
}
function getPersistentComponentStore(): DiscordComponentStore | undefined {
if (persistentComponentStore) {
return persistentComponentStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
persistentComponentStore = runtime.state.openKeyedStore<PersistedDiscordComponentEntry>({
namespace: PERSISTENT_COMPONENT_NAMESPACE,
maxEntries: PERSISTENT_COMPONENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
return persistentComponentStore;
}
function getPersistentModalStore(): DiscordModalStore | undefined {
if (persistentModalStore) {
return persistentModalStore;
}
const runtime = getOptionalDiscordRuntime();
if (!runtime) {
return undefined;
}
persistentModalStore = runtime.state.openKeyedStore<PersistedDiscordModalEntry>({
namespace: PERSISTENT_MODAL_NAMESPACE,
maxEntries: PERSISTENT_MODAL_MAX_ENTRIES,
defaultTtlMs: DEFAULT_COMPONENT_TTL_MS,
});
return persistentModalStore;
}
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 isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
}
@@ -40,7 +116,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 +125,9 @@ function registerEntries<
params.ttlMs,
);
store.set(entry.id, normalized);
normalizedEntries.push(normalized);
}
return normalizedEntries;
}
function resolveEntry<T extends { expiresAt?: number }>(
@@ -70,20 +149,132 @@ function resolveEntry<T extends { expiresAt?: number }>(
return entry;
}
function readPersistedComponentEntry(value: unknown): DiscordComponentEntry | null {
const persisted = value as PersistedDiscordComponentEntry | undefined;
if (persisted?.version !== 1 || !persisted.entry || typeof persisted.entry.id !== "string") {
return null;
}
return persisted.entry;
}
function readPersistedModalEntry(value: unknown): DiscordModalEntry | null {
const persisted = value as PersistedDiscordModalEntry | undefined;
if (persisted?.version !== 1 || !persisted.entry || typeof persisted.entry.id !== "string") {
return null;
}
return persisted.entry;
}
function registerPersistentEntries(params: {
cfg?: OpenClawConfig;
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs: number;
}): void {
if (!isPersistentComponentRegistryEnabled(params.cfg)) {
return;
}
let componentStore: DiscordComponentStore | undefined;
let modalStore: DiscordModalStore | undefined;
try {
componentStore = getPersistentComponentStore();
modalStore = getPersistentModalStore();
} catch (error) {
reportPersistentComponentRegistryError(error);
return;
}
if (componentStore) {
for (const entry of params.entries) {
void componentStore
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
.catch(reportPersistentComponentRegistryError);
}
}
if (modalStore) {
for (const entry of params.modals) {
void modalStore
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
.catch(reportPersistentComponentRegistryError);
}
}
}
function deletePersistentEntry(params: {
cfg?: OpenClawConfig;
id: string;
openStore: () => DiscordComponentStore | DiscordModalStore | undefined;
}): void {
if (!isPersistentComponentRegistryEnabled(params.cfg)) {
return;
}
let store: DiscordComponentStore | DiscordModalStore | undefined;
try {
store = params.openStore();
} catch (error) {
reportPersistentComponentRegistryError(error);
return;
}
if (!store) {
return;
}
void store.delete(params.id).catch(reportPersistentComponentRegistryError);
}
async function resolvePersistentEntry<T>(params: {
cfg?: OpenClawConfig;
id: string;
consume?: boolean;
openStore: () => DiscordComponentStore | DiscordModalStore | undefined;
read: (value: unknown) => T | null;
}): Promise<T | null> {
if (!isPersistentComponentRegistryEnabled(params.cfg)) {
return null;
}
let store: DiscordComponentStore | DiscordModalStore | undefined;
try {
store = params.openStore();
} catch (error) {
reportPersistentComponentRegistryError(error);
return null;
}
if (!store) {
return null;
}
try {
const value =
params.consume === false ? await store.lookup(params.id) : await store.consume(params.id);
return params.read(value);
} catch (error) {
reportPersistentComponentRegistryError(error);
return null;
}
}
export function registerDiscordComponentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs?: number;
messageId?: string;
cfg?: OpenClawConfig;
}): 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({
cfg: params.cfg,
entries: normalizedEntries,
modals: normalizedModals,
ttlMs,
});
}
export function resolveDiscordComponentEntry(params: {
@@ -93,6 +284,25 @@ export function resolveDiscordComponentEntry(params: {
return resolveEntry(getComponentEntries(), params);
}
export async function resolveDiscordComponentEntryForConfig(params: {
cfg?: OpenClawConfig;
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 resolvePersistentEntry({
...params,
openStore: getPersistentComponentStore,
read: readPersistedComponentEntry,
});
}
export function resolveDiscordModalEntry(params: {
id: string;
consume?: boolean;
@@ -100,7 +310,28 @@ export function resolveDiscordModalEntry(params: {
return resolveEntry(getModalEntries(), params);
}
export async function resolveDiscordModalEntryForConfig(params: {
cfg?: OpenClawConfig;
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 resolvePersistentEntry({
...params,
openStore: getPersistentModalStore,
read: readPersistedModalEntry,
});
}
export function clearDiscordComponentEntries(): void {
getComponentEntries().clear();
getModalEntries().clear();
persistentComponentStore = undefined;
persistentModalStore = undefined;
}

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 resolveDiscordComponentEntryForConfig: typeof import("./components-registry.js").resolveDiscordComponentEntryForConfig;
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
let resolveDiscordModalEntryForConfig: typeof import("./components-registry.js").resolveDiscordModalEntryForConfig;
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,
resolveDiscordComponentEntryForConfig,
resolveDiscordModalEntry,
resolveDiscordModalEntryForConfig,
} = 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,80 @@ describe("discord component registry", () => {
second.clearDiscordComponentEntries();
});
it("persists component and modal entries only when opted in", 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_ignored", kind: "button", label: "Ignored" }],
modals: [],
});
expect(openKeyedStore).not.toHaveBeenCalled();
const cfg = {
plugins: { entries: { discord: { config: { experimentalPersistentState: true } } } },
};
registerDiscordComponentEntries({
cfg,
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(
resolveDiscordComponentEntryForConfig({ cfg, id: "btn_persisted", consume: false }),
).resolves.toMatchObject({ id: "btn_persisted" });
await expect(
resolveDiscordModalEntryForConfig({ cfg, id: "mdl_persisted", consume: false }),
).resolves.toMatchObject({ id: "mdl_persisted" });
expect(componentLookup).toHaveBeenCalledWith("btn_persisted");
expect(modalLookup).toHaveBeenCalledWith("mdl_persisted");
expect(openKeyedStore).toHaveBeenCalledTimes(4);
});
});

View File

@@ -1,5 +1,8 @@
import { logError } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
import {
resolveDiscordComponentEntryForConfig,
resolveDiscordModalEntryForConfig,
} from "../components-registry.js";
import type { ButtonInteraction, ComponentData } from "../internal/discord.js";
import {
type AgentComponentContext,
@@ -46,7 +49,11 @@ async function handleDiscordComponentEvent(params: {
return;
}
const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false });
const entry = await resolveDiscordComponentEntryForConfig({
cfg: params.ctx.cfg,
id: parsed.componentId,
consume: false,
});
if (!entry) {
try {
await params.interaction.reply({
@@ -93,7 +100,8 @@ async function handleDiscordComponentEvent(params: {
if (!componentAllowed) {
return;
}
const consumed = resolveDiscordComponentEntry({
const consumed = await resolveDiscordComponentEntryForConfig({
cfg: params.ctx.cfg,
id: parsed.componentId,
consume: !entry.reusable,
});
@@ -193,7 +201,11 @@ async function handleDiscordModalTrigger(params: {
}
return;
}
const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false });
const entry = await resolveDiscordComponentEntryForConfig({
cfg: params.ctx.cfg,
id: parsed.componentId,
consume: false,
});
if (!entry || entry.kind !== "modal-trigger") {
try {
await params.interaction.reply({
@@ -246,7 +258,8 @@ async function handleDiscordModalTrigger(params: {
return;
}
const consumed = resolveDiscordComponentEntry({
const consumed = await resolveDiscordComponentEntryForConfig({
cfg: params.ctx.cfg,
id: parsed.componentId,
consume: !entry.reusable,
});
@@ -263,7 +276,11 @@ async function handleDiscordModalTrigger(params: {
}
const resolvedModalId = consumed.modalId ?? modalId;
const modalEntry = resolveDiscordModalEntry({ id: resolvedModalId, consume: false });
const modalEntry = await resolveDiscordModalEntryForConfig({
cfg: params.ctx.cfg,
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 { resolveDiscordModalEntryForConfig } from "../components-registry.js";
import { Modal, type ComponentData, type ModalInteraction } from "../internal/discord.js";
import {
type AgentComponentContext,
@@ -41,7 +41,11 @@ export class DiscordComponentModal extends Modal {
return;
}
const modalEntry = resolveDiscordModalEntry({ id: modalId, consume: false });
const modalEntry = await resolveDiscordModalEntryForConfig({
cfg: this.ctx.cfg,
id: modalId,
consume: false,
});
if (!modalEntry) {
try {
await interaction.reply({
@@ -94,7 +98,8 @@ export class DiscordComponentModal extends Modal {
return;
}
const consumed = resolveDiscordModalEntry({
const consumed = await resolveDiscordModalEntryForConfig({
cfg: this.ctx.cfg,
id: modalId,
consume: !modalEntry.reusable,
});

View File

@@ -170,11 +170,13 @@ type DiscordComponentSendOpts = {
export function registerBuiltDiscordComponentMessage(params: {
buildResult: DiscordComponentBuildResult;
messageId: string;
cfg?: OpenClawConfig;
}): void {
registerDiscordComponentEntries({
entries: params.buildResult.entries,
modals: params.buildResult.modals,
messageId: params.messageId,
cfg: params.cfg,
});
}
@@ -315,6 +317,7 @@ export async function sendDiscordComponentMessage(
registerBuiltDiscordComponentMessage({
buildResult,
messageId: result.id,
cfg,
});
recordChannelActivity({
@@ -368,6 +371,7 @@ export async function editDiscordComponentMessage(
registerBuiltDiscordComponentMessage({
buildResult,
messageId: result.id ?? messageId,
cfg,
});
recordChannelActivity({

View File

@@ -21,6 +21,11 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"experimentalPersistentState": {
"type": "boolean",
"description": "Opt in to the experimental SDK-backed persistent state store for Matrix runtime caches."
}
}
}
}

View File

@@ -511,28 +511,30 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
},
},
interactions: {
bindPending: ({ entry, pendingPayload }) => {
bindPending: (params) => {
const target = normalizeReactionTargetRef({
roomId: entry.roomId,
eventId: entry.reactionEventId,
roomId: params.entry.roomId,
eventId: params.entry.reactionEventId,
});
if (!target) {
return null;
}
registerMatrixApprovalReactionTarget({
cfg: params.cfg as CoreConfig,
roomId: target.roomId,
eventId: target.eventId,
approvalId: pendingPayload.approvalId,
allowedDecisions: pendingPayload.allowedDecisions,
approvalId: params.pendingPayload.approvalId,
allowedDecisions: params.pendingPayload.allowedDecisions,
ttlMs: params.view.expiresAtMs - Date.now(),
});
return target;
},
unbindPending: ({ binding }) => {
const target = normalizeReactionTargetRef(binding);
unbindPending: (params) => {
const target = normalizeReactionTargetRef(params.binding);
if (!target) {
return;
}
unregisterMatrixApprovalReactionTarget(target);
unregisterMatrixApprovalReactionTarget({ ...target, cfg: params.cfg as CoreConfig });
},
},
});

View File

@@ -1,15 +1,18 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMatrixApprovalReactionHint,
clearMatrixApprovalReactionTargetsForTest,
listMatrixApprovalReactionBindings,
registerMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTargetForConfig,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { setMatrixRuntime } from "./runtime.js";
afterEach(() => {
clearMatrixApprovalReactionTargetsForTest();
vi.restoreAllMocks();
});
describe("matrix approval reactions", () => {
@@ -104,4 +107,66 @@ describe("matrix approval reactions", () => {
}),
).toBeNull();
});
it("persists approval reaction targets only when opted in", async () => {
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({
version: 1,
target: { approvalId: "req-persisted", allowedDecisions: ["deny"] },
});
const openKeyedStore = vi.fn(() => ({
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
}));
setMatrixRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg",
approvalId: "req-ignored",
allowedDecisions: ["deny"],
});
expect(openKeyedStore).not.toHaveBeenCalled();
const cfg = {
plugins: { entries: { matrix: { config: { experimentalPersistentState: true } } } },
};
registerMatrixApprovalReactionTarget({
cfg,
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
approvalId: "req-123",
allowedDecisions: ["allow-once", "deny"],
ttlMs: 1000,
});
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith(
"!ops:example.org:$approval-msg-2",
{
version: 1,
target: { approvalId: "req-123", allowedDecisions: ["allow-once", "deny"] },
},
{ ttlMs: 1000 },
);
clearMatrixApprovalReactionTargetsForTest();
await expect(
resolveMatrixApprovalReactionTargetForConfig({
cfg,
roomId: "!ops:example.org",
eventId: "$approval-msg-2",
reactionKey: "❌",
}),
).resolves.toEqual({ approvalId: "req-persisted", decision: "deny" });
expect(openKeyedStore).toHaveBeenCalledTimes(2);
expect(lookup).toHaveBeenCalledWith("!ops:example.org:$approval-msg-2");
});
});

View File

@@ -1,4 +1,8 @@
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawConfig } from "./runtime-api.js";
import { getOptionalMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
const MATRIX_APPROVAL_REACTION_META = {
"allow-once": {
@@ -21,6 +25,10 @@ const MATRIX_APPROVAL_REACTION_ORDER = [
"deny",
] as const satisfies readonly ExecApprovalReplyDecision[];
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
const PERSISTENT_MAX_ENTRIES = 1000;
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
export type MatrixApprovalReactionBinding = {
decision: ExecApprovalReplyDecision;
emoji: string;
@@ -37,7 +45,25 @@ type MatrixApprovalReactionTarget = {
allowedDecisions: readonly ExecApprovalReplyDecision[];
};
type PersistedMatrixApprovalReactionTarget = {
version: 1;
target: MatrixApprovalReactionTarget;
};
type MatrixApprovalReactionStore = {
register(
key: string,
value: PersistedMatrixApprovalReactionTarget,
opts?: { ttlMs?: number },
): Promise<void>;
lookup(key: string): Promise<PersistedMatrixApprovalReactionTarget | undefined>;
delete(key: string): Promise<boolean>;
};
type MatrixPluginConfigLookup = Pick<OpenClawConfig, "plugins">;
const matrixApprovalReactionTargets = new Map<string, MatrixApprovalReactionTarget>();
let persistentStore: MatrixApprovalReactionStore | undefined;
function buildReactionTargetKey(roomId: string, eventId: string): string | null {
const normalizedRoomId = roomId.trim();
@@ -48,6 +74,118 @@ function buildReactionTargetKey(roomId: string, eventId: string): string | null
return `${normalizedRoomId}:${normalizedEventId}`;
}
function isPersistentApprovalReactionStateEnabled(
cfg: MatrixPluginConfigLookup | undefined,
): boolean {
return resolvePluginConfigObject(cfg, "matrix")?.experimentalPersistentState === true;
}
function getPersistentApprovalReactionStore(): MatrixApprovalReactionStore | undefined {
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalMatrixRuntime();
if (!runtime) {
return undefined;
}
persistentStore = runtime.state.openKeyedStore<PersistedMatrixApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
});
return persistentStore;
}
function reportPersistentApprovalReactionError(error: unknown): void {
try {
getOptionalMatrixRuntime()
?.logging.getChildLogger({ plugin: "matrix", feature: "approval-reaction-state" })
.warn("Matrix persistent approval reaction state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Matrix reactions.
}
}
function readPersistedTarget(value: unknown): MatrixApprovalReactionTarget | null {
const persisted = value as PersistedMatrixApprovalReactionTarget | undefined;
if (
persisted?.version !== 1 ||
!persisted.target ||
typeof persisted.target.approvalId !== "string" ||
!Array.isArray(persisted.target.allowedDecisions)
) {
return null;
}
return persisted.target;
}
function rememberPersistentApprovalReactionTarget(params: {
cfg?: CoreConfig;
key: string;
target: MatrixApprovalReactionTarget;
ttlMs?: number;
}): void {
if (!isPersistentApprovalReactionStateEnabled(params.cfg)) {
return;
}
const ttlMs = params.ttlMs == null ? DEFAULT_REACTION_TARGET_TTL_MS : Math.max(1, params.ttlMs);
let store: MatrixApprovalReactionStore | undefined;
try {
store = getPersistentApprovalReactionStore();
} catch (error) {
reportPersistentApprovalReactionError(error);
return;
}
if (!store) {
return;
}
void store
.register(params.key, { version: 1, target: params.target }, { ttlMs })
.catch(reportPersistentApprovalReactionError);
}
function forgetPersistentApprovalReactionTarget(params: { cfg?: CoreConfig; key: string }): void {
if (!isPersistentApprovalReactionStateEnabled(params.cfg)) {
return;
}
let store: MatrixApprovalReactionStore | undefined;
try {
store = getPersistentApprovalReactionStore();
} catch (error) {
reportPersistentApprovalReactionError(error);
return;
}
if (!store) {
return;
}
void store.delete(params.key).catch(reportPersistentApprovalReactionError);
}
async function lookupPersistentApprovalReactionTarget(params: {
cfg?: CoreConfig;
key: string;
}): Promise<MatrixApprovalReactionTarget | null> {
if (!isPersistentApprovalReactionStateEnabled(params.cfg)) {
return null;
}
let store: MatrixApprovalReactionStore | undefined;
try {
store = getPersistentApprovalReactionStore();
} catch (error) {
reportPersistentApprovalReactionError(error);
return null;
}
if (!store) {
return null;
}
try {
return readPersistedTarget(await store.lookup(params.key));
} catch (error) {
reportPersistentApprovalReactionError(error);
return null;
}
}
export function listMatrixApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): MatrixApprovalReactionBinding[] {
@@ -96,6 +234,8 @@ export function registerMatrixApprovalReactionTarget(params: {
eventId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
cfg?: CoreConfig;
ttlMs?: number;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
const approvalId = params.approvalId.trim();
@@ -110,33 +250,37 @@ export function registerMatrixApprovalReactionTarget(params: {
if (!key || !approvalId || allowedDecisions.length === 0) {
return;
}
matrixApprovalReactionTargets.set(key, {
const target = {
approvalId,
allowedDecisions,
};
matrixApprovalReactionTargets.set(key, target);
rememberPersistentApprovalReactionTarget({
cfg: params.cfg,
key,
target,
ttlMs: params.ttlMs,
});
}
export function unregisterMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
cfg?: CoreConfig;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return;
}
matrixApprovalReactionTargets.delete(key);
forgetPersistentApprovalReactionTarget({ cfg: params.cfg, key });
}
export function resolveMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
function resolveTarget(params: {
target: MatrixApprovalReactionTarget | null | undefined;
reactionKey: string;
}): MatrixApprovalReactionResolution | null {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
const target = matrixApprovalReactionTargets.get(key);
const target = params.target;
if (!target) {
return null;
}
@@ -153,6 +297,45 @@ export function resolveMatrixApprovalReactionTarget(params: {
};
}
export function resolveMatrixApprovalReactionTarget(params: {
roomId: string;
eventId: string;
reactionKey: string;
}): MatrixApprovalReactionResolution | null {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
return resolveTarget({
target: matrixApprovalReactionTargets.get(key),
reactionKey: params.reactionKey,
});
}
export async function resolveMatrixApprovalReactionTargetForConfig(params: {
cfg?: CoreConfig;
roomId: string;
eventId: string;
reactionKey: string;
}): Promise<MatrixApprovalReactionResolution | null> {
const key = buildReactionTargetKey(params.roomId, params.eventId);
if (!key) {
return null;
}
const inMemory = resolveTarget({
target: matrixApprovalReactionTargets.get(key),
reactionKey: params.reactionKey,
});
if (inMemory) {
return inMemory;
}
return resolveTarget({
target: await lookupPersistentApprovalReactionTarget({ cfg: params.cfg, key }),
reactionKey: params.reactionKey,
});
}
export function clearMatrixApprovalReactionTargetsForTest(): void {
matrixApprovalReactionTargets.clear();
persistentStore = undefined;
}

View File

@@ -1,6 +1,6 @@
import { getSessionBindingService } from "openclaw/plugin-sdk/session-binding-runtime";
import {
resolveMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTargetForConfig,
unregisterMatrixApprovalReactionTarget,
} from "../../approval-reactions.js";
import type { CoreConfig } from "../../types.js";
@@ -47,7 +47,7 @@ async function maybeResolveMatrixApprovalReaction(params: {
cfg: CoreConfig;
accountId: string;
senderId: string;
target: ReturnType<typeof resolveMatrixApprovalReactionTarget>;
target: Awaited<ReturnType<typeof resolveMatrixApprovalReactionTargetForConfig>>;
targetEventId: string;
roomId: string;
logVerboseMessage: (message: string) => void;
@@ -75,6 +75,7 @@ async function maybeResolveMatrixApprovalReaction(params: {
} catch (err) {
if (isApprovalNotFoundError(err)) {
unregisterMatrixApprovalReactionTarget({
cfg: params.cfg,
roomId: params.roomId,
eventId: params.targetEventId,
});
@@ -110,7 +111,8 @@ export async function handleInboundMatrixReaction(params: {
if (params.senderId === params.selfUserId) {
return;
}
const approvalTarget = resolveMatrixApprovalReactionTarget({
const approvalTarget = await resolveMatrixApprovalReactionTargetForConfig({
cfg: params.cfg,
roomId: params.roomId,
eventId: reaction.eventId,
reactionKey: reaction.key,

View File

@@ -1,10 +1,13 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "matrix",
errorMessage: "Matrix runtime not initialized",
});
const {
setRuntime: setMatrixRuntime,
getRuntime: getMatrixRuntime,
tryGetRuntime: getOptionalMatrixRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "matrix",
errorMessage: "Matrix runtime not initialized",
});
export { getMatrixRuntime, setMatrixRuntime };
export { getMatrixRuntime, getOptionalMatrixRuntime, setMatrixRuntime };

View File

@@ -237,5 +237,6 @@ export type CoreConfig = {
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
};
secrets?: OpenClawConfig["secrets"];
plugins?: OpenClawConfig["plugins"];
[key: string]: unknown;
};

View File

@@ -10,6 +10,11 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"experimentalPersistentState": {
"type": "boolean",
"description": "Opt in to the experimental SDK-backed persistent state store for Microsoft Teams runtime caches."
}
}
}
}

View File

@@ -98,7 +98,7 @@ import { extractMSTeamsPollVote } from "../polls.js";
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
import { getMSTeamsRuntime } from "../runtime.js";
import type { MSTeamsTurnContext } from "../sdk-types.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js";
import { recordMSTeamsSentMessage, wasMSTeamsMessageSentForConfig } from "../sent-message-cache.js";
import { resolveMSTeamsSenderAccess } from "./access.js";
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
@@ -814,7 +814,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
textLimit,
onSentMessageIds: (ids) => {
for (const id of ids) {
recordMSTeamsSentMessage(conversationId, id);
recordMSTeamsSentMessage(conversationId, id, { cfg });
}
},
tokenProvider,
@@ -984,7 +984,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? "");
const replyToId = activity.replyToId ?? undefined;
const implicitMentionKinds: Array<"reply_to_bot"> =
conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId)
conversationId &&
replyToId &&
(await wasMSTeamsMessageSentForConfig({ cfg, conversationId, messageId: replyToId }))
? ["reply_to_bot"]
: [];

View File

@@ -1,9 +1,12 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "msteams",
errorMessage: "MSTeams runtime not initialized",
});
export { getMSTeamsRuntime, setMSTeamsRuntime };
const {
setRuntime: setMSTeamsRuntime,
getRuntime: getMSTeamsRuntime,
tryGetRuntime: getOptionalMSTeamsRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "msteams",
errorMessage: "MSTeams runtime not initialized",
});
export { getMSTeamsRuntime, getOptionalMSTeamsRuntime, setMSTeamsRuntime };

View File

@@ -1,15 +1,63 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setMSTeamsRuntime } from "./runtime.js";
import {
clearMSTeamsSentMessageCache,
recordMSTeamsSentMessage,
wasMSTeamsMessageSent,
wasMSTeamsMessageSentForConfig,
} from "./sent-message-cache.js";
describe("msteams sent message cache", () => {
it("records and resolves sent message ids", () => {
afterEach(() => {
clearMSTeamsSentMessageCache();
vi.restoreAllMocks();
});
it("records and resolves sent message ids", () => {
recordMSTeamsSentMessage("conv-1", "msg-1");
expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true);
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false);
});
it("persists sent message ids only when opted in", async () => {
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({ sentAt: 123 });
const openKeyedStore = vi.fn(() => ({
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
}));
setMSTeamsRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
recordMSTeamsSentMessage("conv-1", "msg-1");
expect(openKeyedStore).not.toHaveBeenCalled();
const cfg = {
plugins: { entries: { msteams: { config: { experimentalPersistentState: true } } } },
};
recordMSTeamsSentMessage("conv-1", "msg-2", { cfg });
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith("conv-1:msg-2", { sentAt: expect.any(Number) });
clearMSTeamsSentMessageCache();
await expect(
wasMSTeamsMessageSentForConfig({ cfg, conversationId: "conv-1", messageId: "msg-2" }),
).resolves.toBe(true);
expect(openKeyedStore).toHaveBeenCalledTimes(2);
expect(lookup).toHaveBeenCalledWith("conv-1:msg-2");
lookup.mockClear();
await expect(
wasMSTeamsMessageSentForConfig({ cfg, conversationId: "conv-1", messageId: "msg-2" }),
).resolves.toBe(true);
expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(true);
expect(lookup).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,23 @@
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawConfig } from "../runtime-api.js";
import { getOptionalMSTeamsRuntime } from "./runtime.js";
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "msteams.sent-messages";
const MSTEAMS_SENT_MESSAGES_KEY = Symbol.for("openclaw.msteamsSentMessages");
type MSTeamsSentMessageRecord = {
sentAt: number;
};
type MSTeamsSentMessageStore = {
register(key: string, value: MSTeamsSentMessageRecord, opts?: { ttlMs?: number }): Promise<void>;
lookup(key: string): Promise<MSTeamsSentMessageRecord | undefined>;
};
let sentMessageCache: Map<string, Map<string, number>> | undefined;
let persistentStore: MSTeamsSentMessageStore | undefined;
function getSentMessageCache(): Map<string, Map<string, number>> {
if (!sentMessageCache) {
@@ -14,6 +30,40 @@ function getSentMessageCache(): Map<string, Map<string, number>> {
return sentMessageCache;
}
function makePersistentKey(conversationId: string, messageId: string): string {
return `${conversationId}:${messageId}`;
}
function isPersistentSentMessageCacheEnabled(cfg: OpenClawConfig | undefined): boolean {
return resolvePluginConfigObject(cfg, "msteams")?.experimentalPersistentState === true;
}
function getPersistentSentMessageStore(): MSTeamsSentMessageStore | undefined {
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalMSTeamsRuntime();
if (!runtime) {
return undefined;
}
persistentStore = runtime.state.openKeyedStore<MSTeamsSentMessageRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});
return persistentStore;
}
function reportPersistentSentMessageError(error: unknown): void {
try {
getOptionalMSTeamsRuntime()
?.logging.getChildLogger({ plugin: "msteams", feature: "sent-message-state" })
.warn("Microsoft Teams persistent sent-message state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Teams routing.
}
}
function cleanupExpired(scopeKey: string, entry: Map<string, number>, now: number): void {
for (const [id, timestamp] of entry) {
if (now - timestamp > TTL_MS) {
@@ -25,23 +75,86 @@ function cleanupExpired(scopeKey: string, entry: Map<string, number>, now: numbe
}
}
export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void {
if (!conversationId || !messageId) {
return;
}
const now = Date.now();
function rememberSentMessageInMemory(
conversationId: string,
messageId: string,
sentAt: number,
): void {
const store = getSentMessageCache();
let entry = store.get(conversationId);
if (!entry) {
entry = new Map<string, number>();
store.set(conversationId, entry);
}
entry.set(messageId, now);
entry.set(messageId, sentAt);
if (entry.size > 200) {
cleanupExpired(conversationId, entry, now);
cleanupExpired(conversationId, entry, sentAt);
}
}
function rememberPersistentSentMessage(params: {
cfg?: OpenClawConfig;
conversationId: string;
messageId: string;
sentAt: number;
}): void {
if (!isPersistentSentMessageCacheEnabled(params.cfg)) {
return;
}
let store: MSTeamsSentMessageStore | undefined;
try {
store = getPersistentSentMessageStore();
} catch (error) {
reportPersistentSentMessageError(error);
return;
}
if (!store) {
return;
}
void store
.register(makePersistentKey(params.conversationId, params.messageId), { sentAt: params.sentAt })
.catch(reportPersistentSentMessageError);
}
async function lookupPersistentSentMessage(params: {
cfg?: OpenClawConfig;
conversationId: string;
messageId: string;
}): Promise<boolean> {
if (!isPersistentSentMessageCacheEnabled(params.cfg)) {
return false;
}
let store: MSTeamsSentMessageStore | undefined;
try {
store = getPersistentSentMessageStore();
} catch (error) {
reportPersistentSentMessageError(error);
return false;
}
if (!store) {
return false;
}
try {
return Boolean(await store.lookup(makePersistentKey(params.conversationId, params.messageId)));
} catch (error) {
reportPersistentSentMessageError(error);
return false;
}
}
export function recordMSTeamsSentMessage(
conversationId: string,
messageId: string,
opts?: { cfg?: OpenClawConfig },
): void {
if (!conversationId || !messageId) {
return;
}
const now = Date.now();
rememberSentMessageInMemory(conversationId, messageId, now);
rememberPersistentSentMessage({ cfg: opts?.cfg, conversationId, messageId, sentAt: now });
}
export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean {
const entry = getSentMessageCache().get(conversationId);
if (!entry) {
@@ -51,6 +164,25 @@ export function wasMSTeamsMessageSent(conversationId: string, messageId: string)
return entry.has(messageId);
}
export async function wasMSTeamsMessageSentForConfig(params: {
cfg?: OpenClawConfig;
conversationId: string;
messageId: string;
}): Promise<boolean> {
if (!params.conversationId || !params.messageId) {
return false;
}
if (wasMSTeamsMessageSent(params.conversationId, params.messageId)) {
return true;
}
const found = await lookupPersistentSentMessage(params);
if (found) {
rememberSentMessageInMemory(params.conversationId, params.messageId, Date.now());
}
return found;
}
export function clearMSTeamsSentMessageCache(): void {
getSentMessageCache().clear();
persistentStore = undefined;
}

View File

@@ -10,6 +10,11 @@
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"experimentalPersistentState": {
"type": "boolean",
"description": "Opt in to the experimental SDK-backed persistent state store for Slack runtime caches."
}
}
}
}

View File

@@ -266,6 +266,7 @@ export async function handleSlackAction(
account.accountId,
result.channelId,
threadTs,
{ cfg },
);
}
@@ -311,6 +312,7 @@ export async function handleSlackAction(
account.accountId,
result.channelId,
threadTs,
{ cfg },
);
}

View File

@@ -1170,7 +1170,10 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
// or draft stream). Falls back to statusThreadTs for edge cases.
const participationThreadTs = usedReplyThreadTs ?? statusThreadTs;
if (anyReplyDelivered && participationThreadTs) {
recordSlackThreadParticipation(account.accountId, message.channel, participationThreadTs);
recordSlackThreadParticipation(account.accountId, message.channel, participationThreadTs, {
cfg,
agentId: route.agentId,
});
}
if (!anyReplyDelivered) {

View File

@@ -33,7 +33,7 @@ import {
import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
import { formatSlackFileReference } from "../../file-reference.js";
import { hasSlackThreadParticipation } from "../../sent-thread-cache.js";
import { hasSlackThreadParticipationForConfig } from "../../sent-thread-cache.js";
import type { SlackMessageEvent } from "../../types.js";
import {
normalizeAllowListLower,
@@ -360,7 +360,12 @@ export async function prepareSlackMessage(params: {
...implicitMentionKindWhen("reply_to_bot", message.parent_user_id === ctx.botUserId),
...implicitMentionKindWhen(
"bot_thread_participant",
hasSlackThreadParticipation(account.accountId, message.channel, message.thread_ts),
await hasSlackThreadParticipationForConfig({
cfg: ctx.cfg,
accountId: account.accountId,
channelId: message.channel,
threadTs: message.thread_ts,
}),
),
];

View File

@@ -1,14 +1,17 @@
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearSlackRuntime, setSlackRuntime } from "./runtime.js";
import {
clearSlackThreadParticipationCache,
hasSlackThreadParticipation,
hasSlackThreadParticipationForConfig,
recordSlackThreadParticipation,
} from "./sent-thread-cache.js";
describe("slack sent-thread-cache", () => {
afterEach(() => {
clearSlackThreadParticipationCache();
clearSlackRuntime();
vi.restoreAllMocks();
});
@@ -88,4 +91,58 @@ describe("slack sent-thread-cache", () => {
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000000")).toBe(false);
expect(hasSlackThreadParticipation("A1", "C123", "1700000000.005000")).toBe(true);
});
it("writes and reads persistent thread participation only when opted in", async () => {
const register = vi.fn().mockResolvedValue(undefined);
const lookup = vi.fn().mockResolvedValue({ repliedAt: 123 });
const openKeyedStore = vi.fn(() => ({
register,
lookup,
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
}));
setSlackRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
recordSlackThreadParticipation("A1", "C123", "1700000000.000001");
expect(openKeyedStore).not.toHaveBeenCalled();
const cfg = {
plugins: { entries: { slack: { config: { experimentalPersistentState: true } } } },
};
recordSlackThreadParticipation("A1", "C123", "1700000000.000002", { cfg });
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
expect(register).toHaveBeenCalledWith(
"A1:C123:1700000000.000002",
expect.objectContaining({ repliedAt: expect.any(Number) }),
);
clearSlackThreadParticipationCache();
await expect(
hasSlackThreadParticipationForConfig({
cfg,
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000002",
}),
).resolves.toBe(true);
expect(openKeyedStore).toHaveBeenCalledTimes(2);
expect(lookup).toHaveBeenCalledWith("A1:C123:1700000000.000002");
lookup.mockClear();
await expect(
hasSlackThreadParticipationForConfig({
cfg,
accountId: "A1",
channelId: "C123",
threadTs: "1700000000.000002",
}),
).resolves.toBe(true);
expect(lookup).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,7 @@
import { resolveGlobalDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawConfig } from "./runtime-api.js";
import { getOptionalSlackRuntime } from "./runtime.js";
/**
* In-memory cache of Slack threads the bot has participated in.
@@ -8,6 +11,22 @@ import { resolveGlobalDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "slack.thread-participation";
type SlackThreadParticipationRecord = {
agentId?: string;
repliedAt: number;
};
type SlackThreadParticipationStore = {
register(
key: string,
value: SlackThreadParticipationRecord,
opts?: { ttlMs?: number },
): Promise<void>;
lookup(key: string): Promise<SlackThreadParticipationRecord | undefined>;
};
/**
* Keep Slack thread participation shared across bundled chunks so thread
@@ -19,19 +38,106 @@ const threadParticipation = resolveGlobalDedupeCache(SLACK_THREAD_PARTICIPATION_
maxSize: MAX_ENTRIES,
});
let persistentStore: SlackThreadParticipationStore | undefined;
function makeKey(accountId: string, channelId: string, threadTs: string): string {
return `${accountId}:${channelId}:${threadTs}`;
}
function isPersistentThreadParticipationEnabled(cfg: OpenClawConfig | undefined): boolean {
return resolvePluginConfigObject(cfg, "slack")?.experimentalPersistentState === true;
}
function getPersistentThreadParticipationStore(): SlackThreadParticipationStore | undefined {
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalSlackRuntime();
if (!runtime) {
return undefined;
}
persistentStore = runtime.state.openKeyedStore<SlackThreadParticipationRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});
return persistentStore;
}
function reportPersistentThreadParticipationError(error: unknown): void {
try {
getOptionalSlackRuntime()
?.logging.getChildLogger({ plugin: "slack", feature: "thread-participation-state" })
.warn("Slack persistent thread participation state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Slack message handling.
}
}
function rememberPersistentThreadParticipation(params: {
cfg?: OpenClawConfig;
key: string;
agentId?: string;
}): void {
if (!isPersistentThreadParticipationEnabled(params.cfg)) {
return;
}
let store: SlackThreadParticipationStore | undefined;
try {
store = getPersistentThreadParticipationStore();
} catch (error) {
reportPersistentThreadParticipationError(error);
return;
}
if (!store) {
return;
}
void store
.register(params.key, {
// Stored for future per-agent thread routing; current reads only need presence.
...(params.agentId ? { agentId: params.agentId } : {}),
repliedAt: Date.now(),
})
.catch(reportPersistentThreadParticipationError);
}
async function lookupPersistentThreadParticipation(params: {
cfg?: OpenClawConfig;
key: string;
}): Promise<boolean> {
if (!isPersistentThreadParticipationEnabled(params.cfg)) {
return false;
}
let store: SlackThreadParticipationStore | undefined;
try {
store = getPersistentThreadParticipationStore();
} catch (error) {
reportPersistentThreadParticipationError(error);
return false;
}
if (!store) {
return false;
}
try {
return Boolean(await store.lookup(params.key));
} catch (error) {
reportPersistentThreadParticipationError(error);
return false;
}
}
export function recordSlackThreadParticipation(
accountId: string,
channelId: string,
threadTs: string,
opts?: { cfg?: OpenClawConfig; agentId?: string },
): void {
if (!accountId || !channelId || !threadTs) {
return;
}
threadParticipation.check(makeKey(accountId, channelId, threadTs));
const key = makeKey(accountId, channelId, threadTs);
threadParticipation.check(key);
rememberPersistentThreadParticipation({ cfg: opts?.cfg, key, agentId: opts?.agentId });
}
export function hasSlackThreadParticipation(
@@ -45,6 +151,27 @@ export function hasSlackThreadParticipation(
return threadParticipation.peek(makeKey(accountId, channelId, threadTs));
}
export async function hasSlackThreadParticipationForConfig(params: {
cfg?: OpenClawConfig;
accountId: string;
channelId: string;
threadTs: string;
}): Promise<boolean> {
if (!params.accountId || !params.channelId || !params.threadTs) {
return false;
}
const key = makeKey(params.accountId, params.channelId, params.threadTs);
if (threadParticipation.peek(key)) {
return true;
}
const found = await lookupPersistentThreadParticipation({ cfg: params.cfg, key });
if (found) {
threadParticipation.check(key);
}
return found;
}
export function clearSlackThreadParticipationCache(): void {
threadParticipation.clear();
persistentStore = undefined;
}