From 0d161069f2211d1028ad29d08af01b9bfe1ca5ec Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 22 Mar 2026 21:14:17 -0700 Subject: [PATCH] fix(matrix): avoid touching dropped room bindings --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/handler.test.ts | 61 ++++++++++++++++++- .../matrix/src/matrix/monitor/handler.ts | 10 ++- .../src/matrix/monitor/reaction-events.ts | 6 +- .../matrix/src/matrix/monitor/route.test.ts | 7 ++- extensions/matrix/src/matrix/monitor/route.ts | 6 +- 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b68ac282b94..7d25c46d939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,7 @@ Docs: https://docs.openclaw.ai - Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras. - Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702) - Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras. +- Plugins/Matrix: stop mention-gated or otherwise dropped room chatter from refreshing focused thread bindings before the message is actually routed, so idle ACP and session bindings can still expire normally in mention-required rooms. Thanks @vincentkoc, @dinakars777 and @mvanhorn. - Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow. - Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras - Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz. diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 8512f7b7cdd..7c048545eb2 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -591,6 +591,7 @@ describe("matrix monitor handler pairing account scope", () => { }); it("routes bound Matrix threads to the target session key", async () => { + const touch = vi.fn(); registerSessionBindingAdapter({ channel: "matrix", accountId: "ops", @@ -614,7 +615,7 @@ describe("matrix monitor handler pairing account scope", () => { }, } : null, - touch: vi.fn(), + touch, }); const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ client: { @@ -649,6 +650,64 @@ describe("matrix monitor handler pairing account scope", () => { sessionKey: "agent:bound:session-1", }), ); + expect(touch).toHaveBeenCalledTimes(1); + }); + + it("does not refresh bound Matrix thread bindings for room messages dropped before routing", async () => { + const touch = vi.fn(); + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "ops", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "$root" + ? { + bindingId: "ops:!room:example:$root", + targetSessionKey: "agent:bound:session-1", + targetKind: "session", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + }, + } + : null, + touch, + }); + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$root", + sender: "@alice:example.org", + body: "Root topic", + }), + }, + isDirectMessage: false, + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example", + createMatrixTextMessageEvent({ + eventId: "$reply-no-mention", + body: "follow up without mention", + relatesTo: { + rel_type: "m.thread", + event_id: "$root", + "m.in_reply_to": { event_id: "$root" }, + }, + }), + ); + + expect(recordInboundSession).not.toHaveBeenCalled(); + expect(touch).not.toHaveBeenCalled(); }); it("does not enqueue system events for delivered text replies", async () => { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 3e330059b40..90eafb8794c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -4,6 +4,7 @@ import { ensureConfiguredAcpBindingReady, formatAllowlistMatchMeta, getAgentScopedMediaLocalRoots, + getSessionBindingService, logInboundDrop, logTypingFailure, resolveControlCommandGate, @@ -529,7 +530,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const _messageId = event.event_id ?? ""; const _threadRootId = resolveMatrixThreadRootId({ event, content }); - const { route: _route, configuredBinding: _configuredBinding } = resolveMatrixInboundRoute({ + const { + route: _route, + configuredBinding: _configuredBinding, + runtimeBindingId: _runtimeBindingId, + } = resolveMatrixInboundRoute({ cfg, accountId, roomId, @@ -714,6 +719,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } + if (_runtimeBindingId) { + getSessionBindingService().touch(_runtimeBindingId, eventTs ?? undefined); + } const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); const textWithId = `${bodyText}\n[matrix event id: ${_messageId} room: ${roomId}]`; const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { diff --git a/extensions/matrix/src/matrix/monitor/reaction-events.ts b/extensions/matrix/src/matrix/monitor/reaction-events.ts index 51d807a26c3..08af65857ad 100644 --- a/extensions/matrix/src/matrix/monitor/reaction-events.ts +++ b/extensions/matrix/src/matrix/monitor/reaction-events.ts @@ -1,3 +1,4 @@ +import { getSessionBindingService } from "../../runtime-api.js"; import type { PluginRuntime } from "../../runtime-api.js"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixAccountConfig } from "../accounts.js"; @@ -72,7 +73,7 @@ export async function handleInboundMatrixReaction(params: { content: targetContent, }) : undefined; - const { route } = resolveMatrixInboundRoute({ + const { route, runtimeBindingId } = resolveMatrixInboundRoute({ cfg: params.cfg, accountId: params.accountId, roomId: params.roomId, @@ -83,6 +84,9 @@ export async function handleInboundMatrixReaction(params: { eventTs: params.event.origin_server_ts, resolveAgentRoute: params.core.channel.routing.resolveAgentRoute, }); + if (runtimeBindingId) { + getSessionBindingService().touch(runtimeBindingId, params.event.origin_server_ts); + } const text = `Matrix reaction added: ${reaction.key} by ${params.senderLabel} on msg ${reaction.eventId}`; params.core.system.enqueueSystemEvent(text, { sessionKey: route.sessionKey, diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts index f170db9080b..97a5d4fe0fe 100644 --- a/extensions/matrix/src/matrix/monitor/route.test.ts +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -131,6 +131,7 @@ describe("resolveMatrixInboundRoute", () => { }); it("lets runtime conversation bindings override both sender and room route matches", () => { + const touch = vi.fn(); registerSessionBindingAdapter({ channel: "matrix", accountId: "ops", @@ -151,7 +152,7 @@ describe("resolveMatrixInboundRoute", () => { metadata: { boundBy: "user-1" }, } : null, - touch: vi.fn(), + touch, }); const cfg = { @@ -176,11 +177,13 @@ describe("resolveMatrixInboundRoute", () => { ], } satisfies OpenClawConfig; - const { route, configuredBinding } = resolveDmRoute(cfg); + const { route, configuredBinding, runtimeBindingId } = resolveDmRoute(cfg); expect(configuredBinding).toBeNull(); + expect(runtimeBindingId).toBe("ops:!dm:example.org"); expect(route.agentId).toBe("bound"); expect(route.matchedBy).toBe("binding.channel"); expect(route.sessionKey).toBe("agent:bound:session-1"); + expect(touch).not.toHaveBeenCalled(); }); }); diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts index 6f280ab40dc..0cd6a0a8acf 100644 --- a/extensions/matrix/src/matrix/monitor/route.ts +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -21,6 +21,7 @@ export function resolveMatrixInboundRoute(params: { }): { route: MatrixResolvedRoute; configuredBinding: ReturnType; + runtimeBindingId: string | null; } { const baseRoute = params.resolveAgentRoute({ cfg: params.cfg, @@ -54,9 +55,6 @@ export function resolveMatrixInboundRoute(params: { }); const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); - if (runtimeBinding) { - sessionBindingService.touch(runtimeBinding.bindingId, params.eventTs); - } if (runtimeBinding && boundSessionKey) { return { route: { @@ -66,6 +64,7 @@ export function resolveMatrixInboundRoute(params: { matchedBy: "binding.channel", }, configuredBinding: null, + runtimeBindingId: runtimeBinding.bindingId, }; } @@ -95,5 +94,6 @@ export function resolveMatrixInboundRoute(params: { } : baseRoute, configuredBinding, + runtimeBindingId: null, }; }