fix(matrix): avoid touching dropped room bindings

This commit is contained in:
Vincent Koc 2026-03-22 21:14:17 -07:00
parent ee749b520e
commit 0d161069f2
6 changed files with 83 additions and 8 deletions

View File

@ -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 OpenClaws 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.

View File

@ -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 () => {

View File

@ -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, {

View File

@ -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,

View File

@ -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();
});
});

View File

@ -21,6 +21,7 @@ export function resolveMatrixInboundRoute(params: {
}): {
route: MatrixResolvedRoute;
configuredBinding: ReturnType<typeof resolveConfiguredAcpBindingRecord>;
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,
};
}