mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 10:02:15 +00:00
Fix Matrix configured two-person room routing (#85137)
* Fix Matrix configured room DM routing * Add Matrix room routing changelog
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user