fix: restore matrix per-room dm discovery

This commit is contained in:
stainlu
2026-05-09 15:24:31 +08:00
committed by Peter Steinberger
parent b90f28e895
commit 166b42a40f
7 changed files with 128 additions and 1 deletions

View File

@@ -406,6 +406,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu.
- Gateway/macOS: clear ignored SIGUSR1 restart state, skip redundant package-update restarts when the refreshed LaunchAgent already serves the expected version, and give launchd a 10s throttle plus 20s shutdown window so update restarts do not leave old gateways alive or fight supervisor recovery. Fixes #79577; refs #78699 and #60885. Thanks @BunsDev.
- Status/Codex: route Codex-harness `openai/*` usage through the OpenAI Codex quota provider and scope CLI status usage to the default agent auth store so `/status` and `openclaw status --usage` show Codex quota windows again. Fixes #79312. Thanks @keshavbotagent.
- Matrix: keep joined strict DM rooms discoverable when stale `m.direct` mappings already point at an older strict room, and let `dm.sessionScope: "per-room"` promote safe unmapped strict rooms through the existing unnamed/unaliased room gate. Fixes #79514. Thanks @stainlu.
- Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66.
- Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou.
- Control UI/subagents: suppress internal `subagent_announce` handoff prompts from requester transcripts and hide legacy inter-session wrapper rows so completed subagent results no longer surface runtime context in WebChat history. (#79618) Thanks @joshavant.

View File

@@ -47,6 +47,24 @@ describe("inspectMatrixDirectRooms", () => {
]);
});
it("still surfaces joined strict rooms when an older mapped room is strict", async () => {
const client = createClient({
getAccountData: vi.fn(async () => ({
"@alice:example.org": ["!older:example.org"],
})),
getJoinedRooms: vi.fn(async () => ["!older:example.org", "!fresh:example.org"]),
getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]),
});
const result = await inspectMatrixDirectRooms({
client,
remoteUserId: "@alice:example.org",
});
expect(result.activeRoomId).toBe("!older:example.org");
expect(result.discoveredStrictRoomIds).toEqual(["!fresh:example.org"]);
});
it("falls back to discovered strict joined rooms when m.direct is stale", async () => {
const client = createClient({
getAccountData: vi.fn(async () => ({

View File

@@ -277,7 +277,7 @@ export async function inspectMatrixDirectRooms(params: {
const mappedStrict = mappedRooms.find((room) => room.strict);
let joinedRooms: string[] = [];
if (!mappedStrict && typeof params.client.getJoinedRooms === "function") {
if (typeof params.client.getJoinedRooms === "function") {
try {
const resolved = await params.client.getJoinedRooms();
joinedRooms = Array.isArray(resolved) ? resolved : [];

View File

@@ -128,6 +128,43 @@ describe("createDirectRoomTracker", () => {
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
});
it("promotes strict unmapped rooms when the per-room fallback gate allows it", async () => {
const client = createMockClient({ isDm: false, dmCacheAvailable: true });
const tracker = createDirectRoomTracker(client, {
canPromoteUnmappedStrictRoom: () => true,
});
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
expect(client.setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({
"@alice:example.org": ["!room:example.org"],
}),
);
});
it("does not promote strict unmapped rooms when the per-room fallback gate vetoes it", async () => {
const client = createMockClient({ isDm: false, dmCacheAvailable: true });
const tracker = createDirectRoomTracker(client, {
canPromoteUnmappedStrictRoom: () => false,
});
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("falls back to strict 2-member membership before m.direct account data is available", async () => {
const client = createMockClient({ isDm: false, dmCacheAvailable: false });
const tracker = createDirectRoomTracker(client);

View File

@@ -15,6 +15,7 @@ type DirectMessageCheck = {
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
shouldKeepLocallyPromotedDirectRoom?:
| ((roomId: string) => boolean | undefined | Promise<boolean | undefined>)
| undefined;
@@ -141,6 +142,15 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
}
};
const canPromoteUnmappedStrictRoom = async (roomId: string): Promise<boolean> => {
try {
return (await opts.canPromoteUnmappedStrictRoom?.(roomId)) ?? false;
} catch (err) {
log(`matrix: unmapped strict room promotion veto failed room=${roomId} (${String(err)})`);
return false;
}
};
const shouldKeepLocallyPromotedDirectRoom = async (
roomId: string,
): Promise<boolean | undefined> => {
@@ -259,6 +269,22 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
return true;
}
}
if (await canPromoteUnmappedStrictRoom(roomId)) {
const promotion = await promoteMatrixDirectRoomCandidate({
client,
remoteUserId: senderId ?? "",
roomId,
selfUserId,
});
if (promotion.classifyAsDirect) {
rememberLocallyPromotedDirectRoom(roomId, senderId ?? "");
log(
`matrix: dm detected via per-room strict fallback room=${roomId} reason=${promotion.reason} repaired=${String(promotion.repaired)}`,
);
return true;
}
}
}
log(

View File

@@ -5,6 +5,7 @@ import type { MatrixRoomInfo } from "./room-info.js";
type DirectRoomTrackerOptions = {
canPromoteRecentInvite?: (roomId: string) => boolean | Promise<boolean>;
canPromoteUnmappedStrictRoom?: (roomId: string) => boolean | Promise<boolean>;
shouldKeepLocallyPromotedDirectRoom?:
| ((roomId: string) => boolean | undefined | Promise<boolean | undefined>)
| undefined;
@@ -981,6 +982,40 @@ describe("monitorMatrixProvider", () => {
await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false);
});
it("does not wire unmapped strict room promotion for per-user DM scope", async () => {
await startMonitorAndAbortAfterStartup();
const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
expect(trackerOpts?.canPromoteUnmappedStrictRoom).toBeUndefined();
});
it("wires per-room unmapped strict room promotion through the room metadata gate", async () => {
hoisted.accountConfig.dm = { sessionScope: "per-room" };
await startMonitorAndAbortAfterStartup();
const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1];
if (!trackerOpts?.canPromoteUnmappedStrictRoom) {
throw new Error("per-room strict fallback callback was not wired");
}
hoisted.getRoomInfo.mockResolvedValueOnce({
altAliases: [],
nameResolved: true,
aliasesResolved: true,
});
await expect(trackerOpts.canPromoteUnmappedStrictRoom("!dm:example.org")).resolves.toBe(true);
hoisted.getRoomInfo.mockResolvedValueOnce({
name: "Ops Room",
altAliases: [],
nameResolved: true,
aliasesResolved: true,
});
await expect(trackerOpts.canPromoteUnmappedStrictRoom("!ops:example.org")).resolves.toBe(false);
});
it("treats unresolved room metadata as indeterminate for local promotion revalidation", async () => {
await startMonitorAndAbortAfterStartup();

View File

@@ -353,6 +353,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
roomInfo: await getRoomInfo(roomId, { includeAliases: true }),
rooms: roomsConfig,
}),
...(dmSessionScope === "per-room"
? {
canPromoteUnmappedStrictRoom: async (roomId) =>
shouldPromoteRecentInviteRoom({
roomId,
roomInfo: await getRoomInfo(roomId, { includeAliases: true }),
rooms: roomsConfig,
}),
}
: {}),
shouldKeepLocallyPromotedDirectRoom: async (roomId) => {
try {
const roomInfo = await getRoomInfo(roomId, { includeAliases: true });