matrix: persist approval reaction targets best-effort (#75586)

* matrix: persist approval reaction targets best-effort

* docs: refine matrix approval changelog
This commit is contained in:
Alex Knight
2026-05-03 17:17:01 +10:00
committed by GitHub
parent b74401074b
commit f27ecffc0c
6 changed files with 267 additions and 27 deletions

View File

@@ -414,6 +414,7 @@ Docs: https://docs.openclaw.ai
- Agents/workspace: add `agents.defaults.skipOptionalBootstrapFiles` for skipping selected optional workspace files during bootstrap without disabling required workspace setup. (#62110) Thanks @mainstay22.
- Plugins/CLI: add first-class `git:` plugin installs with ref checkout, commit metadata, normal scanner/staging, and `plugins update` support for recorded git sources. Thanks @badlogic.
- Google Meet: add live caption health for Chrome transcribe mode, including caption observer state, transcript counters, last caption text, and recent transcript lines in status and doctor output. Refs #72478. Thanks @DougButdorf.
- Matrix: keep pending approval reactions working across Gateway restarts, so room approvers can still approve or deny existing prompts after OpenClaw comes back online. Thanks @amknight.
- Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.
- macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.
- Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.

View File

@@ -511,10 +511,10 @@ 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;
@@ -522,13 +522,14 @@ export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAda
registerMatrixApprovalReactionTarget({
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;
}

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,
resolveMatrixApprovalReactionTargetWithPersistence,
unregisterMatrixApprovalReactionTarget,
} from "./approval-reactions.js";
import { setMatrixRuntime } from "./runtime.js";
afterEach(() => {
clearMatrixApprovalReactionTargetsForTest();
vi.restoreAllMocks();
});
describe("matrix approval reactions", () => {
@@ -104,4 +107,81 @@ describe("matrix approval reactions", () => {
}),
).toBeNull();
});
it("persists approval reaction targets when runtime state is available", 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-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(
resolveMatrixApprovalReactionTargetWithPersistence({
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");
});
it("falls back to in-memory approval reaction targets when persistent state cannot open", () => {
const warn = vi.fn();
setMatrixRuntime({
state: {
openKeyedStore: vi.fn(() => {
throw new Error("sqlite unavailable");
}),
},
logging: { getChildLogger: () => ({ warn }) },
} as never);
registerMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
approvalId: "req-fallback",
allowedDecisions: ["deny"],
});
expect(
resolveMatrixApprovalReactionTarget({
roomId: "!ops:example.org",
eventId: "$approval-msg-3",
reactionKey: "❌",
}),
).toEqual({ approvalId: "req-fallback", decision: "deny" });
expect(warn).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
import { getOptionalMatrixRuntime } from "./runtime.js";
const MATRIX_APPROVAL_REACTION_META = {
"allow-once": {
@@ -21,7 +22,11 @@ const MATRIX_APPROVAL_REACTION_ORDER = [
"deny",
] as const satisfies readonly ExecApprovalReplyDecision[];
type MatrixApprovalReactionBinding = {
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;
label: string;
@@ -37,7 +42,24 @@ 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>;
};
const matrixApprovalReactionTargets = new Map<string, MatrixApprovalReactionTarget>();
let persistentStore: MatrixApprovalReactionStore | undefined;
let persistentStoreDisabled = false;
function buildReactionTargetKey(roomId: string, eventId: string): string | null {
const normalizedRoomId = roomId.trim();
@@ -48,6 +70,97 @@ function buildReactionTargetKey(roomId: string, eventId: string): string | null
return `${normalizedRoomId}:${normalizedEventId}`;
}
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 disablePersistentApprovalReactionStore(error: unknown): void {
persistentStoreDisabled = true;
persistentStore = undefined;
reportPersistentApprovalReactionError(error);
}
function getPersistentApprovalReactionStore(): MatrixApprovalReactionStore | undefined {
if (persistentStoreDisabled) {
return undefined;
}
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalMatrixRuntime();
if (!runtime) {
return undefined;
}
try {
persistentStore = runtime.state.openKeyedStore<PersistedMatrixApprovalReactionTarget>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
});
return persistentStore;
} catch (error) {
disablePersistentApprovalReactionStore(error);
return undefined;
}
}
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: {
key: string;
target: MatrixApprovalReactionTarget;
ttlMs?: number;
}): void {
const ttlMs = params.ttlMs == null ? DEFAULT_REACTION_TARGET_TTL_MS : Math.max(1, params.ttlMs);
const store = getPersistentApprovalReactionStore();
if (!store) {
return;
}
void store
.register(params.key, { version: 1, target: params.target }, { ttlMs })
.catch(disablePersistentApprovalReactionStore);
}
function forgetPersistentApprovalReactionTarget(key: string): void {
const store = getPersistentApprovalReactionStore();
if (!store) {
return;
}
void store.delete(key).catch(disablePersistentApprovalReactionStore);
}
async function lookupPersistentApprovalReactionTarget(
key: string,
): Promise<MatrixApprovalReactionTarget | null> {
const store = getPersistentApprovalReactionStore();
if (!store) {
return null;
}
try {
return readPersistedTarget(await store.lookup(key));
} catch (error) {
disablePersistentApprovalReactionStore(error);
return null;
}
}
export function listMatrixApprovalReactionBindings(
allowedDecisions: readonly ExecApprovalReplyDecision[],
): MatrixApprovalReactionBinding[] {
@@ -96,6 +209,7 @@ export function registerMatrixApprovalReactionTarget(params: {
eventId: string;
approvalId: string;
allowedDecisions: readonly ExecApprovalReplyDecision[];
ttlMs?: number;
}): void {
const key = buildReactionTargetKey(params.roomId, params.eventId);
const approvalId = params.approvalId.trim();
@@ -110,9 +224,15 @@ export function registerMatrixApprovalReactionTarget(params: {
if (!key || !approvalId || allowedDecisions.length === 0) {
return;
}
matrixApprovalReactionTargets.set(key, {
const target = {
approvalId,
allowedDecisions,
};
matrixApprovalReactionTargets.set(key, target);
rememberPersistentApprovalReactionTarget({
key,
target,
ttlMs: params.ttlMs,
});
}
@@ -125,18 +245,14 @@ export function unregisterMatrixApprovalReactionTarget(params: {
return;
}
matrixApprovalReactionTargets.delete(key);
forgetPersistentApprovalReactionTarget(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 +269,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 resolveMatrixApprovalReactionTargetWithPersistence(params: {
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(key),
reactionKey: params.reactionKey,
});
}
export function clearMatrixApprovalReactionTargetsForTest(): void {
matrixApprovalReactionTargets.clear();
persistentStore = undefined;
persistentStoreDisabled = false;
}

View File

@@ -1,6 +1,6 @@
import { getSessionBindingService } from "openclaw/plugin-sdk/session-binding-runtime";
import {
resolveMatrixApprovalReactionTarget,
resolveMatrixApprovalReactionTargetWithPersistence,
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 resolveMatrixApprovalReactionTargetWithPersistence>>;
targetEventId: string;
roomId: string;
logVerboseMessage: (message: string) => void;
@@ -110,7 +110,7 @@ export async function handleInboundMatrixReaction(params: {
if (params.senderId === params.selfUserId) {
return;
}
const approvalTarget = resolveMatrixApprovalReactionTarget({
const approvalTarget = await resolveMatrixApprovalReactionTargetWithPersistence({
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 };