Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Attachments sent alongside text no longer incorrectly carry the reply-to relationship
- Incoming formatted messages were parsed twice; removed redundant `<mx-reply>` pre-strip pass
- `matrixSDKLogConfigured` flag no longer latches when `setLevel` is missing from the SDK logger
- Chat SDK now classifies rooms listed in `m.direct` as direct messages

### Changes

Expand Down
109 changes: 102 additions & 7 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ 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,
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";
Expand Down Expand Up @@ -225,6 +231,10 @@ function makeClient() {
getAccountDataFromServer: vi.fn(
async (): Promise<Record<string, string[]> | null> => null
),
getAccountData: vi.fn(
(_type: string): { getContent: () => Record<string, string[]> } | undefined =>
undefined
),
getAccessToken: vi.fn(() => "token"),
getCrypto: vi.fn(() => crypto),
getEventMapper: vi.fn(() => (raw: Record<string, unknown>) => mapRawToEvent(raw)),
Expand Down Expand Up @@ -457,6 +467,90 @@ describe("MatrixAdapter", () => {
});
});

it("classifies only m.direct rooms as DMs and refreshes the synchronous cache", async () => {
const client = makeClient();
client.getAccountDataFromServer.mockResolvedValue({
"@alice:beeper.com": ["!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:!direct%3Abeeper.com")).toBe(true);
// 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");
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);
});

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

await expect(adapter.initialize(makeChatInstance())).resolves.toBeUndefined();

expect(client.startClient).toHaveBeenCalledOnce();
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",
Expand Down Expand Up @@ -2676,12 +2770,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({
Expand All @@ -2691,6 +2780,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, {
Expand Down
47 changes: 46 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
private readonly reactionByEventID = new Map<string, StoredReaction>();
private readonly myReactionByKey = new Map<string, string>();
private readonly processedTimelineEventIDs = new Set<string>();
private readonly directRoomIDs = new Set<string>();
private lastSecretsBundlePersistAt = 0;
private secretsBundleUnavailableLogged = false;
private liveSyncReady = false;
Expand Down Expand Up @@ -294,8 +295,14 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
}
this.dispatchTimelineEvent(event, undefined, false);
});
this.client.on(ClientEvent.AccountData, (event) => {
if (event.getType() === EventType.Direct) {
this.replaceDirectRoomIDs(this.normalizeDirectAccountData(event.getContent()));
}
});

await this.maybeInitE2EE();
await this.primeDirectRoomIDs();
await this.client.startClient(this.syncOptions);
this.started = true;

Expand All @@ -322,6 +329,7 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
this.client.stopClient();
this.reactionByEventID.clear();
this.myReactionByKey.clear();
this.directRoomIDs.clear();
this.client = null;
this.started = false;
this.logger.info("Matrix adapter shutdown complete");
Expand Down Expand Up @@ -352,6 +360,15 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
return channelIdFromThreadId(threadId);
}

isDM(threadId: string): boolean {
try {
const { roomID } = this.decodeThreadId(threadId);
return this.directRoomIDs.has(roomID);
} catch {
return false;
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
renderFormatted(content: FormattedContent): string {
return stringifyMarkdown(content);
}
Expand Down Expand Up @@ -1042,11 +1059,38 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
private async loadDirectAccountData(): Promise<DirectAccountData> {
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 primeDirectRoomIDs(): Promise<void> {
// 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 {
const direct = await this.requireClient().getAccountDataFromServer(EventType.Direct);
this.replaceDirectRoomIDs(this.normalizeDirectAccountData(direct));
} catch (error) {
this.logger.warn(
"Failed to refresh Matrix direct rooms; retaining cached m.direct data and treating other rooms as channels",
{ error }
);
}
}

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 loadCachedDirectAccountData(): DirectAccountData {
Expand Down Expand Up @@ -1140,6 +1184,7 @@ export class MatrixAdapter implements Adapter<MatrixThreadID, MatrixEvent> {
[userID]: [...existingRooms, roomID],
};
await this.requireClient().setAccountData(EventType.Direct, updated);
this.directRoomIDs.add(roomID);
}
}

Expand Down