mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
2 Commits
v2026.5.3
...
ak/plugin-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b8ac61e9 | ||
|
|
55ecf09a70 |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
: [];
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user