diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3bf5298d4..de2e81ed0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index 8184f364017..f03dd91ab84 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -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; } diff --git a/extensions/matrix/src/approval-reactions.test.ts b/extensions/matrix/src/approval-reactions.test.ts index cd2d9846e37..4e2c657b0f4 100644 --- a/extensions/matrix/src/approval-reactions.test.ts +++ b/extensions/matrix/src/approval-reactions.test.ts @@ -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(); + }); }); diff --git a/extensions/matrix/src/approval-reactions.ts b/extensions/matrix/src/approval-reactions.ts index 0a8c442c234..f4676e2b4d1 100644 --- a/extensions/matrix/src/approval-reactions.ts +++ b/extensions/matrix/src/approval-reactions.ts @@ -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; + lookup(key: string): Promise; + delete(key: string): Promise; +}; + const matrixApprovalReactionTargets = new Map(); +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({ + 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 { + 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 { + 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; } diff --git a/extensions/matrix/src/matrix/monitor/reaction-events.ts b/extensions/matrix/src/matrix/monitor/reaction-events.ts index 445c8cb8fab..2cb69950343 100644 --- a/extensions/matrix/src/matrix/monitor/reaction-events.ts +++ b/extensions/matrix/src/matrix/monitor/reaction-events.ts @@ -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; + target: Awaited>; 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, diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 5f830d03619..4003bb916e8 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -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({ - pluginId: "matrix", - errorMessage: "Matrix runtime not initialized", - }); +const { + setRuntime: setMatrixRuntime, + getRuntime: getMatrixRuntime, + tryGetRuntime: getOptionalMatrixRuntime, +} = createPluginRuntimeStore({ + pluginId: "matrix", + errorMessage: "Matrix runtime not initialized", +}); -export { getMatrixRuntime, setMatrixRuntime }; +export { getMatrixRuntime, getOptionalMatrixRuntime, setMatrixRuntime };