From b3f894ea7ebb8dadef34b1c647914217c5c40c50 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 30 Mar 2026 22:30:47 -0400 Subject: [PATCH] fix(matrix): repair fresh invited DMs (#58024) Merged via squash. Prepared head SHA: 69b52296329861453f7a75b8e87cd0645a4051da Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../matrix/src/matrix/actions/summary.ts | 6 +- .../src/matrix/direct-management.test.ts | 150 +++++++++++++++- .../matrix/src/matrix/direct-management.ts | 160 +++++++++++++++--- extensions/matrix/src/matrix/errors.ts | 10 ++ .../src/matrix/monitor/auto-join.test.ts | 29 +++- .../matrix/src/matrix/monitor/direct.test.ts | 127 +++++++++++++- .../matrix/src/matrix/monitor/direct.ts | 55 ++++++ .../matrix/src/matrix/monitor/events.test.ts | 69 ++++++++ .../matrix/src/matrix/monitor/events.ts | 7 + .../matrix/src/matrix/monitor/index.test.ts | 139 +++++++++++++-- extensions/matrix/src/matrix/monitor/index.ts | 13 +- .../src/matrix/monitor/recent-invite.test.ts | 92 ++++++++++ .../src/matrix/monitor/recent-invite.ts | 30 ++++ .../src/matrix/monitor/room-info.test.ts | 115 ++++++++++++- .../matrix/src/matrix/monitor/room-info.ts | 59 ++++--- extensions/matrix/src/matrix/sdk.ts | 12 +- 17 files changed, 992 insertions(+), 82 deletions(-) create mode 100644 extensions/matrix/src/matrix/errors.ts create mode 100644 extensions/matrix/src/matrix/monitor/recent-invite.test.ts create mode 100644 extensions/matrix/src/matrix/monitor/recent-invite.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6abb12e7857..0fd9b6d5f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ Docs: https://docs.openclaw.ai - Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc. - Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc. - Telegram/audio: transcode Telegram voice-note `.ogg` attachments before the local `whisper-cli` auto fallback runs, and keep mention-preflight transcription enabled in auto mode when `tools.media.audio` is unset. +- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras. ## 2026.3.28 diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index 69a3a76715d..7cce088199d 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,3 +1,4 @@ +import { isMatrixNotFoundError } from "../errors.js"; import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js"; import { fetchMatrixPollMessageSummary } from "../poll-summary.js"; import type { MatrixClient } from "../sdk.js"; @@ -58,10 +59,7 @@ export async function readPinnedEvents(client: MatrixClient, roomId: string): Pr const pinned = content.pinned; return pinned.filter((id) => id.trim().length > 0); } catch (err: unknown) { - const errObj = err as { statusCode?: number; body?: { errcode?: string } }; - const httpStatus = errObj.statusCode; - const errcode = errObj.body?.errcode; - if (httpStatus === 404 || errcode === "M_NOT_FOUND") { + if (isMatrixNotFoundError(err)) { return []; } throw err; diff --git a/extensions/matrix/src/matrix/direct-management.test.ts b/extensions/matrix/src/matrix/direct-management.test.ts index 42b12fabe4e..ddf8e5d7c6c 100644 --- a/extensions/matrix/src/matrix/direct-management.test.ts +++ b/extensions/matrix/src/matrix/direct-management.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { inspectMatrixDirectRooms, repairMatrixDirectRooms } from "./direct-management.js"; +import { + inspectMatrixDirectRooms, + persistMatrixDirectRoomMapping, + promoteMatrixDirectRoomCandidate, + repairMatrixDirectRooms, +} from "./direct-management.js"; import type { MatrixClient } from "./sdk.js"; import { EventType } from "./send/types.js"; @@ -198,3 +203,146 @@ describe("repairMatrixDirectRooms", () => { ).rejects.toThrow('Matrix user IDs must be fully qualified (got "alice")'); }); }); + +describe("promoteMatrixDirectRoomCandidate", () => { + it("classifies a strict room as direct and repairs m.direct", async () => { + const setAccountData = vi.fn(async () => undefined); + const client = createClient({ + getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]), + setAccountData, + }); + + const result = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: "@alice:example.org", + roomId: "!fresh:example.org", + }); + + expect(result).toEqual({ + classifyAsDirect: true, + repaired: true, + roomId: "!fresh:example.org", + reason: "promoted", + }); + expect(setAccountData).toHaveBeenCalledWith( + EventType.Direct, + expect.objectContaining({ + "@alice:example.org": ["!fresh:example.org"], + }), + ); + }); + + it("does not classify rooms with local is_direct false as direct", async () => { + const setAccountData = vi.fn(async () => undefined); + const client = createClient({ + getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]), + getRoomStateEvent: vi.fn(async (_roomId: string, _eventType: string, stateKey: string) => + stateKey === "@bot:example.org" ? { is_direct: false } : {}, + ), + setAccountData, + }); + + const result = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: "@alice:example.org", + roomId: "!blocked:example.org", + }); + + expect(result).toEqual({ + classifyAsDirect: false, + repaired: false, + reason: "local-explicit-false", + }); + expect(setAccountData).not.toHaveBeenCalled(); + }); + + it("returns already-mapped without rewriting account data", async () => { + const setAccountData = vi.fn(async () => undefined); + const client = createClient({ + getAccountData: vi.fn(async () => ({ + "@alice:example.org": ["!mapped:example.org", "!older:example.org"], + })), + getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]), + setAccountData, + }); + + const result = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: "@alice:example.org", + roomId: "!mapped:example.org", + }); + + expect(result).toEqual({ + classifyAsDirect: true, + repaired: false, + roomId: "!mapped:example.org", + reason: "already-mapped", + }); + expect(setAccountData).not.toHaveBeenCalled(); + }); + + it("still classifies the room as direct when repair fails", async () => { + const client = createClient({ + getJoinedRoomMembers: vi.fn(async () => ["@bot:example.org", "@alice:example.org"]), + setAccountData: vi.fn(async () => { + throw new Error("account data unavailable"); + }), + }); + + const result = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: "@alice:example.org", + roomId: "!fresh:example.org", + }); + + expect(result).toEqual({ + classifyAsDirect: true, + repaired: false, + roomId: "!fresh:example.org", + reason: "repair-failed", + }); + }); + + it("serializes concurrent m.direct writes so distinct mappings are not lost", async () => { + let directContent: Record = {}; + let releaseFirstWrite: (() => void) | null = null; + const firstWriteStarted = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + let writeCount = 0; + const setAccountData = vi.fn(async (_eventType: string, content: Record) => { + writeCount += 1; + if (writeCount === 1) { + await firstWriteStarted; + } + directContent = { ...content }; + }); + const client = createClient({ + getAccountData: vi.fn(async () => ({ ...directContent })), + setAccountData, + }); + + const firstWrite = persistMatrixDirectRoomMapping({ + client, + remoteUserId: "@alice:example.org", + roomId: "!alice:example.org", + }); + await vi.waitFor(() => { + expect(setAccountData).toHaveBeenCalledTimes(1); + }); + + const secondWrite = persistMatrixDirectRoomMapping({ + client, + remoteUserId: "@bob:example.org", + roomId: "!bob:example.org", + }); + + releaseFirstWrite?.(); + await expect(Promise.all([firstWrite, secondWrite])).resolves.toEqual([true, true]); + + expect(directContent).toEqual({ + "@alice:example.org": ["!alice:example.org"], + "@bob:example.org": ["!bob:example.org"], + }); + }); +}); diff --git a/extensions/matrix/src/matrix/direct-management.ts b/extensions/matrix/src/matrix/direct-management.ts index db63018c62b..fc0de7a5dee 100644 --- a/extensions/matrix/src/matrix/direct-management.ts +++ b/extensions/matrix/src/matrix/direct-management.ts @@ -1,3 +1,4 @@ +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/core"; import { inspectMatrixDirectRoomEvidence } from "./direct-room.js"; import type { MatrixClient } from "./sdk.js"; import { EventType, type MatrixDirectAccountData } from "./send/types.js"; @@ -27,6 +28,28 @@ export type MatrixDirectRoomRepairResult = MatrixDirectRoomInspection & { directContentAfter: MatrixDirectAccountData; }; +export type MatrixDirectRoomPromotionResult = + | { + classifyAsDirect: true; + repaired: boolean; + roomId: string; + reason: "promoted" | "already-mapped" | "repair-failed"; + } + | { + classifyAsDirect: false; + repaired: false; + reason: "not-strict" | "local-explicit-false"; + }; + +type MatrixDirectRoomMappingWriteResult = { + changed: boolean; + directContentBefore: MatrixDirectAccountData; + directContentAfter: MatrixDirectAccountData; +}; + +const DIRECT_ACCOUNT_DATA_QUEUE_KEY = EventType.Direct; +const directAccountDataWriteQueues = new WeakMap(); + async function readMatrixDirectAccountData(client: MatrixClient): Promise { try { const direct = (await client.getAccountData(EventType.Direct)) as MatrixDirectAccountData; @@ -76,6 +99,55 @@ function normalizeRoomIdList(values: readonly string[]): string[] { return normalized; } +function hasPrimaryMatrixDirectRoomMapping(params: { + directContent: MatrixDirectAccountData; + remoteUserId: string; + roomId: string; +}): boolean { + return normalizeMappedRoomIds(params.directContent, params.remoteUserId)[0] === params.roomId; +} + +function resolveDirectAccountDataWriteQueue(client: MatrixClient): KeyedAsyncQueue { + const existing = directAccountDataWriteQueues.get(client); + if (existing) { + return existing; + } + const created = new KeyedAsyncQueue(); + directAccountDataWriteQueues.set(client, created); + return created; +} + +async function writeMatrixDirectRoomMapping(params: { + client: MatrixClient; + remoteUserId: string; + roomId: string; +}): Promise { + return await resolveDirectAccountDataWriteQueue(params.client).enqueue( + DIRECT_ACCOUNT_DATA_QUEUE_KEY, + async () => { + const directContentBefore = await readMatrixDirectAccountData(params.client); + const directContentAfter = buildNextDirectContent({ + directContent: directContentBefore, + remoteUserId: params.remoteUserId, + roomId: params.roomId, + }); + const changed = !hasPrimaryMatrixDirectRoomMapping({ + directContent: directContentBefore, + remoteUserId: params.remoteUserId, + roomId: params.roomId, + }); + if (changed) { + await params.client.setAccountData(EventType.Direct, directContentAfter); + } + return { + changed, + directContentBefore, + directContentAfter, + }; + }, + ); +} + async function classifyDirectRoomCandidate(params: { client: MatrixClient; roomId: string; @@ -121,20 +193,63 @@ export async function persistMatrixDirectRoomMapping(params: { roomId: string; }): Promise { const remoteUserId = normalizeRemoteUserId(params.remoteUserId); - const directContent = await readMatrixDirectAccountData(params.client); - const current = normalizeMappedRoomIds(directContent, remoteUserId); - if (current[0] === params.roomId) { - return false; - } - await params.client.setAccountData( - EventType.Direct, - buildNextDirectContent({ - directContent, + return ( + await writeMatrixDirectRoomMapping({ + client: params.client, remoteUserId, roomId: params.roomId, - }), - ); - return true; + }) + ).changed; +} + +export async function promoteMatrixDirectRoomCandidate(params: { + client: MatrixClient; + remoteUserId: string; + roomId: string; + selfUserId?: string | null; +}): Promise { + const remoteUserId = normalizeRemoteUserId(params.remoteUserId); + const evidence = await inspectMatrixDirectRoomEvidence({ + client: params.client, + roomId: params.roomId, + remoteUserId, + selfUserId: params.selfUserId, + }); + if (!evidence.strict) { + return { + classifyAsDirect: false, + repaired: false, + reason: "not-strict", + }; + } + if (evidence.memberStateFlag === false) { + return { + classifyAsDirect: false, + repaired: false, + reason: "local-explicit-false", + }; + } + + try { + const repaired = await persistMatrixDirectRoomMapping({ + client: params.client, + remoteUserId, + roomId: params.roomId, + }); + return { + classifyAsDirect: true, + repaired, + roomId: params.roomId, + reason: repaired ? "promoted" : "already-mapped", + }; + } catch { + return { + classifyAsDirect: true, + repaired: false, + roomId: params.roomId, + reason: "repair-failed", + }; + } } export async function inspectMatrixDirectRooms(params: { @@ -204,7 +319,6 @@ export async function repairMatrixDirectRooms(params: { encrypted?: boolean; }): Promise { const remoteUserId = normalizeRemoteUserId(params.remoteUserId); - const directContentBefore = await readMatrixDirectAccountData(params.client); const inspected = await inspectMatrixDirectRooms({ client: params.client, remoteUserId, @@ -215,27 +329,17 @@ export async function repairMatrixDirectRooms(params: { encrypted: params.encrypted === true, })); const createdRoomId = inspected.activeRoomId ? null : activeRoomId; - const directContentAfter = buildNextDirectContent({ - directContent: directContentBefore, + const mappingWrite = await writeMatrixDirectRoomMapping({ + client: params.client, remoteUserId, roomId: activeRoomId, }); - const changed = - JSON.stringify(directContentAfter[remoteUserId] ?? []) !== - JSON.stringify(directContentBefore[remoteUserId] ?? []); - if (changed) { - await persistMatrixDirectRoomMapping({ - client: params.client, - remoteUserId, - roomId: activeRoomId, - }); - } return { ...inspected, activeRoomId, createdRoomId, - changed, - directContentBefore, - directContentAfter, + changed: mappingWrite.changed, + directContentBefore: mappingWrite.directContentBefore, + directContentAfter: mappingWrite.directContentAfter, }; } diff --git a/extensions/matrix/src/matrix/errors.ts b/extensions/matrix/src/matrix/errors.ts new file mode 100644 index 00000000000..2e478be9792 --- /dev/null +++ b/extensions/matrix/src/matrix/errors.ts @@ -0,0 +1,10 @@ +export function isMatrixNotFoundError(err: unknown): boolean { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") { + return true; + } + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return ( + message.includes("m_not_found") || message.includes("[404]") || message.includes("not found") + ); +} diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts index d5faf07bfd3..32c534d990d 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.test.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts @@ -54,10 +54,13 @@ function registerAutoJoinHarness(params: { return harness; } -async function triggerInvite(getInviteHandler: () => InviteHandler | null) { +async function triggerInvite( + getInviteHandler: () => InviteHandler | null, + inviteEvent: unknown = {}, +) { const inviteHandler = getInviteHandler(); expect(inviteHandler).toBeTruthy(); - await inviteHandler!("!room:example.org", {}); + await inviteHandler!("!room:example.org", inviteEvent); } describe("registerMatrixAutoJoin", () => { @@ -175,4 +178,26 @@ describe("registerMatrixAutoJoin", () => { await triggerInvite(getInviteHandler); expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); }); + + it("joins sender-scoped invites without eager direct repair", async () => { + const { getInviteHandler, joinRoom } = registerAutoJoinHarness({ + accountConfig: { + autoJoin: "always", + }, + }); + + await triggerInvite(getInviteHandler, { sender: "@alice:example.org" }); + + expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); + }); + + it("still joins invites when the sender is unavailable", async () => { + const { getInviteHandler } = registerAutoJoinHarness({ + accountConfig: { + autoJoin: "always", + }, + }); + + await expect(triggerInvite(getInviteHandler, {})).resolves.toBeUndefined(); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index ded86edc496..8fb05b2d98c 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { MatrixClient } from "../sdk.js"; +import { EventType } from "../send/types.js"; import { createDirectRoomTracker } from "./direct.js"; type MockStateEvents = Record>; @@ -9,15 +10,27 @@ function createMockClient(params: { members?: string[]; stateEvents?: MockStateEvents; dmCacheAvailable?: boolean; + directAccountData?: Record; + setAccountDataError?: Error; }) { let members = params.members ?? ["@alice:example.org", "@bot:example.org"]; const stateEvents = params.stateEvents ?? {}; + let directAccountData = params.directAccountData ?? {}; + const dmRoomIds = new Set(); + if (params.isDm === true) { + dmRoomIds.add("!room:example.org"); + } return { dms: { update: vi.fn().mockResolvedValue(params.dmCacheAvailable !== false), - isDm: vi.fn().mockReturnValue(params.isDm === true), + isDm: vi.fn().mockImplementation((roomId: string) => dmRoomIds.has(roomId)), }, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getAccountData: vi + .fn() + .mockImplementation(async (eventType: string) => + eventType === EventType.Direct ? directAccountData : undefined, + ), getJoinedRoomMembers: vi.fn().mockImplementation(async () => members), getRoomStateEvent: vi .fn() @@ -29,6 +42,26 @@ function createMockClient(params: { } return state; }), + setAccountData: vi.fn().mockImplementation(async (eventType: string, content: unknown) => { + if (params.setAccountDataError) { + throw params.setAccountDataError; + } + if (eventType !== EventType.Direct) { + return; + } + directAccountData = (content as Record) ?? {}; + dmRoomIds.clear(); + for (const value of Object.values(directAccountData)) { + if (!Array.isArray(value)) { + continue; + } + for (const roomId of value) { + if (typeof roomId === "string" && roomId.trim()) { + dmRoomIds.add(roomId); + } + } + } + }), __setMembers(next: string[]) { members = next; }, @@ -37,8 +70,10 @@ function createMockClient(params: { update: ReturnType; isDm: ReturnType; }; + getAccountData: ReturnType; getJoinedRoomMembers: ReturnType; getRoomStateEvent: ReturnType; + setAccountData: ReturnType; __setMembers: (members: string[]) => void; }; } @@ -198,6 +233,96 @@ describe("createDirectRoomTracker", () => { ).resolves.toBe(false); }); + it("treats strict rooms from recent invites as DMs after the dm cache has seeded", async () => { + const client = createMockClient({ isDm: false, dmCacheAvailable: true }); + const tracker = createDirectRoomTracker(client); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + + 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("keeps recent invite candidates across room invalidation", async () => { + const client = createMockClient({ isDm: false, dmCacheAvailable: true }); + const tracker = createDirectRoomTracker(client); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + tracker.invalidateRoom("!room:example.org"); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(true); + }); + + it("still rejects recent invite candidates when self member state is_direct is false", async () => { + const client = createMockClient({ + isDm: false, + dmCacheAvailable: true, + stateEvents: { + "!room:example.org|m.room.member|@bot:example.org": { is_direct: false }, + }, + }); + const tracker = createDirectRoomTracker(client); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(false); + }); + + it("does not promote recent invite candidates when local vetoes mark the room as non-DM", async () => { + const client = createMockClient({ + isDm: false, + dmCacheAvailable: true, + }); + const tracker = createDirectRoomTracker(client, { + canPromoteRecentInvite: () => false, + }); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(false); + + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("still treats recent invite candidates as DMs when m.direct repair fails", async () => { + const client = createMockClient({ + isDm: false, + dmCacheAvailable: true, + setAccountDataError: new Error("account data unavailable"), + }); + const tracker = createDirectRoomTracker(client); + tracker.rememberInvite("!room:example.org", "@alice:example.org"); + + await expect( + tracker.isDirectMessage({ + roomId: "!room:example.org", + senderId: "@alice:example.org", + }), + ).resolves.toBe(true); + }); + it("does not classify 2-member rooms whose sender is not a joined member when falling back", async () => { const client = createMockClient({ isDm: false, diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index 39e21fccab1..04c8eae4359 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -1,3 +1,4 @@ +import { promoteMatrixDirectRoomCandidate } from "../direct-management.js"; import { hasDirectMatrixMemberFlag, isStrictDirectMembership, @@ -13,9 +14,11 @@ type DirectMessageCheck = { type DirectRoomTrackerOptions = { log?: (message: string) => void; + canPromoteRecentInvite?: (roomId: string) => boolean | Promise; }; const DM_CACHE_TTL_MS = 30_000; +const RECENT_INVITE_TTL_MS = 30_000; const MAX_TRACKED_DM_ROOMS = 1024; const MAX_TRACKED_DM_MEMBER_FLAGS = 2048; @@ -38,6 +41,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr let cachedSelfUserId: string | null = null; const joinedMembersCache = new Map(); const directMemberFlagCache = new Map(); + const recentInviteCandidates = new Map(); const ensureSelfUserId = async (): Promise => { if (cachedSelfUserId) { @@ -98,6 +102,31 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr return isDirect; }; + const hasRecentInviteCandidate = (roomId: string, remoteUserId?: string | null): boolean => { + const normalizedRemoteUserId = remoteUserId?.trim(); + if (!normalizedRemoteUserId) { + return false; + } + const cached = recentInviteCandidates.get(roomId); + if (!cached) { + return false; + } + if (Date.now() - cached.ts >= RECENT_INVITE_TTL_MS) { + recentInviteCandidates.delete(roomId); + return false; + } + return cached.remoteUserId === normalizedRemoteUserId; + }; + + const canPromoteRecentInvite = async (roomId: string): Promise => { + try { + return (await opts.canPromoteRecentInvite?.(roomId)) ?? true; + } catch (err) { + log(`matrix: recent invite promotion veto failed room=${roomId} (${String(err)})`); + return false; + } + }; + return { invalidateRoom: (roomId: string): void => { joinedMembersCache.delete(roomId); @@ -109,6 +138,17 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr lastDmUpdateMs = 0; log(`matrix: invalidated dm cache room=${roomId}`); }, + rememberInvite: (roomId: string, remoteUserId: string): void => { + const normalizedRemoteUserId = remoteUserId.trim(); + if (!normalizedRemoteUserId) { + return; + } + rememberBounded(recentInviteCandidates, roomId, { + remoteUserId: normalizedRemoteUserId, + ts: Date.now(), + }); + log(`matrix: remembered invite candidate room=${roomId} sender=${normalizedRemoteUserId}`); + }, isDirectMessage: async (params: DirectMessageCheck): Promise => { const { roomId, senderId } = params; const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); @@ -150,6 +190,21 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr ); return true; } + + if (hasRecentInviteCandidate(roomId, senderId) && (await canPromoteRecentInvite(roomId))) { + const promotion = await promoteMatrixDirectRoomCandidate({ + client, + remoteUserId: senderId ?? "", + roomId, + selfUserId, + }); + if (promotion.classifyAsDirect) { + log( + `matrix: dm detected via recent invite room=${roomId} reason=${promotion.reason} repaired=${String(promotion.repaired)}`, + ); + return true; + } + } } log( diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index c3f1e5d1f94..625cc890192 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -71,6 +71,7 @@ function createHarness(params?: { ); const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice"); const invalidateRoom = vi.fn(); + const rememberInvite = vi.fn(); const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const formatNativeDependencyHint = vi.fn(() => "install hint"); const logVerboseMessage = vi.fn(); @@ -130,6 +131,7 @@ function createHarness(params?: { readStoreAllowFrom, directTracker: { invalidateRoom, + rememberInvite, }, logVerboseMessage, warnedEncryptedRooms: new Set(), @@ -148,6 +150,7 @@ function createHarness(params?: { onRoomMessage, sendMessage, invalidateRoom, + rememberInvite, roomEventListener, listVerifications, readStoreAllowFrom, @@ -161,6 +164,8 @@ function createHarness(params?: { verificationSummaryListener: listeners.get("verification.summary") as | VerificationSummaryListener | undefined, + roomInviteListener: listeners.get("room.invite") as RoomEventListener | undefined, + roomJoinListener: listeners.get("room.join") as RoomEventListener | undefined, }; } @@ -258,6 +263,70 @@ describe("registerMatrixMonitorEvents verification routing", () => { expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); }); + it("remembers invite provenance on room invites", async () => { + const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness(); + if (!roomInviteListener) { + throw new Error("room.invite listener was not registered"); + } + + roomInviteListener("!room:example.org", { + event_id: "$invite1", + sender: "@alice:example.org", + type: EventType.RoomMember, + origin_server_ts: Date.now(), + content: { + membership: "invite", + is_direct: true, + }, + state_key: "@bot:example.org", + }); + + expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); + expect(rememberInvite).toHaveBeenCalledWith("!room:example.org", "@alice:example.org"); + }); + + it("ignores lifecycle-only invite events emitted with self sender ids", async () => { + const { invalidateRoom, rememberInvite, roomInviteListener } = createHarness(); + if (!roomInviteListener) { + throw new Error("room.invite listener was not registered"); + } + + roomInviteListener("!room:example.org", { + event_id: "$invite-self", + sender: "@bot:example.org", + type: EventType.RoomMember, + origin_server_ts: Date.now(), + content: { + membership: "invite", + }, + state_key: "@bot:example.org", + }); + + expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); + expect(rememberInvite).not.toHaveBeenCalled(); + }); + + it("does not synthesize invite provenance from room joins", async () => { + const { invalidateRoom, rememberInvite, roomJoinListener } = createHarness(); + if (!roomJoinListener) { + throw new Error("room.join listener was not registered"); + } + + roomJoinListener("!room:example.org", { + event_id: "$join1", + sender: "@bot:example.org", + type: EventType.RoomMember, + origin_server_ts: Date.now(), + content: { + membership: "join", + }, + state_key: "@bot:example.org", + }); + + expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); + expect(rememberInvite).not.toHaveBeenCalled(); + }); + it("posts verification request notices directly into the room", async () => { const { onRoomMessage, sendMessage, roomMessageListener } = createHarness(); if (!roomMessageListener) { diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index bfb8f89fd45..048ecd3cb7f 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -40,6 +40,7 @@ export function registerMatrixMonitorEvents(params: { readStoreAllowFrom: () => Promise; directTracker?: { invalidateRoom: (roomId: string) => void; + rememberInvite?: (roomId: string, remoteUserId: string) => void; }; logVerboseMessage: (message: string) => void; warnedEncryptedRooms: Set; @@ -126,6 +127,12 @@ export function registerMatrixMonitorEvents(params: { directTracker?.invalidateRoom(roomId); const eventId = event?.event_id ?? "unknown"; const sender = event?.sender ?? "unknown"; + const invitee = typeof event?.state_key === "string" ? event.state_key.trim() : ""; + const senderIsInvitee = + typeof event?.sender === "string" && invitee && event.sender.trim() === invitee; + if (typeof event?.sender === "string" && event.sender.trim() && !senderIsInvitee) { + directTracker?.rememberInvite?.(roomId, event.sender); + } const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; logVerboseMessage( `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 471add87704..8672438c1f0 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -8,6 +8,9 @@ const hoisted = vi.hoisted(() => { const state = { startClientError: null as Error | null, }; + const accountConfig = { + dm: {}, + }; const inboundDeduper = { claimEvent: vi.fn(() => true), commitEvent: vi.fn(async () => undefined), @@ -22,6 +25,15 @@ const hoisted = vi.hoisted(() => { drainPendingDecryptions: vi.fn(async () => undefined), }; const createMatrixRoomMessageHandler = vi.fn(() => vi.fn()); + const createDirectRoomTracker = vi.fn(() => ({ + isDirectMessage: vi.fn(async () => false), + })); + const getRoomInfo = vi.fn(async () => ({ + altAliases: [], + nameResolved: true, + aliasesResolved: true, + })); + const getMemberDisplayName = vi.fn(async () => "Bot"); const resolveTextChunkLimit = vi.fn< (cfg: unknown, channel: unknown, accountId?: unknown) => number >(() => 4000); @@ -37,8 +49,12 @@ const hoisted = vi.hoisted(() => { const setMatrixRuntime = vi.fn(); return { callOrder, + accountConfig, client, + createDirectRoomTracker, createMatrixRoomMessageHandler, + getMemberDisplayName, + getRoomInfo, inboundDeduper, logger, registeredOnRoomMessage: null as null | ((roomId: string, event: unknown) => Promise), @@ -61,6 +77,7 @@ vi.mock("../../runtime-api.js", () => { MarkdownConfigSchema: z.any().optional(), PAIRING_APPROVED_MESSAGE: "paired", ToolPolicySchema: z.any().optional(), + addAllowlistUserEntriesFromConfigEntry: vi.fn(), buildChannelConfigSchema: (schema: unknown) => schema, buildChannelKeyCandidates: () => [], buildProbeChannelStatusSummary: ( @@ -93,7 +110,40 @@ vi.mock("../../runtime-api.js", () => { groupPolicy: "allowlist", providerMissingFallbackApplied: false, }), - resolveChannelEntryMatch: () => null, + resolveChannelEntryMatch: ({ + entries, + keys, + wildcardKey, + }: { + entries: Record; + keys: string[]; + wildcardKey: string; + }) => { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(entries, key)) { + return { + entry: entries[key], + key, + wildcardEntry: Object.prototype.hasOwnProperty.call(entries, wildcardKey) + ? entries[wildcardKey] + : undefined, + wildcardKey: Object.prototype.hasOwnProperty.call(entries, wildcardKey) + ? wildcardKey + : undefined, + }; + } + } + return { + entry: undefined, + key: undefined, + wildcardEntry: Object.prototype.hasOwnProperty.call(entries, wildcardKey) + ? entries[wildcardKey] + : undefined, + wildcardKey: Object.prototype.hasOwnProperty.call(entries, wildcardKey) + ? wildcardKey + : undefined, + }; + }, resolveDefaultGroupPolicy: () => "allowlist", resolveOutboundSendDep: () => null, resolveThreadBindingFarewellText: () => null, @@ -152,9 +202,7 @@ vi.mock("../accounts.js", async (importOriginal) => { resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set()), resolveMatrixAccount: () => ({ accountId: "default", - config: { - dm: {}, - }, + config: hoisted.accountConfig, }), }; }); @@ -234,9 +282,7 @@ vi.mock("./auto-join.js", () => ({ })); vi.mock("./direct.js", () => ({ - createDirectRoomTracker: vi.fn(() => ({ - isDirectMessage: vi.fn(async () => false), - })), + createDirectRoomTracker: hoisted.createDirectRoomTracker, })); vi.mock("./events.js", () => ({ @@ -262,10 +308,8 @@ vi.mock("./legacy-crypto-restore.js", () => ({ vi.mock("./room-info.js", () => ({ createMatrixRoomInfoResolver: vi.fn(() => ({ - getRoomInfo: vi.fn(async () => ({ - altAliases: [], - })), - getMemberDisplayName: vi.fn(async () => "Bot"), + getRoomInfo: hoisted.getRoomInfo, + getMemberDisplayName: hoisted.getMemberDisplayName, })), })); @@ -292,8 +336,19 @@ describe("monitorMatrixProvider", () => { beforeEach(() => { hoisted.callOrder.length = 0; hoisted.state.startClientError = null; + hoisted.accountConfig.dm = {}; + delete (hoisted.accountConfig as { rooms?: Record }).rooms; hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000); hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true); + hoisted.createDirectRoomTracker.mockReset().mockReturnValue({ + isDirectMessage: vi.fn(async () => false), + }); + hoisted.getRoomInfo.mockReset().mockResolvedValue({ + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + hoisted.getMemberDisplayName.mockReset().mockResolvedValue("Bot"); hoisted.registeredOnRoomMessage = null; hoisted.setActiveMatrixClient.mockReset(); hoisted.stopThreadBindingManager.mockReset(); @@ -437,6 +492,68 @@ describe("monitorMatrixProvider", () => { hoisted.callOrder.indexOf("release-client"), ); }); + + it("wires recent-invite promotion to fail closed when room metadata is unresolved", async () => { + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1] as + | { canPromoteRecentInvite?: (roomId: string) => Promise } + | undefined; + if (!trackerOpts?.canPromoteRecentInvite) { + throw new Error("recent invite promotion callback was not wired"); + } + + hoisted.getRoomInfo.mockResolvedValueOnce({ + altAliases: [], + nameResolved: false, + aliasesResolved: false, + }); + + await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false); + }); + + it("wires recent-invite promotion to reject named rooms", async () => { + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1] as + | { canPromoteRecentInvite?: (roomId: string) => Promise } + | undefined; + if (!trackerOpts?.canPromoteRecentInvite) { + throw new Error("recent invite promotion callback was not wired"); + } + + hoisted.getRoomInfo.mockResolvedValueOnce({ + name: "Ops Room", + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + + await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false); + }); + + it("wires recent-invite promotion to reject wildcard-configured rooms", async () => { + (hoisted.accountConfig as { rooms?: Record }).rooms = { + "*": { enabled: false }, + }; + + await startMonitorAndAbortAfterStartup(); + + const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1] as + | { canPromoteRecentInvite?: (roomId: string) => Promise } + | undefined; + if (!trackerOpts?.canPromoteRecentInvite) { + throw new Error("recent invite promotion callback was not wired"); + } + + hoisted.getRoomInfo.mockResolvedValueOnce({ + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }); + + await expect(trackerOpts.canPromoteRecentInvite("!room:example.org")).resolves.toBe(false); + }); }); describe("matrix plugin registration", () => { diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 6f06ebeb981..d695ae30013 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -26,6 +26,7 @@ import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js"; +import { shouldPromoteRecentInviteRoom } from "./recent-invite.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; import { runMatrixStartupMaintenance } from "./startup.js"; @@ -213,12 +214,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi // Cold starts should ignore old room history, but once we have a persisted // /sync cursor we want restart backlogs to replay just like other channels. const dropPreStartupMessages = !client.hasPersistedSyncState(); - const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); + const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); + const directTracker = createDirectRoomTracker(client, { + log: logVerboseMessage, + canPromoteRecentInvite: async (roomId) => + shouldPromoteRecentInviteRoom({ + roomId, + roomInfo: await getRoomInfo(roomId, { includeAliases: true }), + rooms: roomsConfig, + }), + }); registerMatrixAutoJoin({ client, accountConfig, runtime }); const warnedEncryptedRooms = new Set(); const warnedCryptoMissingRooms = new Set(); - const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); const handleRoomMessage = createMatrixRoomMessageHandler({ client, core, diff --git a/extensions/matrix/src/matrix/monitor/recent-invite.test.ts b/extensions/matrix/src/matrix/monitor/recent-invite.test.ts new file mode 100644 index 00000000000..90ce5f2c19d --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/recent-invite.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { shouldPromoteRecentInviteRoom } from "./recent-invite.js"; + +describe("shouldPromoteRecentInviteRoom", () => { + it("fails closed when room metadata could not be resolved", () => { + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!room:example.org", + roomInfo: { + altAliases: [], + nameResolved: false, + aliasesResolved: true, + }, + }), + ).toBe(false); + }); + + it("rejects named or aliased rooms", () => { + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!named:example.org", + roomInfo: { + name: "Ops Room", + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }, + }), + ).toBe(false); + + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!aliased:example.org", + roomInfo: { + canonicalAlias: "#ops:example.org", + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }, + }), + ).toBe(false); + }); + + it("rejects rooms explicitly configured by direct match", () => { + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!room:example.org", + roomInfo: { + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }, + rooms: { + "!room:example.org": { + enabled: true, + }, + }, + }), + ).toBe(false); + }); + + it("rejects rooms matched only by wildcard config", () => { + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!room:example.org", + roomInfo: { + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }, + rooms: { + "*": { + enabled: false, + }, + }, + }), + ).toBe(false); + }); + + it("allows strict unnamed invite rooms without direct room config", () => { + expect( + shouldPromoteRecentInviteRoom({ + roomId: "!room:example.org", + roomInfo: { + altAliases: [], + nameResolved: true, + aliasesResolved: true, + }, + }), + ).toBe(true); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/recent-invite.ts b/extensions/matrix/src/matrix/monitor/recent-invite.ts new file mode 100644 index 00000000000..cb40aa4a19b --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/recent-invite.ts @@ -0,0 +1,30 @@ +import type { MatrixRoomConfig } from "../../types.js"; +import type { MatrixRoomInfo } from "./room-info.js"; +import { resolveMatrixRoomConfig } from "./rooms.js"; + +export function shouldPromoteRecentInviteRoom(params: { + roomId: string; + roomInfo: Pick< + MatrixRoomInfo, + "name" | "canonicalAlias" | "altAliases" | "nameResolved" | "aliasesResolved" + >; + rooms?: Record; +}): boolean { + if (!params.roomInfo.nameResolved || !params.roomInfo.aliasesResolved) { + return false; + } + + const roomAliases = [params.roomInfo.canonicalAlias ?? "", ...params.roomInfo.altAliases].filter( + Boolean, + ); + if ((params.roomInfo.name?.trim() ?? "") || roomAliases.length > 0) { + return false; + } + + const roomConfig = resolveMatrixRoomConfig({ + rooms: params.rooms, + roomId: params.roomId, + aliases: roomAliases, + }); + return roomConfig.matchSource === undefined; +} diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts index f3ffb1768b3..bbeb34469ea 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.test.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -35,9 +35,22 @@ describe("createMatrixRoomInfoResolver", () => { const client = createClientStub(); const resolver = createMatrixRoomInfoResolver(client); + await expect(resolver.getRoomInfo("!room:example.org")).resolves.toEqual({ + name: "Room !room:example.org", + altAliases: [], + nameResolved: true, + aliasesResolved: false, + }); await resolver.getRoomInfo("!room:example.org"); - await resolver.getRoomInfo("!room:example.org"); - await resolver.getRoomInfo("!room:example.org", { includeAliases: true }); + await expect( + resolver.getRoomInfo("!room:example.org", { includeAliases: true }), + ).resolves.toEqual({ + name: "Room !room:example.org", + canonicalAlias: "#alias-!room:example.org:example.org", + altAliases: ["#alt-!room:example.org:example.org"], + nameResolved: true, + aliasesResolved: true, + }); await resolver.getRoomInfo("!room:example.org", { includeAliases: true }); await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); @@ -70,6 +83,104 @@ describe("createMatrixRoomInfoResolver", () => { expect(client.getRoomStateEvent).toHaveBeenCalledTimes(1); }); + it("marks unresolved room metadata when room info lookups fail", async () => { + const client = { + getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => { + if (eventType === "m.room.member") { + return {}; + } + throw new Error("room info unavailable"); + }), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; + const resolver = createMatrixRoomInfoResolver(client); + + await expect( + resolver.getRoomInfo("!room:example.org", { includeAliases: true }), + ).resolves.toEqual({ + altAliases: [], + aliasesResolved: false, + nameResolved: false, + }); + }); + + it("treats missing room metadata as resolved-empty state", async () => { + const client = { + getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => { + if (eventType === "m.room.name" || eventType === "m.room.canonical_alias") { + const err = new Error("M_NOT_FOUND"); + Object.assign(err, { + statusCode: 404, + body: { errcode: "M_NOT_FOUND" }, + }); + throw err; + } + return {}; + }), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; + const resolver = createMatrixRoomInfoResolver(client); + + await expect( + resolver.getRoomInfo("!room:example.org", { includeAliases: true }), + ).resolves.toEqual({ + altAliases: [], + aliasesResolved: true, + nameResolved: true, + }); + }); + + it("retries room metadata after a transient lookup failure", async () => { + const client = { + getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => { + if (eventType === "m.room.name") { + if ( + client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length === + 1 + ) { + throw new Error("name lookup unavailable"); + } + return { name: "Recovered Room" }; + } + if (eventType === "m.room.canonical_alias") { + if ( + client.getRoomStateEvent.mock.calls.filter(([, type]) => type === eventType).length === + 1 + ) { + throw new Error("alias lookup unavailable"); + } + return { + alias: "#recovered:example.org", + alt_aliases: ["#alt-recovered:example.org"], + }; + } + return {}; + }), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; + const resolver = createMatrixRoomInfoResolver(client); + + await expect( + resolver.getRoomInfo("!room:example.org", { includeAliases: true }), + ).resolves.toEqual({ + altAliases: [], + aliasesResolved: false, + nameResolved: false, + }); + await expect( + resolver.getRoomInfo("!room:example.org", { includeAliases: true }), + ).resolves.toEqual({ + name: "Recovered Room", + canonicalAlias: "#recovered:example.org", + altAliases: ["#alt-recovered:example.org"], + nameResolved: true, + aliasesResolved: true, + }); + }); + it("caches fallback user IDs when member display-name lookups fail", async () => { const client = { getRoomStateEvent: vi.fn(async (): Promise> => { diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 07af0df1a0f..c9871ed81e5 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -1,9 +1,12 @@ +import { isMatrixNotFoundError } from "../errors.js"; import type { MatrixClient } from "../sdk.js"; export type MatrixRoomInfo = { name?: string; canonicalAlias?: string; altAliases: string[]; + nameResolved: boolean; + aliasesResolved: boolean; }; const MAX_TRACKED_ROOM_INFO = 1024; @@ -20,40 +23,52 @@ function rememberBounded(map: Map, key: string, value: T, maxEntri } export function createMatrixRoomInfoResolver(client: MatrixClient) { - const roomNameCache = new Map(); - const roomAliasCache = new Map>(); + const roomNameCache = new Map>(); + const roomAliasCache = new Map< + string, + Pick + >(); const memberDisplayNameCache = new Map(); - const getRoomName = async (roomId: string): Promise => { + const getRoomName = async ( + roomId: string, + ): Promise> => { if (roomNameCache.has(roomId)) { - return roomNameCache.get(roomId); + return roomNameCache.get(roomId) ?? { nameResolved: false }; } let name: string | undefined; + let nameResolved = false; try { - const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null); + const nameState = await client.getRoomStateEvent(roomId, "m.room.name", ""); + nameResolved = true; if (nameState && typeof nameState.name === "string") { name = nameState.name; } - } catch { - // ignore + } catch (err) { + if (isMatrixNotFoundError(err)) { + nameResolved = true; + } } - rememberBounded(roomNameCache, roomId, name, MAX_TRACKED_ROOM_INFO); - return name; + const info = { name, nameResolved }; + if (nameResolved) { + rememberBounded(roomNameCache, roomId, info, MAX_TRACKED_ROOM_INFO); + } + return info; }; const getRoomAliases = async ( roomId: string, - ): Promise> => { + ): Promise> => { const cached = roomAliasCache.get(roomId); if (cached) { return cached; } let canonicalAlias: string | undefined; let altAliases: string[] = []; + let aliasesResolved = false; try { - const aliasState = await client - .getRoomStateEvent(roomId, "m.room.canonical_alias", "") - .catch(() => null); + const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", ""); + aliasesResolved = true; if (aliasState && typeof aliasState.alias === "string") { canonicalAlias = aliasState.alias; } @@ -61,11 +76,15 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { if (Array.isArray(rawAliases)) { altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string"); } - } catch { - // ignore + } catch (err) { + if (isMatrixNotFoundError(err)) { + aliasesResolved = true; + } + } + const info = { canonicalAlias, altAliases, aliasesResolved }; + if (aliasesResolved) { + rememberBounded(roomAliasCache, roomId, info, MAX_TRACKED_ROOM_INFO); } - const info = { canonicalAlias, altAliases }; - rememberBounded(roomAliasCache, roomId, info, MAX_TRACKED_ROOM_INFO); return info; }; @@ -73,12 +92,12 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { roomId: string, opts: { includeAliases?: boolean } = {}, ): Promise => { - const name = await getRoomName(roomId); + const { name, nameResolved } = await getRoomName(roomId); if (!opts.includeAliases) { - return { name, altAliases: [] }; + return { name, altAliases: [], nameResolved, aliasesResolved: false }; } const aliases = await getRoomAliases(roomId); - return { name, ...aliases }; + return { name, nameResolved, ...aliases }; }; const getMemberDisplayName = async (roomId: string, userId: string): Promise => { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index a5ec0498c2d..70aaf5291cd 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -16,6 +16,7 @@ import type { SsrFPolicy } from "../runtime-api.js"; import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js"; import { FileBackedMatrixSyncStore } from "./client/file-sync-store.js"; import { createMatrixJsSdkClientLogger } from "./client/logging.js"; +import { isMatrixNotFoundError } from "./errors.js"; import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js"; import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js"; @@ -145,17 +146,6 @@ function normalizeOptionalString(value: string | null | undefined): string | nul return normalized ? normalized : null; } -function isMatrixNotFoundError(err: unknown): boolean { - const errObj = err as { statusCode?: number; body?: { errcode?: string } }; - if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") { - return true; - } - const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); - return ( - message.includes("m_not_found") || message.includes("[404]") || message.includes("not found") - ); -} - function isUnsupportedAuthenticatedMediaEndpointError(err: unknown): boolean { const statusCode = (err as { statusCode?: number })?.statusCode; if (statusCode === 404 || statusCode === 405 || statusCode === 501) {