From 98a9bb3ff0e8215445f1f9de78dac7aa765b3ba3 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 9 Jul 2026 22:43:57 +0200 Subject: [PATCH 1/3] fix: classify Matrix direct-message rooms --- CHANGELOG.md | 1 + src/index.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 85 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f1433..d18a843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Attachments sent alongside text no longer incorrectly carry the reply-to relationship - Incoming formatted messages were parsed twice; removed redundant `` pre-strip pass - `matrixSDKLogConfigured` flag no longer latches when `setLevel` is missing from the SDK logger +- Chat SDK now classifies `m.direct` and authoritative two-person rooms as direct messages ### Changes diff --git a/src/index.test.ts b/src/index.test.ts index cc27915..660012e 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { Chat, getEmoji, stringifyMarkdown } from "chat"; import type { AdapterPostableMessage, ChatInstance, Logger, StateAdapter } from "chat"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { EventType, MsgType, RelationType, type MatrixClient } from "matrix-js-sdk"; +import { + ClientEvent, + EventType, + MsgType, + RelationType, + RoomMemberEvent, + type MatrixClient, +} from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk/lib/http-api/errors"; import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key"; import { createMatrixAdapter, MatrixAdapter } from "./index"; @@ -225,6 +232,18 @@ function makeClient() { getAccountDataFromServer: vi.fn( async (): Promise | null> => null ), + getAccountData: vi.fn( + (_type: string): { getContent: () => Record } | undefined => + undefined + ), + getJoinedRooms: vi.fn( + async (): Promise<{ joined_rooms: string[] }> => ({ joined_rooms: [] }) + ), + getJoinedRoomMembers: vi.fn( + async (_roomID: string): Promise<{ joined: Record }> => ({ + joined: {}, + }) + ), getAccessToken: vi.fn(() => "token"), getCrypto: vi.fn(() => crypto), getEventMapper: vi.fn(() => (raw: Record) => mapRawToEvent(raw)), @@ -457,6 +476,76 @@ describe("MatrixAdapter", () => { }); }); + it("classifies m.direct and authoritative two-person rooms as DMs", async () => { + const client = makeClient(); + client.getAccountData.mockReturnValue({ + getContent: () => ({ + "@alice:beeper.com": ["!direct:beeper.com"], + }), + }); + client.getJoinedRooms.mockResolvedValue({ + joined_rooms: ["!named:beeper.com", "!group:beeper.com"], + }); + client.getJoinedRoomMembers.mockImplementation(async (roomID: string) => ({ + joined: + roomID === "!named:beeper.com" + ? { + "@bot:beeper.com": {}, + "@bob:beeper.com": {}, + } + : { + "@bot:beeper.com": {}, + "@bob:beeper.com": {}, + "@carol:beeper.com": {}, + }, + })); + const adapter = createMatrixAdapter({ + baseURL: "https://matrix.example.com", + auth: { + type: "accessToken", + accessToken: "token", + userID: "@bot:beeper.com", + }, + createClient: () => asMatrixClient(client), + }); + + await adapter.initialize(makeChatInstance()); + + expect(adapter.isDM("matrix:!direct%3Abeeper.com")).toBe(true); + expect(adapter.isDM("matrix:!named%3Abeeper.com")).toBe(true); + expect(adapter.isDM("matrix:!group%3Abeeper.com")).toBe(false); + expect(adapter.isDM("not-a-matrix-thread")).toBe(false); + + const newDirectThread = await adapter.openDM("@dave:beeper.com"); + expect(adapter.isDM(newDirectThread)).toBe(true); + + client.__handlers.get(ClientEvent.AccountData)?.( + makeEvent({ + getType: () => EventType.Direct, + getContent: () => ({ + "@erin:beeper.com": ["!updated-direct:beeper.com"], + }), + }) + ); + expect(adapter.isDM("matrix:!direct%3Abeeper.com")).toBe(false); + expect(adapter.isDM("matrix:!updated-direct%3Abeeper.com")).toBe(true); + + client.getJoinedRoomMembers.mockResolvedValue({ + joined: { + "@bot:beeper.com": {}, + "@bob:beeper.com": {}, + "@carol:beeper.com": {}, + }, + }); + client.__handlers.get(RoomMemberEvent.Membership)?.( + makeEvent(), + { roomId: "!named:beeper.com" } + ); + await vi.waitFor(() => { + expect(adapter.isDM("matrix:!named%3Abeeper.com")).toBe(false); + }); + }); + it("rejects thread IDs with an empty room ID", () => { const adapter = new MatrixAdapter({ baseURL: "https://hs.beeper.com", diff --git a/src/index.ts b/src/index.ts index 042be70..5605451 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ import sdk, { MsgType, RelationType, RoomEvent, + RoomMemberEvent, SyncState, ThreadFilterType, THREAD_RELATION_TYPE, @@ -208,6 +209,8 @@ export class MatrixAdapter implements Adapter { private readonly reactionByEventID = new Map(); private readonly myReactionByKey = new Map(); private readonly processedTimelineEventIDs = new Set(); + private readonly directRoomIDs = new Set(); + private readonly twoPersonRoomIDs = new Set(); private lastSecretsBundlePersistAt = 0; private secretsBundleUnavailableLogged = false; private liveSyncReady = false; @@ -294,8 +297,18 @@ export class MatrixAdapter implements Adapter { } this.dispatchTimelineEvent(event, undefined, false); }); + this.client.on(ClientEvent.AccountData, (event) => { + if (event.getType() === EventType.Direct) { + this.replaceDirectRoomIDs(this.normalizeDirectAccountData(event.getContent())); + } + }); + this.client.on(RoomMemberEvent.Membership, (_event, member) => { + this.twoPersonRoomIDs.delete(member.roomId); + void this.refreshTwoPersonRoom(member.roomId); + }); await this.maybeInitE2EE(); + await this.primeDMRoomIDs(); await this.client.startClient(this.syncOptions); this.started = true; @@ -322,6 +335,8 @@ export class MatrixAdapter implements Adapter { this.client.stopClient(); this.reactionByEventID.clear(); this.myReactionByKey.clear(); + this.directRoomIDs.clear(); + this.twoPersonRoomIDs.clear(); this.client = null; this.started = false; this.logger.info("Matrix adapter shutdown complete"); @@ -352,6 +367,15 @@ export class MatrixAdapter implements Adapter { return channelIdFromThreadId(threadId); } + isDM(threadId: string): boolean { + try { + const { roomID } = this.decodeThreadId(threadId); + return this.directRoomIDs.has(roomID) || this.twoPersonRoomIDs.has(roomID); + } catch { + return false; + } + } + renderFormatted(content: FormattedContent): string { return stringifyMarkdown(content); } @@ -1042,11 +1066,69 @@ export class MatrixAdapter implements Adapter { private async loadDirectAccountData(): Promise { const cached = this.loadCachedDirectAccountData(); if (Object.keys(cached).length > 0) { + this.replaceDirectRoomIDs(cached); return cached; } const direct = await this.requireClient().getAccountDataFromServer(EventType.Direct); - return this.normalizeDirectAccountData(direct); + const normalized = this.normalizeDirectAccountData(direct); + this.replaceDirectRoomIDs(normalized); + return normalized; + } + + private async primeDMRoomIDs(): Promise { + this.replaceDirectRoomIDs(this.loadCachedDirectAccountData()); + await this.refreshTwoPersonRoomIDs(); + } + + private replaceDirectRoomIDs(direct: DirectAccountData): void { + this.directRoomIDs.clear(); + for (const roomIDs of Object.values(direct)) { + for (const roomID of roomIDs) { + this.directRoomIDs.add(roomID); + } + } + } + + private async refreshTwoPersonRoomIDs(): Promise { + let roomIDs: string[]; + try { + const response = await this.requireClient().getJoinedRooms(); + roomIDs = response.joined_rooms; + } catch (error) { + this.logger.debug("Failed to list joined Matrix rooms for DM detection", { + error, + }); + return; + } + + let next = 0; + const worker = async (): Promise => { + while (next < roomIDs.length) { + const roomID = roomIDs[next++]; + await this.refreshTwoPersonRoom(roomID); + } + }; + await Promise.all( + Array.from({ length: Math.min(4, roomIDs.length) }, () => worker()) + ); + } + + private async refreshTwoPersonRoom(roomID: string): Promise { + try { + const response = await this.requireClient().getJoinedRoomMembers(roomID); + if (Object.keys(response.joined).length === 2) { + this.twoPersonRoomIDs.add(roomID); + } else { + this.twoPersonRoomIDs.delete(roomID); + } + } catch (error) { + this.twoPersonRoomIDs.delete(roomID); + this.logger.debug("Failed to refresh Matrix room members for DM detection", { + roomId: roomID, + error, + }); + } } private loadCachedDirectAccountData(): DirectAccountData { @@ -1140,6 +1222,7 @@ export class MatrixAdapter implements Adapter { [userID]: [...existingRooms, roomID], }; await this.requireClient().setAccountData(EventType.Direct, updated); + this.directRoomIDs.add(roomID); } } From e700b58c0ba7ca88ac0d8aa51e83c3677fb0d83e Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 10 Jul 2026 01:25:36 +0200 Subject: [PATCH 2/3] fix: classify DMs from m.direct only --- CHANGELOG.md | 2 +- src/index.test.ts | 79 +++++++++++++++++------------------------------ src/index.ts | 67 +++++++++------------------------------- 3 files changed, 45 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d18a843..2c87ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Attachments sent alongside text no longer incorrectly carry the reply-to relationship - Incoming formatted messages were parsed twice; removed redundant `` pre-strip pass - `matrixSDKLogConfigured` flag no longer latches when `setLevel` is missing from the SDK logger -- Chat SDK now classifies `m.direct` and authoritative two-person rooms as direct messages +- Chat SDK now classifies rooms listed in `m.direct` as direct messages ### Changes diff --git a/src/index.test.ts b/src/index.test.ts index 660012e..c4a160c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -7,7 +7,6 @@ import { EventType, MsgType, RelationType, - RoomMemberEvent, type MatrixClient, } from "matrix-js-sdk"; import { MatrixError } from "matrix-js-sdk/lib/http-api/errors"; @@ -236,14 +235,6 @@ function makeClient() { (_type: string): { getContent: () => Record } | undefined => undefined ), - getJoinedRooms: vi.fn( - async (): Promise<{ joined_rooms: string[] }> => ({ joined_rooms: [] }) - ), - getJoinedRoomMembers: vi.fn( - async (_roomID: string): Promise<{ joined: Record }> => ({ - joined: {}, - }) - ), getAccessToken: vi.fn(() => "token"), getCrypto: vi.fn(() => crypto), getEventMapper: vi.fn(() => (raw: Record) => mapRawToEvent(raw)), @@ -476,29 +467,11 @@ describe("MatrixAdapter", () => { }); }); - it("classifies m.direct and authoritative two-person rooms as DMs", async () => { + it("classifies only m.direct rooms as DMs and refreshes the synchronous cache", async () => { const client = makeClient(); - client.getAccountData.mockReturnValue({ - getContent: () => ({ - "@alice:beeper.com": ["!direct:beeper.com"], - }), + client.getAccountDataFromServer.mockResolvedValue({ + "@alice:beeper.com": ["!direct:beeper.com"], }); - client.getJoinedRooms.mockResolvedValue({ - joined_rooms: ["!named:beeper.com", "!group:beeper.com"], - }); - client.getJoinedRoomMembers.mockImplementation(async (roomID: string) => ({ - joined: - roomID === "!named:beeper.com" - ? { - "@bot:beeper.com": {}, - "@bob:beeper.com": {}, - } - : { - "@bot:beeper.com": {}, - "@bob:beeper.com": {}, - "@carol:beeper.com": {}, - }, - })); const adapter = createMatrixAdapter({ baseURL: "https://matrix.example.com", auth: { @@ -511,9 +484,10 @@ describe("MatrixAdapter", () => { await adapter.initialize(makeChatInstance()); + expect(client.getAccountDataFromServer).toHaveBeenCalledWith(EventType.Direct); expect(adapter.isDM("matrix:!direct%3Abeeper.com")).toBe(true); - expect(adapter.isDM("matrix:!named%3Abeeper.com")).toBe(true); - expect(adapter.isDM("matrix:!group%3Abeeper.com")).toBe(false); + // A two-person room is still a normal room unless m.direct marks it. + expect(adapter.isDM("matrix:!unmarked-two-person%3Abeeper.com")).toBe(false); expect(adapter.isDM("not-a-matrix-thread")).toBe(false); const newDirectThread = await adapter.openDM("@dave:beeper.com"); @@ -529,21 +503,25 @@ describe("MatrixAdapter", () => { ); expect(adapter.isDM("matrix:!direct%3Abeeper.com")).toBe(false); expect(adapter.isDM("matrix:!updated-direct%3Abeeper.com")).toBe(true); + }); - client.getJoinedRoomMembers.mockResolvedValue({ - joined: { - "@bot:beeper.com": {}, - "@bob:beeper.com": {}, - "@carol:beeper.com": {}, + it("fails closed when cold-start m.direct priming is unavailable", async () => { + const client = makeClient(); + client.getAccountDataFromServer.mockRejectedValue(new Error("homeserver unavailable")); + const adapter = createMatrixAdapter({ + baseURL: "https://matrix.example.com", + auth: { + type: "accessToken", + accessToken: "token", + userID: "@bot:beeper.com", }, + createClient: () => asMatrixClient(client), }); - client.__handlers.get(RoomMemberEvent.Membership)?.( - makeEvent(), - { roomId: "!named:beeper.com" } - ); - await vi.waitFor(() => { - expect(adapter.isDM("matrix:!named%3Abeeper.com")).toBe(false); - }); + + await expect(adapter.initialize(makeChatInstance())).resolves.toBeUndefined(); + + expect(client.startClient).toHaveBeenCalledOnce(); + expect(adapter.isDM("matrix:!unmarked%3Abeeper.com")).toBe(false); }); it("rejects thread IDs with an empty room ID", () => { @@ -2765,12 +2743,7 @@ describe("MatrixAdapter", () => { it("merges fresh m.direct account data before persisting a newly created DM", async () => { const fakeClient = makeClient(); - fakeClient.getAccountDataFromServer - .mockResolvedValueOnce({}) - .mockResolvedValueOnce({ - "@bob:beeper.com": ["!existing-dm:beeper.com"], - "@carol:beeper.com": ["!carol-dm:beeper.com"], - }); + fakeClient.getAccountDataFromServer.mockResolvedValue({}); fakeClient.createRoom.mockResolvedValue({ room_id: "!new-dm:beeper.com" }); const adapter = new MatrixAdapter({ @@ -2780,6 +2753,12 @@ describe("MatrixAdapter", () => { }); await adapter.initialize(makeChatInstance({ state: makeStateAdapter() })); + fakeClient.getAccountDataFromServer + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + "@bob:beeper.com": ["!existing-dm:beeper.com"], + "@carol:beeper.com": ["!carol-dm:beeper.com"], + }); await adapter.openDM("@bob:beeper.com"); expect(fakeClient.setAccountData).toHaveBeenCalledWith(EventType.Direct, { diff --git a/src/index.ts b/src/index.ts index 5605451..1f6434c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,6 @@ import sdk, { MsgType, RelationType, RoomEvent, - RoomMemberEvent, SyncState, ThreadFilterType, THREAD_RELATION_TYPE, @@ -210,7 +209,6 @@ export class MatrixAdapter implements Adapter { private readonly myReactionByKey = new Map(); private readonly processedTimelineEventIDs = new Set(); private readonly directRoomIDs = new Set(); - private readonly twoPersonRoomIDs = new Set(); private lastSecretsBundlePersistAt = 0; private secretsBundleUnavailableLogged = false; private liveSyncReady = false; @@ -302,13 +300,9 @@ export class MatrixAdapter implements Adapter { this.replaceDirectRoomIDs(this.normalizeDirectAccountData(event.getContent())); } }); - this.client.on(RoomMemberEvent.Membership, (_event, member) => { - this.twoPersonRoomIDs.delete(member.roomId); - void this.refreshTwoPersonRoom(member.roomId); - }); await this.maybeInitE2EE(); - await this.primeDMRoomIDs(); + await this.primeDirectRoomIDs(); await this.client.startClient(this.syncOptions); this.started = true; @@ -336,7 +330,6 @@ export class MatrixAdapter implements Adapter { this.reactionByEventID.clear(); this.myReactionByKey.clear(); this.directRoomIDs.clear(); - this.twoPersonRoomIDs.clear(); this.client = null; this.started = false; this.logger.info("Matrix adapter shutdown complete"); @@ -370,7 +363,7 @@ export class MatrixAdapter implements Adapter { isDM(threadId: string): boolean { try { const { roomID } = this.decodeThreadId(threadId); - return this.directRoomIDs.has(roomID) || this.twoPersonRoomIDs.has(roomID); + return this.directRoomIDs.has(roomID); } catch { return false; } @@ -1076,9 +1069,20 @@ export class MatrixAdapter implements Adapter { return normalized; } - private async primeDMRoomIDs(): Promise { + private async primeDirectRoomIDs(): Promise { + // Fail closed: cached m.direct data is safe to use immediately, and an + // unavailable homeserver must never make member count an implicit DM + // signal. loadDirectAccountData() fetches from the server when the cache is + // empty, which covers cold starts before the first sync/account-data event. this.replaceDirectRoomIDs(this.loadCachedDirectAccountData()); - await this.refreshTwoPersonRoomIDs(); + try { + await this.loadDirectAccountData(); + } catch (error) { + this.logger.warn( + "Failed to prime Matrix direct rooms; treating unmarked rooms as channels", + { error } + ); + } } private replaceDirectRoomIDs(direct: DirectAccountData): void { @@ -1090,47 +1094,6 @@ export class MatrixAdapter implements Adapter { } } - private async refreshTwoPersonRoomIDs(): Promise { - let roomIDs: string[]; - try { - const response = await this.requireClient().getJoinedRooms(); - roomIDs = response.joined_rooms; - } catch (error) { - this.logger.debug("Failed to list joined Matrix rooms for DM detection", { - error, - }); - return; - } - - let next = 0; - const worker = async (): Promise => { - while (next < roomIDs.length) { - const roomID = roomIDs[next++]; - await this.refreshTwoPersonRoom(roomID); - } - }; - await Promise.all( - Array.from({ length: Math.min(4, roomIDs.length) }, () => worker()) - ); - } - - private async refreshTwoPersonRoom(roomID: string): Promise { - try { - const response = await this.requireClient().getJoinedRoomMembers(roomID); - if (Object.keys(response.joined).length === 2) { - this.twoPersonRoomIDs.add(roomID); - } else { - this.twoPersonRoomIDs.delete(roomID); - } - } catch (error) { - this.twoPersonRoomIDs.delete(roomID); - this.logger.debug("Failed to refresh Matrix room members for DM detection", { - roomId: roomID, - error, - }); - } - } - private loadCachedDirectAccountData(): DirectAccountData { const client = this.requireClient(); const getAccountData = Reflect.get(client, "getAccountData"); From 483b1cc2d2d01f7e070d72de50a5db0436f0dcd3 Mon Sep 17 00:00:00 2001 From: glifocat Date: Fri, 10 Jul 2026 01:43:44 +0200 Subject: [PATCH 3/3] fix: refresh m.direct on startup --- src/index.test.ts | 27 +++++++++++++++++++++++++++ src/index.ts | 11 +++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index c4a160c..b15ec3f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -524,6 +524,33 @@ describe("MatrixAdapter", () => { expect(adapter.isDM("matrix:!unmarked%3Abeeper.com")).toBe(false); }); + it("refreshes stale cached m.direct data during initialization", async () => { + const client = makeClient(); + client.getAccountData.mockReturnValue({ + getContent: () => ({ + "@alice:beeper.com": ["!stale-direct:beeper.com"], + }), + }); + client.getAccountDataFromServer.mockResolvedValue({ + "@alice:beeper.com": ["!fresh-direct:beeper.com"], + }); + const adapter = createMatrixAdapter({ + baseURL: "https://matrix.example.com", + auth: { + type: "accessToken", + accessToken: "token", + userID: "@bot:beeper.com", + }, + createClient: () => asMatrixClient(client), + }); + + await adapter.initialize(makeChatInstance()); + + expect(client.getAccountDataFromServer).toHaveBeenCalledWith(EventType.Direct); + expect(adapter.isDM("matrix:!stale-direct%3Abeeper.com")).toBe(false); + expect(adapter.isDM("matrix:!fresh-direct%3Abeeper.com")).toBe(true); + }); + it("rejects thread IDs with an empty room ID", () => { const adapter = new MatrixAdapter({ baseURL: "https://hs.beeper.com", diff --git a/src/index.ts b/src/index.ts index 1f6434c..2a38392 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1070,16 +1070,15 @@ export class MatrixAdapter implements Adapter { } private async primeDirectRoomIDs(): Promise { - // Fail closed: cached m.direct data is safe to use immediately, and an - // unavailable homeserver must never make member count an implicit DM - // signal. loadDirectAccountData() fetches from the server when the cache is - // empty, which covers cold starts before the first sync/account-data event. + // Install cached m.direct data as a fallback, but always refresh it from the + // homeserver so warm starts cannot retain stale room classifications. this.replaceDirectRoomIDs(this.loadCachedDirectAccountData()); try { - await this.loadDirectAccountData(); + const direct = await this.requireClient().getAccountDataFromServer(EventType.Direct); + this.replaceDirectRoomIDs(this.normalizeDirectAccountData(direct)); } catch (error) { this.logger.warn( - "Failed to prime Matrix direct rooms; treating unmarked rooms as channels", + "Failed to refresh Matrix direct rooms; retaining cached m.direct data and treating other rooms as channels", { error } ); }