mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user