Matrix: productize direct room repair

This commit is contained in:
Gustavo Madeira Santana
2026-03-13 14:55:45 +00:00
parent 80be1bb356
commit 22bba37b4e
15 changed files with 652 additions and 62 deletions

View File

@@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/matrix";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { withResolvedActionClient, withStartedActionClient } from "./matrix/actions/client.js";
import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
@@ -21,6 +22,11 @@ import { resolveMatrixAuthContext } from "./matrix/client.js";
import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js";
import { isOpenClawManagedMatrixDevice } from "./matrix/device-health.js";
import {
inspectMatrixDirectRooms,
repairMatrixDirectRooms,
type MatrixDirectRoomCandidate,
} from "./matrix/direct-management.js";
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
@@ -309,6 +315,87 @@ async function addMatrixAccount(params: {
};
}
function printDirectRoomCandidate(room: MatrixCliDirectRoomCandidate): void {
const members =
room.joinedMembers === null ? "unavailable" : room.joinedMembers.join(", ") || "none";
console.log(
`- ${room.roomId} [${room.source}] strict=${room.strict ? "yes" : "no"} joined=${members}`,
);
}
function printDirectRoomInspection(result: MatrixCliDirectRoomInspection): void {
printAccountLabel(result.accountId);
console.log(`Peer: ${result.remoteUserId}`);
console.log(`Self: ${result.selfUserId ?? "unknown"}`);
console.log(`Active direct room: ${result.activeRoomId ?? "none"}`);
console.log(
`Mapped rooms: ${result.mappedRoomIds.length ? result.mappedRoomIds.join(", ") : "none"}`,
);
console.log(
`Discovered strict rooms: ${result.discoveredStrictRoomIds.length ? result.discoveredStrictRoomIds.join(", ") : "none"}`,
);
if (result.mappedRooms.length > 0) {
console.log("Mapped room details:");
for (const room of result.mappedRooms) {
printDirectRoomCandidate(room);
}
}
}
async function inspectMatrixDirectRoom(params: {
accountId: string;
userId: string;
}): Promise<MatrixCliDirectRoomInspection> {
return await withResolvedActionClient(
{ accountId: params.accountId },
async (client) => {
const inspection = await inspectMatrixDirectRooms({
client,
remoteUserId: params.userId,
});
return {
accountId: params.accountId,
remoteUserId: inspection.remoteUserId,
selfUserId: inspection.selfUserId,
mappedRoomIds: inspection.mappedRoomIds,
mappedRooms: inspection.mappedRooms.map(toCliDirectRoomCandidate),
discoveredStrictRoomIds: inspection.discoveredStrictRoomIds,
activeRoomId: inspection.activeRoomId,
};
},
"persist",
);
}
async function repairMatrixDirectRoom(params: {
accountId: string;
userId: string;
}): Promise<MatrixCliDirectRoomRepair> {
const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig;
const account = resolveMatrixAccount({ cfg, accountId: params.accountId });
return await withStartedActionClient({ accountId: params.accountId }, async (client) => {
const repaired = await repairMatrixDirectRooms({
client,
remoteUserId: params.userId,
encrypted: account.config.encryption === true,
});
return {
accountId: params.accountId,
remoteUserId: repaired.remoteUserId,
selfUserId: repaired.selfUserId,
mappedRoomIds: repaired.mappedRoomIds,
mappedRooms: repaired.mappedRooms.map(toCliDirectRoomCandidate),
discoveredStrictRoomIds: repaired.discoveredStrictRoomIds,
activeRoomId: repaired.activeRoomId,
encrypted: account.config.encryption === true,
createdRoomId: repaired.createdRoomId,
changed: repaired.changed,
directContentBefore: repaired.directContentBefore,
directContentAfter: repaired.directContentAfter,
};
});
}
type MatrixCliProfileSetResult = MatrixProfileUpdateResult;
async function setMatrixProfile(params: {
@@ -386,6 +473,40 @@ type MatrixCliVerificationStatus = {
pendingVerifications: number;
};
type MatrixCliDirectRoomCandidate = {
roomId: string;
source: "account-data" | "joined";
strict: boolean;
joinedMembers: string[] | null;
};
type MatrixCliDirectRoomInspection = {
accountId: string;
remoteUserId: string;
selfUserId: string | null;
mappedRoomIds: string[];
mappedRooms: MatrixCliDirectRoomCandidate[];
discoveredStrictRoomIds: string[];
activeRoomId: string | null;
};
type MatrixCliDirectRoomRepair = MatrixCliDirectRoomInspection & {
encrypted: boolean;
createdRoomId: string | null;
changed: boolean;
directContentBefore: Record<string, string[]>;
directContentAfter: Record<string, string[]>;
};
function toCliDirectRoomCandidate(room: MatrixDirectRoomCandidate): MatrixCliDirectRoomCandidate {
return {
roomId: room.roomId,
source: room.source,
strict: room.strict,
joinedMembers: room.joinedMembers,
};
}
function resolveBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
@@ -706,6 +827,71 @@ export function registerMatrixCli(params: { program: Command }): void {
},
);
const direct = root.command("direct").description("Inspect and repair Matrix direct-room state");
direct
.command("inspect")
.description("Inspect direct-room mappings for a Matrix user")
.requiredOption("--user-id <id>", "Peer Matrix user ID")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: { userId: string; account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await inspectMatrixDirectRoom({
accountId,
userId: options.userId,
}),
onText: (result) => {
printDirectRoomInspection(result);
},
errorPrefix: "Direct room inspection failed",
});
},
);
direct
.command("repair")
.description("Repair Matrix direct-room mappings for a Matrix user")
.requiredOption("--user-id <id>", "Peer Matrix user ID")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: { userId: string; account?: string; verbose?: boolean; json?: boolean }) => {
const accountId = resolveMatrixCliAccountId(options.account);
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await repairMatrixDirectRoom({
accountId,
userId: options.userId,
}),
onText: (result, verbose) => {
printDirectRoomInspection(result);
console.log(`Encrypted room creation: ${result.encrypted ? "enabled" : "disabled"}`);
console.log(`Created room: ${result.createdRoomId ?? "none"}`);
console.log(`m.direct updated: ${result.changed ? "yes" : "no"}`);
if (verbose) {
console.log(
`m.direct before: ${JSON.stringify(result.directContentBefore[result.remoteUserId] ?? [])}`,
);
console.log(
`m.direct after: ${JSON.stringify(result.directContentAfter[result.remoteUserId] ?? [])}`,
);
}
},
errorPrefix: "Direct room repair failed",
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify

View File

@@ -12,7 +12,7 @@ const {
getMatrixRuntimeMock,
getActiveMatrixClientMock,
resolveSharedMatrixClientMock,
stopSharedClientForAccountMock,
stopSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
@@ -32,7 +32,7 @@ vi.mock("../client.js", () => ({
}));
vi.mock("../client/shared.js", () => ({
stopSharedClientForAccount: (...args: unknown[]) => stopSharedClientForAccountMock(...args),
stopSharedClientInstance: (...args: unknown[]) => stopSharedClientInstanceMock(...args),
}));
vi.mock("../send.js", () => ({
@@ -74,9 +74,7 @@ describe("action client helpers", () => {
const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(sharedClient.stop).toHaveBeenCalledTimes(1);
expect(stopSharedClientForAccountMock).toHaveBeenCalledWith(
expect.objectContaining({ userId: "@bot:example.org" }),
);
expect(stopSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient);
expect(result).toBe("ok");
});
@@ -97,9 +95,7 @@ describe("action client helpers", () => {
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
expect(sharedClient.stop).not.toHaveBeenCalled();
expect(sharedClient.stopAndPersist).toHaveBeenCalledTimes(1);
expect(stopSharedClientForAccountMock).toHaveBeenCalledWith(
expect.objectContaining({ userId: "@bot:example.org" }),
);
expect(stopSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient);
});
it("reuses active monitor client when available", async () => {
@@ -201,9 +197,7 @@ describe("action client helpers", () => {
expect(result).toBe("ok");
expect(sharedClient.stop).toHaveBeenCalledTimes(1);
expect(sharedClient.stopAndPersist).not.toHaveBeenCalled();
expect(stopSharedClientForAccountMock).toHaveBeenCalledWith(
expect.objectContaining({ userId: "@bot:example.org" }),
);
expect(stopSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient);
});
it("stops shared action clients when the wrapped call throws", async () => {

View File

@@ -2,7 +2,7 @@ import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import { isBunRuntime, resolveMatrixAuthContext, resolveSharedMatrixClient } from "./client.js";
import { stopSharedClientForAccount } from "./client/shared.js";
import { stopSharedClientInstance } from "./client/shared.js";
import type { MatrixClient } from "./sdk.js";
type ResolvedRuntimeMatrixClient = {
@@ -78,7 +78,7 @@ async function resolveRuntimeMatrixClient(opts: {
} else {
client.stop();
}
stopSharedClientForAccount(authContext.resolved);
stopSharedClientInstance(client);
},
};
}

View File

@@ -6,7 +6,7 @@ type MatrixClientResolverMocks = {
getMatrixRuntimeMock: Mock<() => unknown>;
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
resolveSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
stopSharedClientForAccountMock: Mock<(...args: unknown[]) => void>;
stopSharedClientInstanceMock: Mock<(...args: unknown[]) => void>;
isBunRuntimeMock: Mock<() => boolean>;
resolveMatrixAuthContextMock: Mock<
(params: { cfg: unknown; accountId?: string | null }) => unknown
@@ -18,7 +18,7 @@ export const matrixClientResolverMocks: MatrixClientResolverMocks = {
getMatrixRuntimeMock: vi.fn(),
getActiveMatrixClientMock: vi.fn(),
resolveSharedMatrixClientMock: vi.fn(),
stopSharedClientForAccountMock: vi.fn(),
stopSharedClientInstanceMock: vi.fn(),
isBunRuntimeMock: vi.fn(() => false),
resolveMatrixAuthContextMock: vi.fn(),
};
@@ -44,7 +44,7 @@ export function primeMatrixClientResolverMocks(params?: {
getMatrixRuntimeMock,
getActiveMatrixClientMock,
resolveSharedMatrixClientMock,
stopSharedClientForAccountMock,
stopSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
@@ -70,7 +70,7 @@ export function primeMatrixClientResolverMocks(params?: {
});
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
stopSharedClientForAccountMock.mockReset();
stopSharedClientInstanceMock.mockReset();
resolveMatrixAuthContextMock.mockImplementation(
({
cfg: explicitCfg,

View File

@@ -10,4 +10,8 @@ export {
validateMatrixHomeserverUrl,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export { resolveSharedMatrixClient, stopSharedClientForAccount } from "./client/shared.js";
export {
resolveSharedMatrixClient,
stopSharedClientForAccount,
stopSharedClientInstance,
} from "./client/shared.js";

View File

@@ -18,6 +18,7 @@ import {
resolveSharedMatrixClient,
stopSharedClient,
stopSharedClientForAccount,
stopSharedClientInstance,
} from "./shared.js";
function authFor(accountId: string): MatrixAuth {
@@ -122,6 +123,26 @@ describe("resolveSharedMatrixClient", () => {
expect(poeClient.stop).toHaveBeenCalledTimes(1);
});
it("drops stopped shared clients by instance so the next resolve recreates them", async () => {
const mainAuth = authFor("main");
const firstMainClient = createMockClient("main-first");
const secondMainClient = createMockClient("main-second");
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
createMatrixClientMock
.mockResolvedValueOnce(firstMainClient)
.mockResolvedValueOnce(secondMainClient);
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
stopSharedClientInstance(first as unknown as import("../sdk.js").MatrixClient);
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
expect(first).toBe(firstMainClient);
expect(second).toBe(secondMainClient);
expect(firstMainClient.stop).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
});
it("reuses the effective implicit account instead of keying it as default", async () => {
const poeAuth = authFor("ops");
const poeClient = createMockClient("ops");

View File

@@ -193,3 +193,15 @@ export function stopSharedClientForAccount(auth: MatrixAuth): void {
sharedClientStates.delete(key);
sharedClientPromises.delete(key);
}
export function stopSharedClientInstance(client: MatrixClient): void {
for (const [key, state] of sharedClientStates.entries()) {
if (state.client !== client) {
continue;
}
state.client.stop();
sharedClientStates.delete(key);
sharedClientPromises.delete(key);
return;
}
}

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import { inspectMatrixDirectRooms, repairMatrixDirectRooms } from "./direct-management.js";
import type { MatrixClient } from "./sdk.js";
import { EventType } from "./send/types.js";
function createClient(overrides: Partial<MatrixClient> = {}): MatrixClient {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getAccountData: vi.fn(async () => undefined),
getJoinedRooms: vi.fn(async () => [] as string[]),
getJoinedRoomMembers: vi.fn(async () => [] as string[]),
setAccountData: vi.fn(async () => undefined),
createDirectRoom: vi.fn(async () => "!created:example.org"),
...overrides,
} as unknown as MatrixClient;
}
describe("inspectMatrixDirectRooms", () => {
it("prefers strict mapped rooms over discovered rooms", async () => {
const client = createClient({
getAccountData: vi.fn(async () => ({
"@alice:example.org": ["!dm:example.org", "!shared:example.org"],
})),
getJoinedRooms: vi.fn(async () => ["!dm:example.org", "!shared:example.org"]),
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
roomId === "!dm:example.org"
? ["@bot:example.org", "@alice:example.org"]
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
),
});
const result = await inspectMatrixDirectRooms({
client,
remoteUserId: "@alice:example.org",
});
expect(result.activeRoomId).toBe("!dm:example.org");
expect(result.mappedRooms).toEqual([
expect.objectContaining({ roomId: "!dm:example.org", strict: true }),
expect.objectContaining({ roomId: "!shared:example.org", strict: false }),
]);
});
it("falls back to discovered strict joined rooms when m.direct is stale", async () => {
const client = createClient({
getAccountData: vi.fn(async () => ({
"@alice:example.org": ["!stale:example.org"],
})),
getJoinedRooms: vi.fn(async () => ["!stale:example.org", "!fresh:example.org"]),
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
roomId === "!fresh:example.org"
? ["@bot:example.org", "@alice:example.org"]
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
),
});
const result = await inspectMatrixDirectRooms({
client,
remoteUserId: "@alice:example.org",
});
expect(result.activeRoomId).toBe("!fresh:example.org");
expect(result.discoveredStrictRoomIds).toEqual(["!fresh:example.org"]);
});
});
describe("repairMatrixDirectRooms", () => {
it("repoints m.direct to an existing strict joined room", async () => {
const setAccountData = vi.fn(async () => undefined);
const client = createClient({
getAccountData: vi.fn(async () => ({
"@alice:example.org": ["!stale:example.org"],
})),
getJoinedRooms: vi.fn(async () => ["!stale:example.org", "!fresh:example.org"]),
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
roomId === "!fresh:example.org"
? ["@bot:example.org", "@alice:example.org"]
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
),
setAccountData,
});
const result = await repairMatrixDirectRooms({
client,
remoteUserId: "@alice:example.org",
encrypted: true,
});
expect(result.activeRoomId).toBe("!fresh:example.org");
expect(result.createdRoomId).toBeNull();
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({
"@alice:example.org": ["!fresh:example.org", "!stale:example.org"],
}),
);
});
it("creates a fresh direct room when no healthy DM exists", async () => {
const createDirectRoom = vi.fn(async () => "!created:example.org");
const setAccountData = vi.fn(async () => undefined);
const client = createClient({
getJoinedRooms: vi.fn(async () => ["!shared:example.org"]),
getJoinedRoomMembers: vi.fn(async () => [
"@bot:example.org",
"@alice:example.org",
"@mallory:example.org",
]),
createDirectRoom,
setAccountData,
});
const result = await repairMatrixDirectRooms({
client,
remoteUserId: "@alice:example.org",
encrypted: true,
});
expect(createDirectRoom).toHaveBeenCalledWith("@alice:example.org", { encrypted: true });
expect(result.createdRoomId).toBe("!created:example.org");
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({
"@alice:example.org": ["!created:example.org"],
}),
);
});
it("rejects unqualified Matrix user ids", async () => {
const client = createClient();
await expect(
repairMatrixDirectRooms({
client,
remoteUserId: "alice",
}),
).rejects.toThrow('Matrix user IDs must be fully qualified (got "alice")');
});
});

View File

@@ -0,0 +1,211 @@
import {
isStrictDirectMembership,
isStrictDirectRoom,
readJoinedMatrixMembers,
} from "./direct-room.js";
import type { MatrixClient } from "./sdk.js";
import { EventType, type MatrixDirectAccountData } from "./send/types.js";
import { isMatrixQualifiedUserId } from "./target-ids.js";
export type MatrixDirectRoomCandidate = {
roomId: string;
joinedMembers: string[] | null;
strict: boolean;
source: "account-data" | "joined";
};
export type MatrixDirectRoomInspection = {
selfUserId: string | null;
remoteUserId: string;
mappedRoomIds: string[];
mappedRooms: MatrixDirectRoomCandidate[];
discoveredStrictRoomIds: string[];
activeRoomId: string | null;
};
export type MatrixDirectRoomRepairResult = MatrixDirectRoomInspection & {
createdRoomId: string | null;
changed: boolean;
directContentBefore: MatrixDirectAccountData;
directContentAfter: MatrixDirectAccountData;
};
async function readMatrixDirectAccountData(client: MatrixClient): Promise<MatrixDirectAccountData> {
try {
const direct = (await client.getAccountData(EventType.Direct)) as MatrixDirectAccountData;
return direct && typeof direct === "object" && !Array.isArray(direct) ? direct : {};
} catch {
return {};
}
}
function normalizeRemoteUserId(remoteUserId: string): string {
const normalized = remoteUserId.trim();
if (!isMatrixQualifiedUserId(normalized)) {
throw new Error(`Matrix user IDs must be fully qualified (got "${remoteUserId}")`);
}
return normalized;
}
function normalizeMappedRoomIds(direct: MatrixDirectAccountData, remoteUserId: string): string[] {
const current = direct[remoteUserId];
if (!Array.isArray(current)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of current) {
const roomId = typeof value === "string" ? value.trim() : "";
if (!roomId || seen.has(roomId)) {
continue;
}
seen.add(roomId);
normalized.push(roomId);
}
return normalized;
}
function normalizeRoomIdList(values: readonly string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
const roomId = value.trim();
if (!roomId || seen.has(roomId)) {
continue;
}
seen.add(roomId);
normalized.push(roomId);
}
return normalized;
}
async function classifyDirectRoomCandidate(params: {
client: MatrixClient;
roomId: string;
remoteUserId: string;
selfUserId: string | null;
source: "account-data" | "joined";
}): Promise<MatrixDirectRoomCandidate> {
const joinedMembers = await readJoinedMatrixMembers(params.client, params.roomId);
return {
roomId: params.roomId,
joinedMembers,
strict:
joinedMembers !== null &&
isStrictDirectMembership({
selfUserId: params.selfUserId,
remoteUserId: params.remoteUserId,
joinedMembers,
}),
source: params.source,
};
}
function buildNextDirectContent(params: {
directContent: MatrixDirectAccountData;
remoteUserId: string;
roomId: string;
}): MatrixDirectAccountData {
const current = normalizeMappedRoomIds(params.directContent, params.remoteUserId);
const nextRooms = normalizeRoomIdList([params.roomId, ...current]);
return {
...params.directContent,
[params.remoteUserId]: nextRooms,
};
}
export async function inspectMatrixDirectRooms(params: {
client: MatrixClient;
remoteUserId: string;
}): Promise<MatrixDirectRoomInspection> {
const remoteUserId = normalizeRemoteUserId(params.remoteUserId);
const selfUserId = (await params.client.getUserId().catch(() => null))?.trim() || null;
const directContent = await readMatrixDirectAccountData(params.client);
const mappedRoomIds = normalizeMappedRoomIds(directContent, remoteUserId);
const mappedRooms = await Promise.all(
mappedRoomIds.map(
async (roomId) =>
await classifyDirectRoomCandidate({
client: params.client,
roomId,
remoteUserId,
selfUserId,
source: "account-data",
}),
),
);
const mappedStrict = mappedRooms.find((room) => room.strict);
let joinedRooms: string[] = [];
if (!mappedStrict && typeof params.client.getJoinedRooms === "function") {
try {
const resolved = await params.client.getJoinedRooms();
joinedRooms = Array.isArray(resolved) ? resolved : [];
} catch {
joinedRooms = [];
}
}
const discoveredStrictRoomIds: string[] = [];
for (const roomId of normalizeRoomIdList(joinedRooms)) {
if (mappedRoomIds.includes(roomId)) {
continue;
}
if (
await isStrictDirectRoom({
client: params.client,
roomId,
remoteUserId,
selfUserId,
})
) {
discoveredStrictRoomIds.push(roomId);
}
}
return {
selfUserId,
remoteUserId,
mappedRoomIds,
mappedRooms,
discoveredStrictRoomIds,
activeRoomId: mappedStrict?.roomId ?? discoveredStrictRoomIds[0] ?? null,
};
}
export async function repairMatrixDirectRooms(params: {
client: MatrixClient;
remoteUserId: string;
encrypted?: boolean;
}): Promise<MatrixDirectRoomRepairResult> {
const remoteUserId = normalizeRemoteUserId(params.remoteUserId);
const directContentBefore = await readMatrixDirectAccountData(params.client);
const inspected = await inspectMatrixDirectRooms({
client: params.client,
remoteUserId,
});
const activeRoomId =
inspected.activeRoomId ??
(await params.client.createDirectRoom(remoteUserId, {
encrypted: params.encrypted === true,
}));
const createdRoomId = inspected.activeRoomId ? null : activeRoomId;
const directContentAfter = buildNextDirectContent({
directContent: directContentBefore,
remoteUserId,
roomId: activeRoomId,
});
const changed =
JSON.stringify(directContentAfter[remoteUserId] ?? []) !==
JSON.stringify(directContentBefore[remoteUserId] ?? []);
if (changed) {
await params.client.setAccountData(EventType.Direct, directContentAfter);
}
return {
...inspected,
activeRoomId,
createdRoomId,
changed,
directContentBefore,
directContentAfter,
};
}

View File

@@ -14,7 +14,7 @@ const hoisted = vi.hoisted(() => {
debug: vi.fn(),
};
const stopThreadBindingManager = vi.fn();
const stopSharedClientForAccount = vi.fn();
const stopSharedClientInstance = vi.fn();
const setActiveMatrixClient = vi.fn();
return {
callOrder,
@@ -23,7 +23,7 @@ const hoisted = vi.hoisted(() => {
resolveTextChunkLimit,
setActiveMatrixClient,
startClientError,
stopSharedClientForAccount,
stopSharedClientInstance,
stopThreadBindingManager,
};
});
@@ -123,7 +123,7 @@ vi.mock("../client.js", () => ({
hoisted.callOrder.push("start-client");
return hoisted.client;
}),
stopSharedClientForAccount: hoisted.stopSharedClientForAccount,
stopSharedClientInstance: hoisted.stopSharedClientInstance,
}));
vi.mock("../config-update.js", () => ({
@@ -203,7 +203,7 @@ describe("monitorMatrixProvider", () => {
hoisted.startClientError = null;
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
hoisted.setActiveMatrixClient.mockReset();
hoisted.stopSharedClientForAccount.mockReset();
hoisted.stopSharedClientInstance.mockReset();
hoisted.stopThreadBindingManager.mockReset();
Object.values(hoisted.logger).forEach((mock) => mock.mockReset());
});
@@ -245,7 +245,7 @@ describe("monitorMatrixProvider", () => {
await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
expect(hoisted.stopSharedClientForAccount).toHaveBeenCalledTimes(1);
expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1);
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
});

View File

@@ -17,7 +17,7 @@ import {
resolveMatrixAuth,
resolveMatrixAuthContext,
resolveSharedMatrixClient,
stopSharedClientForAccount,
stopSharedClientInstance,
} from "../client.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
@@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
try {
threadBindingManager?.stop();
} finally {
stopSharedClientForAccount(auth);
stopSharedClientInstance(client);
setActiveMatrixClient(null, auth.accountId);
}
};

View File

@@ -501,6 +501,30 @@ export class MatrixClient {
}
}
async createDirectRoom(
remoteUserId: string,
opts: { encrypted?: boolean } = {},
): Promise<string> {
const initialState = opts.encrypted
? [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
]
: undefined;
const result = await this.client.createRoom({
invite: [remoteUserId],
is_direct: true,
preset: "trusted_private_chat",
initial_state: initialState,
});
return result.room_id;
}
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
const sent = await this.client.sendMessage(roomId, content as never);
return sent.event_id;

View File

@@ -9,7 +9,7 @@ const {
getMatrixRuntimeMock,
getActiveMatrixClientMock,
resolveSharedMatrixClientMock,
stopSharedClientForAccountMock,
stopSharedClientInstanceMock,
isBunRuntimeMock,
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
@@ -25,7 +25,7 @@ vi.mock("../client.js", () => ({
}));
vi.mock("../client/shared.js", () => ({
stopSharedClientForAccount: (...args: unknown[]) => stopSharedClientForAccountMock(...args),
stopSharedClientInstance: (...args: unknown[]) => stopSharedClientInstanceMock(...args),
}));
vi.mock("../../runtime.js", () => ({
@@ -63,9 +63,7 @@ describe("withResolvedMatrixClient", () => {
const sharedClient = await resolveSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(sharedClient.stop).toHaveBeenCalledTimes(1);
expect(stopSharedClientForAccountMock).toHaveBeenCalledWith(
expect.objectContaining({ userId: "@bot:example.org" }),
);
expect(stopSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient);
expect(result).toBe("ok");
});
@@ -134,8 +132,6 @@ describe("withResolvedMatrixClient", () => {
).rejects.toThrow("boom");
expect(sharedClient.stop).toHaveBeenCalledTimes(1);
expect(stopSharedClientForAccountMock).toHaveBeenCalledWith(
expect.objectContaining({ userId: "@bot:example.org" }),
);
expect(stopSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient);
});
});

View File

@@ -1,3 +1,4 @@
import { inspectMatrixDirectRooms } from "../direct-management.js";
import { isStrictDirectRoom } from "../direct-room.js";
import type { MatrixClient } from "../sdk.js";
import { isMatrixQualifiedUserId, normalizeMatrixResolvableTarget } from "../target-ids.js";
@@ -92,36 +93,16 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
directRoomCache.delete(trimmed);
}
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try {
const directContent = (await client.getAccountData(EventType.Direct)) as Record<
string,
string[] | undefined
>;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
for (const roomId of list) {
if (await isStrictDirectRoom({ client, roomId, remoteUserId: trimmed, selfUserId })) {
setDirectRoomCached(client, trimmed, roomId);
return roomId;
}
const inspection = await inspectMatrixDirectRooms({
client,
remoteUserId: trimmed,
});
if (inspection.activeRoomId) {
setDirectRoomCached(client, trimmed, inspection.activeRoomId);
if (inspection.mappedRoomIds[0] !== inspection.activeRoomId) {
await persistDirectRoom(client, trimmed, inspection.activeRoomId);
}
} catch {
// Ignore and fall back.
}
// 2) Fallback: look for an existing joined room that is actually a 1:1 with the user.
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
if (await isStrictDirectRoom({ client, roomId, remoteUserId: trimmed, selfUserId })) {
setDirectRoomCached(client, trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
}
} catch {
// Ignore and fall back.
return inspection.activeRoomId;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);