mirror of https://github.com/openclaw/openclaw.git
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:
parent
68e49fa791
commit
b3f894ea7e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>> => {
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue