Fix Matrix configured two-person room routing (#85137)

* Fix Matrix configured room DM routing

* Add Matrix room routing changelog
This commit is contained in:
Josh Avant
2026-05-21 17:40:17 -07:00
committed by GitHub
parent 0aabaebba1
commit 1f9ebb9dda
6 changed files with 129 additions and 2 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.

View File

@@ -41,7 +41,7 @@ describe("inspectMatrixDirectRoomEvidence", () => {
expect(result.strict).toBe(true);
});
it("records only the local member-state direct flag", async () => {
it("preserves strict evidence when local is_direct=false provides a promotion veto reason", async () => {
const client = createClient({
getRoomStateEvent: vi.fn(async (_roomId: string, _eventType: string, stateKey: string) =>
stateKey === "@bot:example.org" ? { is_direct: false } : { is_direct: true },

View File

@@ -97,6 +97,40 @@ describe("createDirectRoomTracker", () => {
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("lets explicit room config veto stale m.direct classifications", async () => {
const client = createMockClient({ isDm: true });
const tracker = createDirectRoomTracker(client, {
isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org",
});
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
expect(client.dms.update).not.toHaveBeenCalled();
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
});
it("lets explicit room config veto strict two-member fallback before dm cache seed", async () => {
const client = createMockClient({ isDm: false, dmCacheAvailable: false });
const tracker = createDirectRoomTracker(client, {
isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org",
});
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
expect(client.dms.update).not.toHaveBeenCalled();
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
});
it("does not trust stale m.direct classifications for shared rooms", async () => {
const client = createMockClient({
isDm: true,

View File

@@ -14,6 +14,7 @@ type DirectMessageCheck = {
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise<boolean>;
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
shouldKeepLocallyPromotedDirectRoom?:
@@ -162,6 +163,15 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
}
};
const isExplicitlyConfiguredRoom = async (roomId: string): Promise<boolean> => {
try {
return (await opts.isExplicitlyConfiguredRoom?.(roomId)) ?? false;
} catch (err) {
log(`matrix: configured room check failed room=${roomId} (${String(err)})`);
return true;
}
};
const hasLocallyPromotedDirectRoom = (roomId: string, remoteUserId?: string | null): boolean => {
const normalizedRemoteUserId = remoteUserId?.trim();
if (!normalizedRemoteUserId) {
@@ -204,6 +214,10 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
},
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
if (await isExplicitlyConfiguredRoom(roomId)) {
log(`matrix: dm rejected via explicit room config room=${roomId}`);
return false;
}
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const joinedMembers = await resolveJoinedMembers(roomId);
const strictDirectMembership = isStrictDirectMembership({

View File

@@ -4,6 +4,7 @@ import type { MatrixConfig, MatrixStreamingMode } from "../../types.js";
import type { MatrixRoomInfo } from "./room-info.js";
type DirectRoomTrackerOptions = {
isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise<boolean>;
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
shouldKeepLocallyPromotedDirectRoom?:
@@ -145,7 +146,18 @@ vi.mock("../../runtime-api.js", () => {
ToolPolicySchema: z.any().optional(),
addAllowlistUserEntriesFromConfigEntry: vi.fn(),
buildChannelConfigSchema: (schema: unknown) => schema,
buildChannelKeyCandidates: () => [],
buildChannelKeyCandidates: (...keys: Array<string | undefined | null>) => {
const seen = new Set<string>();
return keys
.map((key) => (typeof key === "string" ? key.trim() : ""))
.filter((key) => {
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
},
buildProbeChannelStatusSummary: (
snapshot: Record<string, unknown>,
extra?: Record<string, unknown>,
@@ -961,6 +973,55 @@ describe("monitorMatrixProvider", () => {
await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
});
it("wires exact room config as a direct-room classifier veto", async () => {
(hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms = {
"!room:example.org": { requireMention: true },
"*": { requireMention: false },
};
await startMonitorAndAbortAfterStartup();
const trackerOpts = directRoomTrackerOptions();
if (!trackerOpts?.isExplicitlyConfiguredRoom) {
throw new Error("explicit room config callback was not wired");
}
expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true);
expect(await trackerOpts.isExplicitlyConfiguredRoom("!other:example.org")).toBe(false);
expect(hoisted.getRoomInfo).not.toHaveBeenCalled();
});
it("wires alias room config as a direct-room classifier veto", async () => {
(hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms = {
"#ops:example.org": { requireMention: true },
"*": { requireMention: false },
};
const { resolveMatrixTargets } = await import("../../resolve-targets.js");
vi.mocked(resolveMatrixTargets).mockResolvedValueOnce([
{
input: "#ops:example.org",
resolved: true,
id: "!room:example.org",
},
]);
await startMonitorAndAbortAfterStartup();
const trackerOpts = directRoomTrackerOptions();
if (!trackerOpts?.isExplicitlyConfiguredRoom) {
throw new Error("explicit room config callback was not wired");
}
hoisted.getRoomInfo.mockResolvedValueOnce({
canonicalAlias: "#ops:example.org",
altAliases: [],
nameResolved: true,
aliasesResolved: true,
});
expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true);
});
it("wires recent-invite promotion to reject named rooms", async () => {
await startMonitorAndAbortAfterStartup();

View File

@@ -50,6 +50,7 @@ import {
} from "./inbound-dedupe.js";
import { shouldPromoteRecentInviteRoom } from "./recent-invite.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { runMatrixStartupMaintenance } from "./startup.js";
import { createMatrixMonitorStatusController } from "./status.js";
import { createMatrixMonitorSyncLifecycle } from "./sync-lifecycle.js";
@@ -345,8 +346,24 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
// /sync cursor we want restart backlogs to replay just like other channels.
const dropPreStartupMessages = !client.hasPersistedSyncState();
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
const isExplicitlyConfiguredRoom = async (roomId: string): Promise<boolean> => {
const roomInfoForConfig = needsRoomAliasesForConfig
? await getRoomInfo(roomId, { includeAliases: true })
: undefined;
const aliases = roomInfoForConfig
? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(Boolean)
: [];
return (
resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases,
}).matchSource === "direct"
);
};
const directTracker = createDirectRoomTracker(client, {
log: logVerboseMessage,
isExplicitlyConfiguredRoom,
canPromoteRecentInvite: async (roomId) =>
shouldPromoteRecentInviteRoom({
roomId,