fix(matrix): repair fresh invited DMs (#58024)

Merged via squash.

Prepared head SHA: 69b5229632
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-03-30 22:30:47 -04:00 committed by GitHub
parent 68e49fa791
commit b3f894ea7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 992 additions and 82 deletions

View File

@ -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

View File

@ -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;

View File

@ -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<string, string[]> = {};
let releaseFirstWrite: (() => void) | null = null;
const firstWriteStarted = new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
});
let writeCount = 0;
const setAccountData = vi.fn(async (_eventType: string, content: Record<string, string[]>) => {
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"],
});
});
});

View File

@ -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<MatrixClient, KeyedAsyncQueue>();
async function readMatrixDirectAccountData(client: MatrixClient): Promise<MatrixDirectAccountData> {
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<MatrixDirectRoomMappingWriteResult> {
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<boolean> {
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<MatrixDirectRoomPromotionResult> {
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<MatrixDirectRoomRepairResult> {
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,
};
}

View File

@ -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")
);
}

View File

@ -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();
});
});

View File

@ -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<string, Record<string, unknown>>;
@ -9,15 +10,27 @@ function createMockClient(params: {
members?: string[];
stateEvents?: MockStateEvents;
dmCacheAvailable?: boolean;
directAccountData?: Record<string, string[]>;
setAccountDataError?: Error;
}) {
let members = params.members ?? ["@alice:example.org", "@bot:example.org"];
const stateEvents = params.stateEvents ?? {};
let directAccountData = params.directAccountData ?? {};
const dmRoomIds = new Set<string>();
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<string, string[]>) ?? {};
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<typeof vi.fn>;
isDm: ReturnType<typeof vi.fn>;
};
getAccountData: ReturnType<typeof vi.fn>;
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
getRoomStateEvent: ReturnType<typeof vi.fn>;
setAccountData: ReturnType<typeof vi.fn>;
__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,

View File

@ -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<boolean>;
};
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<string, { members: string[]; ts: number }>();
const directMemberFlagCache = new Map<string, { isDirect: boolean | null; ts: number }>();
const recentInviteCandidates = new Map<string, { remoteUserId: string; ts: number }>();
const ensureSelfUserId = async (): Promise<string | null> => {
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<boolean> => {
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<boolean> => {
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(

View File

@ -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<string>(),
@ -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) {

View File

@ -40,6 +40,7 @@ export function registerMatrixMonitorEvents(params: {
readStoreAllowFrom: () => Promise<string[]>;
directTracker?: {
invalidateRoom: (roomId: string) => void;
rememberInvite?: (roomId: string, remoteUserId: string) => void;
};
logVerboseMessage: (message: string) => void;
warnedEncryptedRooms: Set<string>;
@ -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}`,

View File

@ -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<void>),
@ -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<string, unknown>;
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<string>()),
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<string, unknown> }).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<boolean> }
| 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<boolean> }
| 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<string, unknown> }).rooms = {
"*": { enabled: false },
};
await startMonitorAndAbortAfterStartup();
const trackerOpts = hoisted.createDirectRoomTracker.mock.calls[0]?.[1] as
| { canPromoteRecentInvite?: (roomId: string) => Promise<boolean> }
| 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", () => {

View File

@ -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<string>();
const warnedCryptoMissingRooms = new Set<string>();
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
const handleRoomMessage = createMatrixRoomMessageHandler({
client,
core,

View File

@ -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);
});
});

View File

@ -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<string, MatrixRoomConfig>;
}): 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;
}

View File

@ -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<typeof vi.fn>;
};
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<typeof vi.fn>;
};
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<typeof vi.fn>;
};
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<Record<string, unknown>> => {

View File

@ -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<T>(map: Map<string, T>, key: string, value: T, maxEntri
}
export function createMatrixRoomInfoResolver(client: MatrixClient) {
const roomNameCache = new Map<string, string | undefined>();
const roomAliasCache = new Map<string, Pick<MatrixRoomInfo, "canonicalAlias" | "altAliases">>();
const roomNameCache = new Map<string, Pick<MatrixRoomInfo, "name" | "nameResolved">>();
const roomAliasCache = new Map<
string,
Pick<MatrixRoomInfo, "canonicalAlias" | "altAliases" | "aliasesResolved">
>();
const memberDisplayNameCache = new Map<string, string>();
const getRoomName = async (roomId: string): Promise<string | undefined> => {
const getRoomName = async (
roomId: string,
): Promise<Pick<MatrixRoomInfo, "name" | "nameResolved">> => {
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<Pick<MatrixRoomInfo, "canonicalAlias" | "altAliases">> => {
): Promise<Pick<MatrixRoomInfo, "canonicalAlias" | "altAliases" | "aliasesResolved">> => {
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<MatrixRoomInfo> => {
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<string> => {

View File

@ -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) {