Skip to content

Commit 1f9ebb9

Browse files
authored
Fix Matrix configured two-person room routing (#85137)
* Fix Matrix configured room DM routing * Add Matrix room routing changelog
1 parent 0aabaeb commit 1f9ebb9

6 files changed

Lines changed: 129 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
4949
- 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.
5050
- 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.
5151
- 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.
52+
- 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.
5253
- 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.
5354
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
5455
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.

extensions/matrix/src/matrix/direct-room.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe("inspectMatrixDirectRoomEvidence", () => {
4141
expect(result.strict).toBe(true);
4242
});
4343

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

extensions/matrix/src/matrix/monitor/direct.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,40 @@ describe("createDirectRoomTracker", () => {
9797
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
9898
});
9999

100+
it("lets explicit room config veto stale m.direct classifications", async () => {
101+
const client = createMockClient({ isDm: true });
102+
const tracker = createDirectRoomTracker(client, {
103+
isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org",
104+
});
105+
106+
await expect(
107+
tracker.isDirectMessage({
108+
roomId: "!room:example.org",
109+
senderId: "@alice:example.org",
110+
}),
111+
).resolves.toBe(false);
112+
113+
expect(client.dms.update).not.toHaveBeenCalled();
114+
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
115+
});
116+
117+
it("lets explicit room config veto strict two-member fallback before dm cache seed", async () => {
118+
const client = createMockClient({ isDm: false, dmCacheAvailable: false });
119+
const tracker = createDirectRoomTracker(client, {
120+
isExplicitlyConfiguredRoom: (roomId) => roomId === "!room:example.org",
121+
});
122+
123+
await expect(
124+
tracker.isDirectMessage({
125+
roomId: "!room:example.org",
126+
senderId: "@alice:example.org",
127+
}),
128+
).resolves.toBe(false);
129+
130+
expect(client.dms.update).not.toHaveBeenCalled();
131+
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
132+
});
133+
100134
it("does not trust stale m.direct classifications for shared rooms", async () => {
101135
const client = createMockClient({
102136
isDm: true,

extensions/matrix/src/matrix/monitor/direct.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type DirectMessageCheck = {
1414

1515
type DirectRoomTrackerOptions = {
1616
log?: (message: string) => void;
17+
isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise<boolean>;
1718
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
1819
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
1920
shouldKeepLocallyPromotedDirectRoom?:
@@ -162,6 +163,15 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
162163
}
163164
};
164165

166+
const isExplicitlyConfiguredRoom = async (roomId: string): Promise<boolean> => {
167+
try {
168+
return (await opts.isExplicitlyConfiguredRoom?.(roomId)) ?? false;
169+
} catch (err) {
170+
log(`matrix: configured room check failed room=${roomId} (${String(err)})`);
171+
return true;
172+
}
173+
};
174+
165175
const hasLocallyPromotedDirectRoom = (roomId: string, remoteUserId?: string | null): boolean => {
166176
const normalizedRemoteUserId = remoteUserId?.trim();
167177
if (!normalizedRemoteUserId) {
@@ -204,6 +214,10 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
204214
},
205215
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
206216
const { roomId, senderId } = params;
217+
if (await isExplicitlyConfiguredRoom(roomId)) {
218+
log(`matrix: dm rejected via explicit room config room=${roomId}`);
219+
return false;
220+
}
207221
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
208222
const joinedMembers = await resolveJoinedMembers(roomId);
209223
const strictDirectMembership = isStrictDirectMembership({

extensions/matrix/src/matrix/monitor/index.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { MatrixConfig, MatrixStreamingMode } from "../../types.js";
44
import type { MatrixRoomInfo } from "./room-info.js";
55

66
type DirectRoomTrackerOptions = {
7+
isExplicitlyConfiguredRoom?: (roomId: string) => boolean | Promise<boolean>;
78
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
89
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
910
shouldKeepLocallyPromotedDirectRoom?:
@@ -145,7 +146,18 @@ vi.mock("../../runtime-api.js", () => {
145146
ToolPolicySchema: z.any().optional(),
146147
addAllowlistUserEntriesFromConfigEntry: vi.fn(),
147148
buildChannelConfigSchema: (schema: unknown) => schema,
148-
buildChannelKeyCandidates: () => [],
149+
buildChannelKeyCandidates: (...keys: Array<string | undefined | null>) => {
150+
const seen = new Set<string>();
151+
return keys
152+
.map((key) => (typeof key === "string" ? key.trim() : ""))
153+
.filter((key) => {
154+
if (!key || seen.has(key)) {
155+
return false;
156+
}
157+
seen.add(key);
158+
return true;
159+
});
160+
},
149161
buildProbeChannelStatusSummary: (
150162
snapshot: Record<string, unknown>,
151163
extra?: Record<string, unknown>,
@@ -961,6 +973,55 @@ describe("monitorMatrixProvider", () => {
961973
await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
962974
});
963975

976+
it("wires exact room config as a direct-room classifier veto", async () => {
977+
(hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms = {
978+
"!room:example.org": { requireMention: true },
979+
"*": { requireMention: false },
980+
};
981+
982+
await startMonitorAndAbortAfterStartup();
983+
984+
const trackerOpts = directRoomTrackerOptions();
985+
if (!trackerOpts?.isExplicitlyConfiguredRoom) {
986+
throw new Error("explicit room config callback was not wired");
987+
}
988+
989+
expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true);
990+
expect(await trackerOpts.isExplicitlyConfiguredRoom("!other:example.org")).toBe(false);
991+
expect(hoisted.getRoomInfo).not.toHaveBeenCalled();
992+
});
993+
994+
it("wires alias room config as a direct-room classifier veto", async () => {
995+
(hoisted.accountConfig as { rooms?: Record<string, unknown> }).rooms = {
996+
"#ops:example.org": { requireMention: true },
997+
"*": { requireMention: false },
998+
};
999+
const { resolveMatrixTargets } = await import("../../resolve-targets.js");
1000+
vi.mocked(resolveMatrixTargets).mockResolvedValueOnce([
1001+
{
1002+
input: "#ops:example.org",
1003+
resolved: true,
1004+
id: "!room:example.org",
1005+
},
1006+
]);
1007+
1008+
await startMonitorAndAbortAfterStartup();
1009+
1010+
const trackerOpts = directRoomTrackerOptions();
1011+
if (!trackerOpts?.isExplicitlyConfiguredRoom) {
1012+
throw new Error("explicit room config callback was not wired");
1013+
}
1014+
1015+
hoisted.getRoomInfo.mockResolvedValueOnce({
1016+
canonicalAlias: "#ops:example.org",
1017+
altAliases: [],
1018+
nameResolved: true,
1019+
aliasesResolved: true,
1020+
});
1021+
1022+
expect(await trackerOpts.isExplicitlyConfiguredRoom("!room:example.org")).toBe(true);
1023+
});
1024+
9641025
it("wires recent-invite promotion to reject named rooms", async () => {
9651026
await startMonitorAndAbortAfterStartup();
9661027

extensions/matrix/src/matrix/monitor/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
} from "./inbound-dedupe.js";
5151
import { shouldPromoteRecentInviteRoom } from "./recent-invite.js";
5252
import { createMatrixRoomInfoResolver } from "./room-info.js";
53+
import { resolveMatrixRoomConfig } from "./rooms.js";
5354
import { runMatrixStartupMaintenance } from "./startup.js";
5455
import { createMatrixMonitorStatusController } from "./status.js";
5556
import { createMatrixMonitorSyncLifecycle } from "./sync-lifecycle.js";
@@ -345,8 +346,24 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
345346
// /sync cursor we want restart backlogs to replay just like other channels.
346347
const dropPreStartupMessages = !client.hasPersistedSyncState();
347348
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
349+
const isExplicitlyConfiguredRoom = async (roomId: string): Promise<boolean> => {
350+
const roomInfoForConfig = needsRoomAliasesForConfig
351+
? await getRoomInfo(roomId, { includeAliases: true })
352+
: undefined;
353+
const aliases = roomInfoForConfig
354+
? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(Boolean)
355+
: [];
356+
return (
357+
resolveMatrixRoomConfig({
358+
rooms: roomsConfig,
359+
roomId,
360+
aliases,
361+
}).matchSource === "direct"
362+
);
363+
};
348364
const directTracker = createDirectRoomTracker(client, {
349365
log: logVerboseMessage,
366+
isExplicitlyConfiguredRoom,
350367
canPromoteRecentInvite: async (roomId) =>
351368
shouldPromoteRecentInviteRoom({
352369
roomId,

0 commit comments

Comments
 (0)