From f1e6fb7271802c9e5a476450248581dd0f967da8 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 27 Oct 2025 12:45:15 +0000 Subject: [PATCH 01/13] Remove .sessionForRoom --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 56 ++++++++++---------- src/matrixrtc/MatrixRTCSession.ts | 8 --- src/matrixrtc/MatrixRTCSessionManager.ts | 4 +- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 8eb11ecdd1..a934e35cd6 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -92,7 +92,7 @@ describe("MatrixRTCSession", () => { testCreateSticky: true, }, ])( - "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", + "roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); @@ -331,7 +331,7 @@ describe("MatrixRTCSession", () => { }, ); - describe("roomSessionForRoom combined state", () => { + describe("roomsessionForSlot combined state", () => { it("perfers sticky events when both membership and sticky events appear for the same user", () => { // Create a room with identical member state and sticky state for the same user. const mockRoom = makeMockRoom([membershipTemplate]); @@ -433,7 +433,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess.getOldestMembership()!.deviceId).toEqual("old"); jest.useRealTimers(); }); @@ -454,7 +454,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { "m.call.intent": intentB }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess.getConsensusCallIntent()).toEqual(result); jest.useRealTimers(); }); @@ -479,7 +479,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -498,7 +498,7 @@ describe("MatrixRTCSession", () => { Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -525,7 +525,7 @@ describe("MatrixRTCSession", () => { client._unstable_updateDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); }); afterEach(async () => { @@ -720,7 +720,7 @@ describe("MatrixRTCSession", () => { describe("onMembershipsChanged", () => { it("does not emit if no membership changes", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -731,7 +731,7 @@ describe("MatrixRTCSession", () => { it("emits on membership changes", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -750,7 +750,7 @@ describe("MatrixRTCSession", () => { // const membership = Object.assign({}, membershipTemplate); // const mockRoom = makeMockRoom([membership]); - // sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + // sess = MatrixRTCSession.roomsessionForSlot(client, mockRoom); // const membershipObject = sess.memberships[0]; // const onMembershipsChanged = jest.fn(); @@ -786,7 +786,7 @@ describe("MatrixRTCSession", () => { client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); }); afterEach(async () => { @@ -905,7 +905,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { @@ -954,7 +954,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); @@ -1005,7 +1005,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([member1, member2]); mockRoomState(mockRoom, [member1, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -1050,7 +1050,7 @@ describe("MatrixRTCSession", () => { }; const mockRoom = makeMockRoom([member1, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -1114,7 +1114,7 @@ describe("MatrixRTCSession", () => { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMyEncryptionKeyChanged = jest.fn(); sess.on( @@ -1204,7 +1204,7 @@ describe("MatrixRTCSession", () => { if (i === 0) { // if first time around then set up the session - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); } else { // otherwise update the state reducing the membership each time in order to trigger key rotation @@ -1230,7 +1230,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -1271,7 +1271,7 @@ describe("MatrixRTCSession", () => { }); const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, @@ -1294,7 +1294,7 @@ describe("MatrixRTCSession", () => { describe("receiving", () => { it("collects keys from encryption events", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1319,7 +1319,7 @@ describe("MatrixRTCSession", () => { it("collects keys at non-zero indices", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1345,7 +1345,7 @@ describe("MatrixRTCSession", () => { it("collects keys by merging", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1396,7 +1396,7 @@ describe("MatrixRTCSession", () => { it("ignores older keys at same index", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1455,7 +1455,7 @@ describe("MatrixRTCSession", () => { it("key timestamps are treated as monotonic", async () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1499,7 +1499,7 @@ describe("MatrixRTCSession", () => { it("ignores keys event for the local participant", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1522,7 +1522,7 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // defaults to getTs() jest.setSystemTime(1000); @@ -1578,7 +1578,7 @@ describe("MatrixRTCSession", () => { describe("read status", () => { it("returns the correct probablyLeft status", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.probablyLeft).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); @@ -1594,7 +1594,7 @@ describe("MatrixRTCSession", () => { it("returns membershipStatus once joinRoomSession got called", () => { const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.membershipStatus).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index f5b39c48f5..e2f295aa39 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -465,14 +465,6 @@ export class MatrixRTCSession extends TypedEventEmitter< ); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } - - /** - * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. - */ - public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { - return this.sessionForSlot(client, room, slotDescription); - } - /** * Return the MatrixRTC session for the room. * This returned session can be used to find out if there are active sessions diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index a103b39db9..b7af472a2d 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { this.roomSessions.set(room.roomId, session); } @@ -104,7 +104,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter Date: Mon, 27 Oct 2025 16:18:51 +0000 Subject: [PATCH 02/13] Implement slots --- src/@types/event.ts | 3 ++ src/matrixrtc/CallApplication.ts | 21 ++++++++ src/matrixrtc/CallMembership.ts | 2 + src/matrixrtc/MatrixRTCSession.ts | 61 +++++++++++------------- src/matrixrtc/MatrixRTCSessionManager.ts | 4 +- src/matrixrtc/index.ts | 1 + src/matrixrtc/types.ts | 8 ++++ 7 files changed, 64 insertions(+), 36 deletions(-) create mode 100644 src/matrixrtc/CallApplication.ts diff --git a/src/@types/event.ts b/src/@types/event.ts index 96780da84e..e3f2d7e651 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -56,6 +56,7 @@ import { type IRTCDeclineContent, type EncryptionKeysEventContent, type ICallNotifyContent, + type RtcSlotEventContent, } from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; @@ -155,6 +156,7 @@ export enum EventType { CallNotify = "org.matrix.msc4075.call.notify", RTCNotification = "org.matrix.msc4075.rtc.notification", RTCDecline = "org.matrix.msc4310.rtc.decline", + RTCSlot = "org.matrix.msc4143.rtc.slot" } export enum RelationType { @@ -339,6 +341,7 @@ export interface TimelineEvents { [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; [EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty. + [EventType.RTCSlot]: RtcSlotEventContent; } /** diff --git a/src/matrixrtc/CallApplication.ts b/src/matrixrtc/CallApplication.ts new file mode 100644 index 0000000000..1e0d2fe41a --- /dev/null +++ b/src/matrixrtc/CallApplication.ts @@ -0,0 +1,21 @@ +import { RtcSlotEventContent } from "./types"; + +/** + * Matrix RTC Slot event for the "m.call" application type. + */ +export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { + application: { + type: "m.call", + "m.call.id"?: string, + } +} +/** + * Default slot for a room using "m.call". + */ +export const DefaultCallApplicationSlot: CallSlotEventContent = { + application: { + type: "m.call", + "m.call.id": "" + } +}; + diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ba142ab063..02b4ce40a5 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -33,6 +33,8 @@ export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; type CallScope = "m.room" | "m.user"; type Member = { user_id: string; device_id: string; id: string }; + + export interface RtcMembershipData { "slot_id": string; "member": Member; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index e2f295aa39..9971aca3b3 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -16,7 +16,7 @@ limitations under the License. import { type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { EventTimeline } from "../models/event-timeline.ts"; +import { Direction, EventTimeline } from "../models/event-timeline.ts"; import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; @@ -35,6 +35,7 @@ import type { ICallNotifyContent, RTCCallIntent, Transport, + RtcSlotEventContent, } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { @@ -52,6 +53,7 @@ import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; +import { DefaultCallApplicationSlot } from "./CallApplication.ts"; /** * Events emitted by MatrixRTCSession @@ -108,6 +110,7 @@ export interface SessionConfig { export interface SlotDescription { id: string; application: string; + parameters?: Record; } export function slotIdToDescription(slotId: string): SlotDescription { const [application, id] = slotId.split("#"); @@ -299,6 +302,9 @@ export class MatrixRTCSession extends TypedEventEmitter< * * It can be undefined since the callId is only known once the first membership joins. * The callId is the property that, per definition, groups memberships into one call. + * + * This may also be undefined if MSC4143 slots are in use, as calls do not always have an ID if they + * have a slot. * @deprecated use `slotId` instead. */ public get callId(): string | undefined { @@ -314,31 +320,6 @@ export class MatrixRTCSession extends TypedEventEmitter< return slotDescriptionToId(this.slotDescription); } - /** - * Returns all the call memberships for a room that match the provided `sessionDescription`, - * oldest first. - * - * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. - */ - public static callMembershipsForRoom( - room: Pick, - ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForSlot(room, { - id: "", - application: "m.call", - }); - } - - /** - * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. - */ - public static sessionMembershipsForRoom( - room: Pick, - sessionDescription: SlotDescription, - ): CallMembership[] { - return this.sessionMembershipsForSlot(room, sessionDescription); - } - /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. @@ -355,13 +336,24 @@ export class MatrixRTCSession extends TypedEventEmitter< listenForMemberStateEvents: true, }, ): CallMembership[] { - const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); + const slotId = slotDescriptionToId(slotDescription); + const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId} ${slotId}]`); let callMemberEvents = [] as MatrixEvent[]; if (listenForStickyEvents) { - // prefill with sticky events - callMemberEvents = [...room._unstable_getStickyEvents()].filter( - (e) => e.getType() === EventType.RTCMembership, - ); + // Also check that the room has the valid slot. + const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); + const slotContent = slot?.getContent() as RtcSlotEventContent ?? {}; + if ( + // Check the slot exists and has the correct type. + slotContent?.application?.type === slotDescription.application && + // Check the parameters of the slot match the expected parameters. + deepCompare({type: slotDescription.application, ...slotDescription.parameters}, slotContent.application)) { + + // Has a slot and the application parameters match, fetch sticky members. + callMemberEvents = [...room._unstable_getStickyEvents()].filter( + (e) => e.getType() === EventType.RTCMembership, + ); + } // otherwise, the slot wasn't valid and we can skip these members } if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -458,12 +450,13 @@ export class MatrixRTCSession extends TypedEventEmitter< room: Room, opts?: SessionMembershipsForRoomOpts, ): MatrixRTCSession { + const sessionDescription = {id: "", application: DefaultCallApplicationSlot.application.type}; const callMemberships = MatrixRTCSession.sessionMembershipsForSlot( room, - { id: "", application: "m.call" }, + sessionDescription, opts, ); - return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); + return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } /** * Return the MatrixRTC session for the room. @@ -532,7 +525,7 @@ export class MatrixRTCSession extends TypedEventEmitter< public readonly slotDescription: SlotDescription, ) { super(); - this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); + this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId} ${slotDescriptionToId(slotDescription)}]`); const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index b7af472a2d..e736997473 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession, SlotDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; export enum MatrixRTCSessionManagerEvents { @@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + "application": { + type: T; + // other application specific keys + [key: string]: unknown; + }; +} \ No newline at end of file From d4b90e38321298805f405f16345c9b613188a860 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 28 Oct 2025 12:12:25 +0000 Subject: [PATCH 03/13] Lots of updates --- src/matrixrtc/CallApplication.ts | 20 ++-- src/matrixrtc/CallMembership.ts | 12 +-- src/matrixrtc/MatrixRTCSession.ts | 126 +++++++++++++---------- src/matrixrtc/MatrixRTCSessionManager.ts | 6 +- src/matrixrtc/MembershipManager.ts | 10 +- src/matrixrtc/index.ts | 1 + src/matrixrtc/types.ts | 13 ++- src/matrixrtc/utils.ts | 11 +- 8 files changed, 114 insertions(+), 85 deletions(-) diff --git a/src/matrixrtc/CallApplication.ts b/src/matrixrtc/CallApplication.ts index 1e0d2fe41a..e0413a0c9c 100644 --- a/src/matrixrtc/CallApplication.ts +++ b/src/matrixrtc/CallApplication.ts @@ -1,21 +1,25 @@ -import { RtcSlotEventContent } from "./types"; +import { RtcSlotEventContent, SlotDescription } from "./types"; + +export const DefaultCallApplicationDescription: SlotDescription = { + id: "", + application: "m.call", +}; /** * Matrix RTC Slot event for the "m.call" application type. */ export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { application: { - type: "m.call", - "m.call.id"?: string, - } + "type": "m.call"; + "m.call.id"?: string; + }; } /** * Default slot for a room using "m.call". */ export const DefaultCallApplicationSlot: CallSlotEventContent = { application: { - type: "m.call", - "m.call.id": "" - } + "type": "m.call", + "m.call.id": "", + }, }; - diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 02b4ce40a5..6fcc47830e 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -17,11 +17,11 @@ limitations under the License. import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; import { type LivekitFocusSelection } from "./LivekitTransport.ts"; -import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; -import type { RTCCallIntent, Transport } from "./types.ts"; +import { type RTCCallIntent, type Transport, type SlotDescription, RtcSlotEventContent } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; import { type RelationType } from "../@types/event.ts"; import { logger } from "../logger.ts"; +import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -33,8 +33,6 @@ export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; type CallScope = "m.room" | "m.user"; type Member = { user_id: string; device_id: string; id: string }; - - export interface RtcMembershipData { "slot_id": string; "member": Member; @@ -42,11 +40,7 @@ export interface RtcMembershipData { event_id: string; rel_type: RelationType.Reference; }; - "application": { - type: string; - // other application specific keys - [key: string]: unknown; - }; + "application": RtcSlotEventContent["application"]; "rtc_transports": Transport[]; "versions": string[]; "msc4354_sticky_key"?: string; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 9971aca3b3..1bae53bcd4 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -22,20 +22,21 @@ import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; -import { CallMembership } from "./CallMembership.ts"; +import { CallMembership, RtcMembershipData } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; -import type { - Statistics, - RTCNotificationType, - Status, - IRTCNotificationContent, - ICallNotifyContent, - RTCCallIntent, - Transport, - RtcSlotEventContent, +import { + type Statistics, + type RTCNotificationType, + type Status, + type IRTCNotificationContent, + type ICallNotifyContent, + type RTCCallIntent, + type Transport, + type RtcSlotEventContent, + type SlotDescription, } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { @@ -54,6 +55,7 @@ import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; import { DefaultCallApplicationSlot } from "./CallApplication.ts"; +import { slotDescriptionToId } from "./utils.ts"; /** * Events emitted by MatrixRTCSession @@ -104,22 +106,6 @@ export interface SessionConfig { callIntent?: RTCCallIntent; } -/** - * The session description is used to identify a session. Used in the state event. - */ -export interface SlotDescription { - id: string; - application: string; - parameters?: Record; -} -export function slotIdToDescription(slotId: string): SlotDescription { - const [application, id] = slotId.split("#"); - return { application, id }; -} -export function slotDescriptionToId(slotDescription: SlotDescription): string { - return `${slotDescription.application}#${slotDescription.id}`; -} - // The names follow these principles: // - we use the technical term delay if the option is related to delayed events. // - we use delayedLeaveEvent if the option is related to the delayed leave event. @@ -302,7 +288,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * * It can be undefined since the callId is only known once the first membership joins. * The callId is the property that, per definition, groups memberships into one call. - * + * * This may also be undefined if MSC4143 slots are in use, as calls do not always have an ID if they * have a slot. * @deprecated use `slotId` instead. @@ -310,14 +296,41 @@ export class MatrixRTCSession extends TypedEventEmitter< public get callId(): string | undefined { return this.slotDescription?.id; } + /** - * The slotId of the call. - * `{application}#{appSpecificId}` - * It can be undefined since the slotId is only known once the first membership joins. - * The slotId is the property that, per definition, groups memberships into one call. + * Get a slot for a given room and description + * @param room The room which the slot is scoped to. + * @param slotDescription The description of the slot. The type, ID, and parameters must exactly match. + * @returns The contents of the slot event, or null if no matching slot found. */ - public get slotId(): string | undefined { - return slotDescriptionToId(this.slotDescription); + public static getRtcSlot( + room: Pick, + slotDescription: SlotDescription, + ): RtcSlotEventContent | null { + const slotId = slotDescriptionToId(slotDescription); + const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); + if (!slot) { + return null; + } + const slotContent = slot.getContent>(); + if (!slotContent.application || typeof slotContent.application !== "object") { + // Invalid slot content. + return null; + } + if ( + "type" in slotContent.application === false || + slotContent.application.type !== slotDescription.application + ) { + // Mistmached or missing application type. + return null; + } + // Check the parameters of the slot match the expected parameters. + if ( + deepCompare({ type: slotDescription.application, ...slotDescription.parameters }, slotContent.application) + ) { + return slotContent as RtcSlotEventContent; + } + return null; } /** @@ -339,22 +352,25 @@ export class MatrixRTCSession extends TypedEventEmitter< const slotId = slotDescriptionToId(slotDescription); const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId} ${slotId}]`); let callMemberEvents = [] as MatrixEvent[]; - if (listenForStickyEvents) { - // Also check that the room has the valid slot. - const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); - const slotContent = slot?.getContent() as RtcSlotEventContent ?? {}; - if ( - // Check the slot exists and has the correct type. - slotContent?.application?.type === slotDescription.application && - // Check the parameters of the slot match the expected parameters. - deepCompare({type: slotDescription.application, ...slotDescription.parameters}, slotContent.application)) { - - // Has a slot and the application parameters match, fetch sticky members. - callMemberEvents = [...room._unstable_getStickyEvents()].filter( - (e) => e.getType() === EventType.RTCMembership, - ); - } // otherwise, the slot wasn't valid and we can skip these members - } + // Check that the room has the valid slot. + if (listenForStickyEvents && this.getRtcSlot(room, slotDescription)) { + // Has a slot and the application parameters match, fetch sticky members. + callMemberEvents = [...room._unstable_getStickyEvents()].filter((e) => { + if (e.getType() !== EventType.RTCMembership) { + return false; + } + const content = e.getContent(); + // Ensure the slot ID of the membership matches the state + if (content.slot_id !== slotId) { + return false; + } + // Ensure the application data matches. + return deepCompare(e.getContent().application, { + type: slotDescription.application, + ...slotDescription.parameters, + }); + }); + } // otherwise, the slot wasn't valid and we can skip these members if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { @@ -450,12 +466,8 @@ export class MatrixRTCSession extends TypedEventEmitter< room: Room, opts?: SessionMembershipsForRoomOpts, ): MatrixRTCSession { - const sessionDescription = {id: "", application: DefaultCallApplicationSlot.application.type}; - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot( - room, - sessionDescription, - opts, - ); + const sessionDescription = { id: "", application: DefaultCallApplicationSlot.application.type }; + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, sessionDescription, opts); return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } /** @@ -525,7 +537,9 @@ export class MatrixRTCSession extends TypedEventEmitter< public readonly slotDescription: SlotDescription, ) { super(); - this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId} ${slotDescriptionToId(slotDescription)}]`); + this.logger = rootLogger.getChild( + `[MatrixRTCSession ${roomSubset.roomId} ${slotDescriptionToId(slotDescription)}]`, + ); const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index e736997473..f34fece166 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, SlotDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; +import { DefaultCallApplicationDescription } from "./CallApplication.ts"; +import type { SlotDescription } from "./types.ts"; export enum MatrixRTCSessionManagerEvents { // A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously @@ -56,7 +58,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { - "application": { + application: { type: T; // other application specific keys [key: string]: unknown; }; -} \ No newline at end of file +} + +/** + * The session description is used to identify a session. Used in the state event. + */ +export interface SlotDescription { + id: string; + application: string; + parameters?: Record; +} diff --git a/src/matrixrtc/utils.ts b/src/matrixrtc/utils.ts index 0d44c5e711..f5ad7194c4 100644 --- a/src/matrixrtc/utils.ts +++ b/src/matrixrtc/utils.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { InboundEncryptionSession, ParticipantId } from "./types.ts"; +import type { InboundEncryptionSession, ParticipantId, SlotDescription } from "./types.ts"; /** * Detects when a key for a given index is outdated. @@ -49,3 +49,12 @@ export class OutdatedKeyFilter { export function getParticipantId(userId: string, deviceId: string): ParticipantId { return `${userId}:${deviceId}`; } + +export function slotIdToDescription(slotId: string): SlotDescription { + const [application, id] = slotId.split("#"); + return { application, id }; +} + +export function slotDescriptionToId(slotDescription: SlotDescription): string { + return `${slotDescription.application}#${slotDescription.id}`; +} From b0a6147f31ea919c5e91b30d9a06e95ae7e7aded Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 30 Oct 2025 12:00:39 +0000 Subject: [PATCH 04/13] Cleanup membership and set explicit boundaries on legacy v.s. new. --- src/matrixrtc/CallMembership.ts | 291 +++++------------------------ src/matrixrtc/membership/common.ts | 5 + src/matrixrtc/membership/legacy.ts | 114 +++++++++++ src/matrixrtc/membership/rtc.ts | 107 +++++++++++ 4 files changed, 276 insertions(+), 241 deletions(-) create mode 100644 src/matrixrtc/membership/common.ts create mode 100644 src/matrixrtc/membership/legacy.ts create mode 100644 src/matrixrtc/membership/rtc.ts diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 6fcc47830e..b0fba94b9c 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,14 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; import { type LivekitFocusSelection } from "./LivekitTransport.ts"; -import { type RTCCallIntent, type Transport, type SlotDescription, RtcSlotEventContent } from "./types.ts"; +import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; -import { type RelationType } from "../@types/event.ts"; import { logger } from "../logger.ts"; import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; +import { checkSessionsMembershipData, SessionMembershipData } from "./membership/legacy.ts"; +import { checkRtcMembershipData, RtcMembershipData } from "./membership/rtc.ts"; +import { EventType } from "../matrix.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -30,209 +31,22 @@ import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; */ export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; -type CallScope = "m.room" | "m.user"; -type Member = { user_id: string; device_id: string; id: string }; - -export interface RtcMembershipData { - "slot_id": string; - "member": Member; - "m.relates_to"?: { - event_id: string; - rel_type: RelationType.Reference; - }; - "application": RtcSlotEventContent["application"]; - "rtc_transports": Transport[]; - "versions": string[]; - "msc4354_sticky_key"?: string; - "sticky_key"?: string; -} - -const checkRtcMembershipData = ( - data: IContent, - errors: string[], - referenceUserId: string, -): data is RtcMembershipData => { - const prefix = " - "; - - // required fields - if (typeof data.slot_id !== "string") { - errors.push(prefix + "slot_id must be string"); - } else { - if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"'); - } - if (typeof data.member !== "object" || data.member === null) { - errors.push(prefix + "member must be an object"); - } else { - if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); - else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); - // This is not what the spec enforces but there currently are no rules what power levels are required to - // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there - // is a proper definition when this is allowed. - else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender"); - if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); - if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); - } - if (typeof data.application !== "object" || data.application === null) { - errors.push(prefix + "application must be an object"); - } else { - if (typeof data.application.type !== "string") { - errors.push(prefix + "application.type must be a string"); - } else { - if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"'); - } - } - if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) { - errors.push(prefix + "rtc_transports must be an array"); - } else { - // validate that each transport has at least a string 'type' - for (const t of data.rtc_transports) { - if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") { - errors.push(prefix + "rtc_transports entries must be objects with a string type"); - break; - } - } - } - if (data.versions === undefined || !Array.isArray(data.versions)) { - errors.push(prefix + "versions must be an array"); - } else if (!data.versions.every((v) => typeof v === "string")) { - errors.push(prefix + "versions must be an array of strings"); - } - - // optional fields - if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) { - errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined"); - } - if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { - errors.push(prefix + "sticky_key must be a string"); - } - if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") { - errors.push(prefix + "msc4354_sticky_key must be a string"); - } - if ( - data.sticky_key !== undefined && - data.msc4354_sticky_key !== undefined && - data.sticky_key !== data.msc4354_sticky_key - ) { - errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined"); - } - if (data["m.relates_to"] !== undefined) { - const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; - if (typeof rel !== "object" || rel === null) { - errors.push(prefix + "m.relates_to must be an object if provided"); - } else { - if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string"); - if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference"); - } - } - - return errors.length === 0; -}; - /** - * MSC4143 (MatrixRTC) session membership data. - * Represents the `session` in the memberships section of an m.call.member event as it is on the wire. - **/ -export type SessionMembershipData = { - /** - * The RTC application defines the type of the RTC session. - */ - "application": string; - - /** - * The id of this session. - * A session can never span over multiple rooms so this id is to distinguish between - * multiple session in one room. A room wide session that is not associated with a user, - * and therefore immune to creation race conflicts, uses the `call_id: ""`. - */ - "call_id": string; - - /** - * The Matrix device ID of this session. A single user can have multiple sessions on different devices. - */ - "device_id": string; - - /** - * The focus selection system this user/membership is using. - */ - "focus_active": LivekitFocusSelection; - - /** - * A list of possible foci this user knows about. One of them might be used based on the focus_active - * selection system. - */ - "foci_preferred": Transport[]; - - /** - * Optional field that contains the creation of the session. If it is undefined the creation - * is the `origin_server_ts` of the event itself. For updates to the event this property tracks - * the `origin_server_ts` of the initial join event. - * - If it is undefined it can be interpreted as a "Join". - * - If it is defined it can be interpreted as an "Update" - */ - "created_ts"?: number; - - // Application specific data - - /** - * If the `application` = `"m.call"` this defines if it is a room or user owned call. - * There can always be one room scoped call but multiple user owned calls (breakout sessions) - */ - "scope"?: CallScope; - - /** - * Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid. - * This should be set to multiple hours. The only reason it exist is to deal with failed delayed events. - * (for example caused by a homeserver crashes) - **/ - "expires"?: number; - + * Describes the source event type that provided the membership data. + */ +enum MembershipKind { /** - * The intent of the call from the perspective of this user. This may be an audio call, video call or - * something else. + * The modern MSC4143 format event. */ - "m.call.intent"?: RTCCallIntent; + RTC = "rtc", /** - * The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device. + * The legacy call event type. */ - "msc4354_sticky_key"?: string; -}; - -const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => { - const prefix = " - "; - if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); - if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); - if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); - if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); - if (data.focus_active === undefined) { - errors.push(prefix + "focus_active has an invalid type"); - } - if ( - data.foci_preferred !== undefined && - !( - Array.isArray(data.foci_preferred) && - data.foci_preferred.every( - (f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string", - ) - ) - ) { - errors.push(prefix + "foci_preferred must be an array of transport objects"); - } - // optional parameters - if (data.created_ts !== undefined && typeof data.created_ts !== "number") { - errors.push(prefix + "created_ts must be number"); - } - - // application specific data (we first need to check if they exist) - if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string"); - - if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { - errors.push(prefix + "m.call.intent must be a string"); - } + Session = "session", +} - return errors.length === 0; -}; -type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; +type MembershipData = { kind: MembershipKind.RTC; data: RtcMembershipData } | { kind: MembershipKind.Session; data: SessionMembershipData }; // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a?: CallMembership, b?: CallMembership): boolean { @@ -252,23 +66,17 @@ export class CallMembership { ) { const eventId = matrixEvent.getId(); const sender = matrixEvent.getSender(); + const evType = matrixEvent.getType(); if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); if (sender === undefined) throw new Error("parentEvent is missing sender field"); - const sessionErrors: string[] = []; - const rtcErrors: string[] = []; - if (checkSessionsMembershipData(data, sessionErrors)) { - this.membershipData = { kind: "session", data }; - } else if (checkRtcMembershipData(data, rtcErrors, sender)) { - this.membershipData = { kind: "rtc", data }; + if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { + this.membershipData = { kind: MembershipKind.RTC, data }; + } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { + this.membershipData = { kind: MembershipKind.Session, data }; } else { - const details = - sessionErrors.length < rtcErrors.length - ? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n` - : `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`; - const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'"); - throw Error(`unknown CallMembership data.\n` + details + json); + throw Error(`'${evType} is not a known call membership type`); } this.matrixEventData = { eventId, sender }; } @@ -277,12 +85,13 @@ export class CallMembership { public get sender(): string { return this.userId; } + public get userId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.member.user_id; - case "session": + case MembershipKind.Session: default: return this.matrixEventData.sender; } @@ -299,9 +108,9 @@ export class CallMembership { public get slotId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.slot_id; - case "session": + case MembershipKind.Session: default: return slotDescriptionToId({ application: this.application, id: data.call_id }); } @@ -310,9 +119,9 @@ export class CallMembership { public get deviceId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.member.device_id; - case "session": + case MembershipKind.Session: default: return data.device_id; } @@ -321,7 +130,7 @@ export class CallMembership { public get callIntent(): RTCCallIntent | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": { + case MembershipKind.RTC: { const intent = data.application["m.call.intent"]; if (typeof intent === "string") { return intent; @@ -329,7 +138,7 @@ export class CallMembership { logger.warn("RTC membership has invalid m.call.intent"); return undefined; } - case "session": + case MembershipKind.Session: default: return data["m.call.intent"]; } @@ -345,9 +154,9 @@ export class CallMembership { public get application(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.application.type; - case "session": + case MembershipKind.Session: default: return data.application; } @@ -355,21 +164,21 @@ export class CallMembership { public get applicationData(): { type: string; [key: string]: unknown } { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.application; - case "session": + case MembershipKind.Session: default: return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } /** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/ - public get scope(): CallScope | undefined { + public get scope(): SessionMembershipData["scope"] | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return undefined; - case "session": + case MembershipKind.Session: default: return data.scope; } @@ -381,9 +190,9 @@ export class CallMembership { // synapse ignores sending state events if they have the same content. const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.member.id; - case "session": + case MembershipKind.Session: default: return (this.createdTs() ?? "").toString(); } @@ -392,10 +201,10 @@ export class CallMembership { public createdTs(): number { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: // TODO we need to read the referenced (relation) event if available to get the real created_ts return this.matrixEvent.getTs(); - case "session": + case MembershipKind.Session: default: return data.created_ts ?? this.matrixEvent.getTs(); } @@ -408,9 +217,9 @@ export class CallMembership { public getAbsoluteExpiry(): number | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return undefined; - case "session": + case MembershipKind.Session: default: // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); @@ -423,9 +232,9 @@ export class CallMembership { public getMsUntilExpiry(): number | undefined { const { kind } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return undefined; - case "session": + case MembershipKind.Session: default: // Assume that local clock is sufficiently in sync with other clocks in the distributed system. // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. @@ -440,9 +249,9 @@ export class CallMembership { public isExpired(): boolean { const { kind } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return false; - case "session": + case MembershipKind.Session: default: return this.getMsUntilExpiry()! <= 0; } @@ -468,9 +277,9 @@ export class CallMembership { public getTransport(oldestMembership: CallMembership): Transport | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.rtc_transports[0]; - case "session": + case MembershipKind.Session: switch (data.focus_active.focus_selection) { case "multi_sfu": return data.foci_preferred[0]; @@ -489,7 +298,7 @@ export class CallMembership { */ public getFocusActive(): LivekitFocusSelection | undefined { const { kind, data } = this.membershipData; - if (kind === "session") return data.focus_active; + if (kind === MembershipKind.Session) return data.focus_active; return undefined; } /** @@ -499,9 +308,9 @@ export class CallMembership { public get transports(): Transport[] { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.rtc_transports; - case "session": + case MembershipKind.Session: default: return data.foci_preferred; } diff --git a/src/matrixrtc/membership/common.ts b/src/matrixrtc/membership/common.ts new file mode 100644 index 0000000000..c492fd2dd9 --- /dev/null +++ b/src/matrixrtc/membership/common.ts @@ -0,0 +1,5 @@ +export class MatrixRTCMembershipParseError extends Error { + constructor(public readonly type: string, public readonly errors: string[]) { + super(`Does not match ${type}:\n${errors.join("\n")}`); + } +} \ No newline at end of file diff --git a/src/matrixrtc/membership/legacy.ts b/src/matrixrtc/membership/legacy.ts new file mode 100644 index 0000000000..6937cbd5f3 --- /dev/null +++ b/src/matrixrtc/membership/legacy.ts @@ -0,0 +1,114 @@ +import { EventType, IContent } from "../../matrix"; +import { LivekitFocusSelection } from "../LivekitTransport"; +import { RTCCallIntent, Transport } from "../types"; +import { MatrixRTCMembershipParseError } from "./common"; + +/** + * **Legacy** (MatrixRTC) session membership data. + * This represents the *OLD* form of MSC4143. + * Represents the `session` in the memberships section of an m.call.member event as it is on the wire. + **/ +export type SessionMembershipData = { + /** + * The RTC application defines the type of the RTC session. + */ + "application": string; + + /** + * The id of this session. + * A session can never span over multiple rooms so this id is to distinguish between + * multiple session in one room. A room wide session that is not associated with a user, + * and therefore immune to creation race conflicts, uses the `call_id: ""`. + */ + "call_id": string; + + /** + * The Matrix device ID of this session. A single user can have multiple sessions on different devices. + */ + "device_id": string; + + /** + * The focus selection system this user/membership is using. + */ + "focus_active": LivekitFocusSelection; + + /** + * A list of possible foci this user knows about. One of them might be used based on the focus_active + * selection system. + */ + "foci_preferred": Transport[]; + + /** + * Optional field that contains the creation of the session. If it is undefined the creation + * is the `origin_server_ts` of the event itself. For updates to the event this property tracks + * the `origin_server_ts` of the initial join event. + * - If it is undefined it can be interpreted as a "Join". + * - If it is defined it can be interpreted as an "Update" + */ + "created_ts"?: number; + + // Application specific data + + /** + * If the `application` = `"m.call"` this defines if it is a room or user owned call. + * There can always be one room scoped call but multiple user owned calls (breakout sessions) + */ + "scope"?: "m.room" | "m.user"; + + /** + * Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid. + * This should be set to multiple hours. The only reason it exist is to deal with failed delayed events. + * (for example caused by a homeserver crashes) + **/ + "expires"?: number; + + /** + * The intent of the call from the perspective of this user. This may be an audio call, video call or + * something else. + */ + "m.call.intent"?: RTCCallIntent; + /** + * The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device. + */ + "msc4354_sticky_key"?: string; +}; + +export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => { + const prefix = " - "; + const errors: string[] = []; + if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); + if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); + if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); + if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); + if (data.focus_active === undefined) { + errors.push(prefix + "focus_active has an invalid type"); + } + if ( + data.foci_preferred !== undefined && + !( + Array.isArray(data.foci_preferred) && + data.foci_preferred.every( + (f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string", + ) + ) + ) { + errors.push(prefix + "foci_preferred must be an array of transport objects"); + } + // optional parameters + if (data.created_ts !== undefined && typeof data.created_ts !== "number") { + errors.push(prefix + "created_ts must be number"); + } + + // application specific data (we first need to check if they exist) + if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string"); + + if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { + errors.push(prefix + "m.call.intent must be a string"); + } + + if (errors.length) { + throw new MatrixRTCMembershipParseError(EventType.GroupCallMemberPrefix, errors) + } + + return true; +}; \ No newline at end of file diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts new file mode 100644 index 0000000000..39d0ddeb2e --- /dev/null +++ b/src/matrixrtc/membership/rtc.ts @@ -0,0 +1,107 @@ +import { EventType, IContent, MXID_PATTERN, RelationType } from "../../matrix"; +import { RtcSlotEventContent, Transport } from "../types"; +import { MatrixRTCMembershipParseError } from "./common"; + +type Member = { user_id: string; device_id: string; id: string }; + +/** + * Represents the current form of MSC4143. + */ +export interface RtcMembershipData { + "slot_id": string; + "member": Member; + "m.relates_to"?: { + event_id: string; + rel_type: RelationType.Reference; + }; + "application": RtcSlotEventContent["application"]; + "rtc_transports": Transport[]; + "versions": string[]; + "msc4354_sticky_key"?: string; + "sticky_key"?: string; +} + +export const checkRtcMembershipData = ( + data: IContent, + referenceUserId: string, +): data is RtcMembershipData => { + const errors: string[] = []; + const prefix = " - "; + + // required fields + if (typeof data.slot_id !== "string") { + errors.push(prefix + "slot_id must be string"); + } else { + if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"'); + } + if (typeof data.member !== "object" || data.member === null) { + errors.push(prefix + "member must be an object"); + } else { + if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); + else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); + // This is not what the spec enforces but there currently are no rules what power levels are required to + // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there + // is a proper definition when this is allowed. + else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender"); + if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); + if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); + } + if (typeof data.application !== "object" || data.application === null) { + errors.push(prefix + "application must be an object"); + } else { + if (typeof data.application.type !== "string") { + errors.push(prefix + "application.type must be a string"); + } else { + if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"'); + } + } + if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) { + errors.push(prefix + "rtc_transports must be an array"); + } else { + // validate that each transport has at least a string 'type' + for (const t of data.rtc_transports) { + if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") { + errors.push(prefix + "rtc_transports entries must be objects with a string type"); + break; + } + } + } + if (data.versions === undefined || !Array.isArray(data.versions)) { + errors.push(prefix + "versions must be an array"); + } else if (!data.versions.every((v) => typeof v === "string")) { + errors.push(prefix + "versions must be an array of strings"); + } + + // optional fields + if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) { + errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined"); + } + if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + errors.push(prefix + "sticky_key must be a string"); + } + if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") { + errors.push(prefix + "msc4354_sticky_key must be a string"); + } + if ( + data.sticky_key !== undefined && + data.msc4354_sticky_key !== undefined && + data.sticky_key !== data.msc4354_sticky_key + ) { + errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined"); + } + if (data["m.relates_to"] !== undefined) { + const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; + if (typeof rel !== "object" || rel === null) { + errors.push(prefix + "m.relates_to must be an object if provided"); + } else { + if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string"); + if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference"); + } + } + + if (errors.length) { + throw new MatrixRTCMembershipParseError(EventType.RTCMembership, errors) + } + + return true; +}; \ No newline at end of file From 4ca30bed25f489ef554ab507c30f03b178348389 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 30 Oct 2025 14:44:56 +0000 Subject: [PATCH 05/13] More refactors --- spec/unit/matrixrtc/CallMembership.spec.ts | 91 ++++---- spec/unit/matrixrtc/LivekitTransport.spec.ts | 11 - spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 197 ++++++++---------- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 46 ++-- spec/unit/matrixrtc/MembershipManager.spec.ts | 31 ++- .../matrixrtc/RTCEncryptionManager.spec.ts | 4 +- spec/unit/matrixrtc/RoomKeyTransport.spec.ts | 4 +- spec/unit/matrixrtc/mocks.ts | 101 +++++++-- src/@types/event.ts | 7 +- src/matrixrtc/CallApplication.ts | 5 +- src/matrixrtc/CallMembership.ts | 26 +-- src/matrixrtc/LivekitTransport.ts | 14 -- src/matrixrtc/MatrixRTCSession.ts | 49 +++-- src/matrixrtc/membership/legacy.ts | 15 +- src/matrixrtc/membership/rtc.ts | 15 +- src/matrixrtc/types.ts | 4 +- 16 files changed, 324 insertions(+), 296 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 95c7140b13..2193f96bd9 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type MatrixEvent } from "../../../src"; +import { SessionMembershipData } from "src/matrixrtc/membership/legacy"; +import { EventType, type MatrixEvent } from "../../../src"; import { CallMembership, - type SessionMembershipData, DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, } from "../../../src/matrixrtc/CallMembership"; -import { membershipTemplate } from "./mocks"; +import { sessionMembershipTemplate } from "./mocks"; +import { RtcMembershipData } from "src/matrixrtc/membership/rtc"; -function makeMockEvent(originTs = 0): MatrixEvent { +function makeMockEvent(eventType: EventType.RTCMembership|EventType.GroupCallMemberPrefix, originTs = 0): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), getId: jest.fn().mockReturnValue("$eventid"), + getType: jest.fn().mockReturnValue(eventType), } as unknown as MatrixEvent; } @@ -53,49 +54,49 @@ describe("CallMembership", () => { it("rejects membership with no device_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { device_id: undefined })); }).toThrow(); }); it("rejects membership with no call_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { call_id: undefined })); }).toThrow(); }); it("allow membership with no scope", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { scope: undefined })); }).not.toThrow(); }); it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix, 12345), membershipTemplate); expect(membership.createdTs()).toEqual(12345); }); it("uses created_ts if present", () => { const membership = new CallMembership( - makeMockEvent(12345), + makeMockEvent(EventType.GroupCallMemberPrefix, 12345), Object.assign({}, membershipTemplate, { created_ts: 67890 }), ); expect(membership.createdTs()).toEqual(67890); }); it("considers memberships unexpired if local age low enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1)); expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false); }); it("considers memberships expired if local age large enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1)); expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true); }); it("returns preferred foci", () => { - const fakeEvent = makeMockEvent(); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix); const mockFocus = { type: "this_is_a_mock_focus" }; const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); expect(membership.transports).toEqual([mockFocus]); @@ -103,9 +104,9 @@ describe("CallMembership", () => { describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), membershipTemplate); it("gets the correct active transport with oldest_membership", () => { - const membership = new CallMembership(makeMockEvent(), { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "oldest_membership" }, @@ -118,21 +119,21 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); }); - it("gets the correct active transport with multi_sfu", () => { - const membership = new CallMembership(makeMockEvent(), { + it("no longer supports multi_sfu", () => { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "multi_sfu" }, }); // if we are the oldest member we use our focus. - expect(membership.getTransport(membership)).toStrictEqual(mockFocus); + expect(membership.getTransport(membership)).toStrictEqual(undefined); // If there is an older member we still use our own focus in multi sfu. - expect(membership.getTransport(oldestMembership)).toBe(mockFocus); + expect(membership.getTransport(oldestMembership)).toBe(undefined); }); it("does not provide focus if the selection method is unknown", () => { - const membership = new CallMembership(makeMockEvent(), { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "unknown" }, @@ -143,7 +144,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), membershipTemplate); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -184,7 +185,7 @@ describe("CallMembership", () => { const membershipTemplate: RtcMembershipData = { slot_id: "m.call#", application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, - member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + member: { claimed_user_id: "@alice:example.org", claimed_device_id: "AAAAAAA", id: "xyzHASHxyz" }, rtc_transports: [{ type: "livekit" }], versions: [], msc4354_sticky_key: "abc123", @@ -192,29 +193,29 @@ describe("CallMembership", () => { it("rejects membership with no slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: undefined }); }).toThrow(); }); it("rejects membership with invalid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: "invalid_slot_id" }); }).toThrow(); }); it("accepts membership with valid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: "m.call#" }); }).not.toThrow(); }); it("rejects membership with no application", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, application: undefined }); }).toThrow(); }); it("rejects membership with incorrect application", () => { expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, application: { wrong_type_key: "unknown" }, }); @@ -223,34 +224,34 @@ describe("CallMembership", () => { it("rejects membership with no member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: undefined }); }).toThrow(); }); it("rejects membership with incorrect member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { i: "test" } }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id_wrong: "test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id: "@@test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, }); @@ -258,41 +259,41 @@ describe("CallMembership", () => { }); it("rejects membership with incorrect sticky_key", () => { expect(() => { - new CallMembership(makeMockEvent(), membershipTemplate); + new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, sticky_key: 1, msc4354_sticky_key: undefined, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, sticky_key: "1", msc4354_sticky_key: undefined, }); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: undefined }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: 1, sticky_key: "valid", }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: "valid", sticky_key: "valid", }); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: "valid_but_different", sticky_key: "valid", @@ -310,11 +311,11 @@ describe("CallMembership", () => { describe("getTransport", () => { it("gets the correct active transport with oldest_membership", () => { - const oldestMembership = new CallMembership(makeMockEvent(), { + const oldestMembership = new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, rtc_transports: [{ type: "oldest_transport" }], }); - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); @@ -324,7 +325,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -371,8 +372,8 @@ describe("CallMembership", () => { beforeEach(() => { // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = new CallMembership(fakeEvent!, membershipTemplate); + fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); + membership = new CallMembership(fakeEvent!, sessionMembershipTemplate); jest.useFakeTimers(); }); diff --git a/spec/unit/matrixrtc/LivekitTransport.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts index 04f04a1357..94e82af8ff 100644 --- a/spec/unit/matrixrtc/LivekitTransport.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -16,7 +16,6 @@ limitations under the License. import { isLivekitTransport, - isLivekitFocusSelection, isLivekitTransportConfig, } from "../../../src/matrixrtc/LivekitTransport"; @@ -40,16 +39,6 @@ describe("LivekitFocus", () => { isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), ).toBeFalsy(); }); - it("isLivekitFocusActive", () => { - expect( - isLivekitFocusSelection({ - type: "livekit", - focus_selection: "oldest_membership", - }), - ).toBeTruthy(); - expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); - }); it("isLivekitFocusConfig", () => { expect( isLivekitTransportConfig({ diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index a934e35cd6..31f480620b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { + Direction, encodeBase64, type EventTimeline, EventType, @@ -25,11 +26,12 @@ import { } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; -import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; +import { SlotDescription, Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { makeMockEvent, makeMockRoom, - membershipTemplate, + sessionMembershipTemplate, + rtcMembershipTemplate, makeKey, type MembershipData, mockRoomState, @@ -43,7 +45,7 @@ const mockFocus = { type: "mock" }; const textEncoder = new TextEncoder(); -const callSession = { id: "", application: "m.call" }; +const callSession: SlotDescription = { id: "", application: "m.call" }; describe("MatrixRTCSession", () => { let client: MatrixClient; @@ -92,9 +94,10 @@ describe("MatrixRTCSession", () => { testCreateSticky: true, }, ])( - "roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", + "sessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { + const membershipTemplate = testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue(undefined), @@ -113,6 +116,7 @@ describe("MatrixRTCSession", () => { }); it("creates a room-scoped session from room state", () => { + const membershipTemplate = testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( @@ -131,9 +135,14 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", - }); + const testMembership = testConfig.testCreateSticky ? { + ...rtcMembershipTemplate, + slot_id: "not-m.call#", + application: { + ...rtcMembershipTemplate.application, + type: "not-m.call", + }, + } : { ...sessionMembershipTemplate, application: "not-m.call#" } const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -145,7 +154,7 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, membershipTemplate, { + const testMembership = Object.assign({}, sessionMembershipTemplate, { call_id: "not-empty", scope: "m.room", }); @@ -161,10 +170,10 @@ describe("MatrixRTCSession", () => { it("ignores expired memberships events", () => { jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); + const expiredMembership = Object.assign({}, sessionMembershipTemplate); expiredMembership.expires = 1000; expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([sessionMembershipTemplate, expiredMembership], testConfig.testCreateSticky); jest.advanceTimersByTime(2000); sess = MatrixRTCSession.sessionForSlot( @@ -179,7 +188,7 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([sessionMembershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -192,7 +201,7 @@ describe("MatrixRTCSession", () => { it("ignores memberships events with no sender", () => { // Force the sender to be undefined. - const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([{ ...sessionMembershipTemplate, user_id: "" }], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -206,7 +215,7 @@ describe("MatrixRTCSession", () => { it("honours created_ts", () => { jest.useFakeTimers(); jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); + const expiredMembership = Object.assign({}, sessionMembershipTemplate); expiredMembership.created_ts = 500; expiredMembership.expires = 1000; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); @@ -238,26 +247,9 @@ describe("MatrixRTCSession", () => { getSender: jest.fn().mockReturnValue("@mock:user.example"), getTs: jest.fn().mockReturnValue(1000), getLocalAge: jest.fn().mockReturnValue(0), - }; + } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline.mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - } as unknown as EventTimeline); + mockRoom.getLiveTimeline().getState(Direction.Forward)?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -274,26 +266,9 @@ describe("MatrixRTCSession", () => { getSender: jest.fn().mockReturnValue("@mock:user.example"), getTs: jest.fn().mockReturnValue(1000), getLocalAge: jest.fn().mockReturnValue(0), - }; + } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline.mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - } as unknown as EventTimeline); + mockRoom.getLiveTimeline().getState(Direction.Forward)?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -304,7 +279,7 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); + const testMembership = Object.assign({}, sessionMembershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); const sess = MatrixRTCSession.sessionForSlot( @@ -317,7 +292,7 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); + const testMembership = Object.assign({}, sessionMembershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); sess = MatrixRTCSession.sessionForSlot( @@ -331,15 +306,15 @@ describe("MatrixRTCSession", () => { }, ); - describe("roomsessionForSlot combined state", () => { + describe("sessionForSlot combined state", () => { it("perfers sticky events when both membership and sticky events appear for the same user", () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...membershipTemplate, - msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + ...sessionMembershipTemplate, + msc4354_sticky_key: `_${sessionMembershipTemplate.user_id}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, ); @@ -361,14 +336,14 @@ describe("MatrixRTCSession", () => { }); it("combines sticky and membership events when both exist", () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); const stickyUserId = "@stickyev:user.example"; mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...membershipTemplate, + ...sessionMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + msc4354_sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, @@ -392,12 +367,12 @@ describe("MatrixRTCSession", () => { expect(memberships[0].isExpired()).toEqual(false); // Then state - expect(memberships[1].sender).toEqual(membershipTemplate.user_id); + expect(memberships[1].sender).toEqual(sessionMembershipTemplate.user_id); expect(sess?.slotDescription.id).toEqual(""); }); it("handles an incoming sticky event to an existing session", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); const stickyUserId = "@stickyev:user.example"; sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { @@ -407,9 +382,9 @@ describe("MatrixRTCSession", () => { expect(sess.memberships.length).toEqual(1); const stickyEv = mockRTCEvent( { - ...membershipTemplate, + ...sessionMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + msc4354_sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, @@ -428,9 +403,9 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }), - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 3000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); @@ -450,8 +425,8 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { "m.call.intent": intentA }), - Object.assign({}, membershipTemplate, { "m.call.intent": intentB }), + Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentA }), + Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentB }), ]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); @@ -470,13 +445,13 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(3000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { + Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 500, foci_preferred: [firstPreferredFocus], }), - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); @@ -489,13 +464,13 @@ describe("MatrixRTCSession", () => { }); it("does not provide focus if the selection method is unknown", () => { const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { + Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 500, foci_preferred: [firstPreferredFocus], }), - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); @@ -567,7 +542,7 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [{ ...sessionMembershipTemplate, user_id: client.getUserId()! }]); sess!.onRTCSessionMemberUpdate(); const ownMembershipId = sess?.memberships[0].eventId; @@ -631,7 +606,7 @@ describe("MatrixRTCSession", () => { mockRoomState(mockRoom, [ { - ...membershipTemplate, + ...sessionMembershipTemplate, "user_id": client.getUserId()!, // This is what triggers the intent type on the notification event. "m.call.intent": "audio", @@ -688,13 +663,13 @@ describe("MatrixRTCSession", () => { it("doesn't send a notification when joining an existing call", async () => { // Add another member to the call so that it is considered an existing call - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess!.onRTCSessionMemberUpdate(); // Simulate a join, including the update to the room state sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [sessionMembershipTemplate, { ...sessionMembershipTemplate, user_id: client.getUserId()! }]); sess!.onRTCSessionMemberUpdate(); expect(client.sendEvent).not.toHaveBeenCalled(); @@ -706,9 +681,9 @@ describe("MatrixRTCSession", () => { await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); // But this time we want to simulate a race condition in which we receive a state event // from someone else, starting the call before our own state event has been sent - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess!.onRTCSessionMemberUpdate(); - mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [sessionMembershipTemplate, { ...sessionMembershipTemplate, user_id: client.getUserId()! }]); sess!.onRTCSessionMemberUpdate(); // We assume that the responsibility to send a notification, if any, lies with the other @@ -719,7 +694,7 @@ describe("MatrixRTCSession", () => { describe("onMembershipsChanged", () => { it("does not emit if no membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); @@ -730,7 +705,7 @@ describe("MatrixRTCSession", () => { }); it("emits on membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); @@ -901,10 +876,10 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { // session with two members - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); + const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // joining will trigger an initial key send @@ -920,14 +895,14 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); // member2 leaves triggering key rotation - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess.onRTCSessionMemberUpdate(); // member2 re-joins which should trigger an immediate re-send const keysSentPromise2 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); @@ -953,7 +928,7 @@ describe("MatrixRTCSession", () => { it("re-sends key if a new member joins", async () => { jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { @@ -974,11 +949,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -997,8 +972,8 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - const member1 = membershipTemplate; - const member2 = Object.assign({}, membershipTemplate, { + const member1 = sessionMembershipTemplate; + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); @@ -1042,9 +1017,9 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - const member1 = { ...membershipTemplate, created_ts: 1000 }; + const member1 = { ...sessionMembershipTemplate, created_ts: 1000 }; const member2 = { - ...membershipTemplate, + ...sessionMembershipTemplate, created_ts: 1000, device_id: "BBBBBBB", }; @@ -1110,10 +1085,10 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const KEY_DELAY = 3000; - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); + const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMyEncryptionKeyChanged = jest.fn(); @@ -1143,7 +1118,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess.onRTCSessionMemberUpdate(); jest.advanceTimersByTime(KEY_DELAY); @@ -1190,7 +1165,7 @@ describe("MatrixRTCSession", () => { const membersToTest = 258; const members: MembershipData[] = []; for (let i = 0; i < membersToTest; i++) { - members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` })); + members.push(Object.assign({}, sessionMembershipTemplate, { device_id: `DEVICE${i}` })); } jest.useFakeTimers(); try { @@ -1229,7 +1204,7 @@ describe("MatrixRTCSession", () => { const realSetTimeout = setTimeout; jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { @@ -1245,11 +1220,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); await new Promise((resolve) => { @@ -1270,7 +1245,7 @@ describe("MatrixRTCSession", () => { sendToDeviceMock.mockImplementation(resolve); }); - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { @@ -1293,7 +1268,7 @@ describe("MatrixRTCSession", () => { describe("receiving", () => { it("collects keys from encryption events", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1318,7 +1293,7 @@ describe("MatrixRTCSession", () => { }); it("collects keys at non-zero indices", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1344,7 +1319,7 @@ describe("MatrixRTCSession", () => { }); it("collects keys by merging", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1395,7 +1370,7 @@ describe("MatrixRTCSession", () => { }); it("ignores older keys at same index", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1454,7 +1429,7 @@ describe("MatrixRTCSession", () => { }); it("key timestamps are treated as monotonic", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1498,7 +1473,7 @@ describe("MatrixRTCSession", () => { }); it("ignores keys event for the local participant", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); @@ -1521,7 +1496,7 @@ describe("MatrixRTCSession", () => { it("tracks total age statistics for collected keys", async () => { jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // defaults to getTs() @@ -1577,7 +1552,7 @@ describe("MatrixRTCSession", () => { }); describe("read status", () => { it("returns the correct probablyLeft status", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.probablyLeft).toBe(undefined); @@ -1593,7 +1568,7 @@ describe("MatrixRTCSession", () => { }); it("returns membershipStatus once joinRoomSession got called", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.membershipStatus).toBe(undefined); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index a6d862cb0b..ee52e9498d 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -17,13 +17,15 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; +import { makeMockRoom, type MembershipData, sessionMembershipTemplate, mockRoomState, mockRTCEvent, rtcMembershipTemplate } from "./mocks"; import { logger } from "../../../src/logger"; +import { slotDescriptionToId } from "../../../src/matrixrtc"; -describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( +describe.each([{ eventKind: "sticky" }, /*{ eventKind: "memberState" }*/])( "MatrixRTCSessionManager ($eventKind)", ({ eventKind }) => { let client: MatrixClient; + let membershipTemplate: MembershipData; function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void { if (eventKind === "memberState") { @@ -40,6 +42,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( beforeEach(() => { client = new MatrixClient({ baseUrl: "base_url" }); client.matrixRTC.start(); + membershipTemplate = eventKind ? rtcMembershipTemplate : sessionMembershipTemplate; }); afterEach(() => { @@ -91,14 +94,15 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); - it("Fires correctly with custom sessionDescription", () => { + it("Fires correctly with custom slotDescription", () => { const onStarted = jest.fn(); const onEnded = jest.fn(); - // create a session manager with a custom session description - const sessionManager = new MatrixRTCSessionManager(logger, client, { + const slotDescription = { id: "test", application: "m.notCall", - }); + }; + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, slotDescription); // manually start the session manager (its not the default one started by the client) sessionManager.start(); @@ -106,19 +110,30 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); try { - // Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id - const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }]; - const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky"); + // Create a session for applicaation m.other, we ignore this session because it has the wrong application type. + const room1MembershipData: MembershipData[] = eventKind === "sticky" ? [{ ...membershipTemplate, application: { + ...rtcMembershipTemplate.application, + type: "m.call" + }}] : [{ ...membershipTemplate, application: "m.call" }]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky", { application: "m.call", id: ""}); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); expect(onStarted).not.toHaveBeenCalled(); onStarted.mockClear(); // Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id - const room2MembershipData: MembershipData[] = [ - { ...membershipTemplate, application: "m.notCall", call_id: "test" }, - ]; - const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky"); + const room2MembershipData: MembershipData[] = eventKind === "sticky" ? [ + { ...membershipTemplate, application: { + ...rtcMembershipTemplate.application, + type: slotDescription.application, + }, slot_id: slotDescriptionToId(slotDescription) }, + ] : [{ + ...membershipTemplate, + application: slotDescription.application, + call_id: slotDescription.id, + }]; + const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky", slotDescription); + console.log({room2: room2.roomId}) jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); expect(onStarted).toHaveBeenCalled(); @@ -143,7 +158,10 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( it("Doesn't fire event if unrelated sessions ends", () => { const onEnded = jest.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const membership: MembershipData[] = eventKind === "sticky" ? [{ ...membershipTemplate, application: { + ...rtcMembershipTemplate.application, + type: "m.other_app", + }}] : [{ ...membershipTemplate, application: "m.other_app" }]; const room1 = makeMockRoom(membership, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRoom").mockReturnValue(room1); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 8c4f90c002..8324c87699 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -29,11 +29,10 @@ import { MembershipManagerEvent, Status, type Transport, - type SessionMembershipData, - type LivekitFocusSelection, } from "../../../src/matrixrtc"; -import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; +import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { SessionMembershipData } from "src/matrixrtc/membership/legacy.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -76,21 +75,21 @@ const callSession = { id: "", application: "m.call" }; describe("MembershipManager", () => { let client: MockClient; let room: Room; - const focusActive: LivekitFocusSelection = { + const focusActive = Object.freeze({ focus_selection: "oldest_membership", type: "livekit", - }; - const focus: Transport = { + }); + const focus: Transport = Object.freeze({ type: "livekit", livekit_service_url: "https://active.url", livekit_alias: "!active:active.url", - }; + }); beforeEach(() => { // Default to fake timers. jest.useFakeTimers(); client = makeMockClient("@alice:example.org", "AAAAAAA"); - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); // Provide a default mock that is like the default "non error" server behaviour. (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); @@ -350,7 +349,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await jest.advanceTimersByTimeAsync(RESTART_DELAY); // first simulate the sync, then resolve sending the delayed event. - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); resolve({ delay_id: "id" }); // Let the scheduler run one iteration so that the new join gets sent await jest.runOnlyPendingTimersAsync(); @@ -433,7 +432,7 @@ describe("MembershipManager", () => { describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { const manager = new MembershipManager({}, room, client, callSession); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); @@ -450,7 +449,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockClear(); await manager.onRTCSessionMemberUpdate([ - mockCallMembership(membershipTemplate, room.roomId), + mockCallMembership(sessionMembershipTemplate, room.roomId), mockCallMembership( { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, room.roomId, @@ -473,7 +472,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockClear(); // Our own membership is removed: - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); await jest.advanceTimersByTimeAsync(1); expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); @@ -496,7 +495,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await jest.advanceTimersByTimeAsync(10_000); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); resolve({ delay_id: "id" }); await jest.advanceTimersByTimeAsync(10_000); @@ -865,7 +864,7 @@ describe("MembershipManager", () => { const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); + const membership = mockCallMembership({ ...sessionMembershipTemplate, user_id: client.getUserId()! }, room.roomId); await manager.onRTCSessionMemberUpdate([membership]); await manager.updateCallIntent("video"); expect(client.sendStateEvent).toHaveBeenCalledTimes(2); @@ -879,7 +878,7 @@ describe("MembershipManager", () => { manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership( - { ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, + { ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, room.roomId, ); await manager.onRTCSessionMemberUpdate([membership]); @@ -948,7 +947,7 @@ describe("MembershipManager", () => { it("Should prefix log with MembershipManager used", () => { const client = makeMockClient("@alice:example.org", "AAAAAAA"); - const room = makeMockRoom([membershipTemplate]); + const room = makeMockRoom([sessionMembershipTemplate]); const membershipManager = new MembershipManager(undefined, room, client, callSession); diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index de88d47f60..00098a5eae 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -20,7 +20,7 @@ import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManage import { type CallMembership, type Statistics } from "../../../src/matrixrtc"; import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts"; -import { membershipTemplate, mockCallMembership } from "./mocks.ts"; +import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts"; import { decodeBase64, TypedEventEmitter } from "../../../src"; import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts"; import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts"; @@ -864,7 +864,7 @@ describe("RTCEncryptionManager", () => { function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership { return mockCallMembership( - { ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, + { ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, "!room:id", ); } diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index f08cced850..59f323663c 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks"; +import { makeMockEvent, makeMockRoom, sessionMembershipTemplate, makeKey } from "./mocks"; import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport"; import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; import { EventType, MatrixClient, RoomEvent } from "../../../src"; @@ -48,7 +48,7 @@ describe("RoomKeyTransport", () => { roomEventEncryptionKeysReceivedTotalAge: 0, }, }; - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); client = new MatrixClient({ baseUrl: "base_url" }); client.matrixRTC.start(); transport = new RoomKeyTransport(room, client, statistics, { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 24982afe25..306c070073 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -18,12 +18,16 @@ import { EventEmitter } from "stream"; import { type Mocked } from "jest-mock"; import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; -import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { CallMembership } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; +import { DefaultCallApplicationDescription, RtcSlotEventContent, SlotDescription, slotDescriptionToId } from "../../../src/matrixrtc"; +import { mkMatrixEvent } from "../../../src/testing"; +import type { SessionMembershipData } from "src/matrixrtc/membership/legacy"; +import type { RtcMembershipData } from "src/matrixrtc/membership/rtc"; -export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; +export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; -export const membershipTemplate: SessionMembershipData & { user_id: string } = { +export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { application: "m.call", call_id: "", user_id: "@mock:user.example", @@ -44,6 +48,35 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { ], }; +export const rtcMembershipTemplate: RtcMembershipData&{user_id: string} = { + slot_id: "m.call#", + application: { + type: "m.call", + "m.call.id": "" + }, + user_id: "@mock:user.example", + member: { + claimed_user_id: "@mock:user.example", + claimed_device_id: "AAAAAAA", + id: "ea2MaingeeMo" + }, + sticky_key: "ea2MaingeeMo", + rtc_transports: [ + { + livekit_alias: "!alias:something.org", + livekit_service_url: "https://livekit-jwt.something.io", + type: "livekit", + }, + { + livekit_alias: "!alias:something.org", + livekit_service_url: "https://livekit-jwt.something.dev", + type: "livekit", + }, + ], + versions: [], +}; + + export type MockClient = Pick< MatrixClient, | "getUserId" @@ -76,10 +109,11 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { export function makeMockRoom( membershipData: MembershipData[], useStickyEvents = false, + slotDescription = DefaultCallApplicationDescription, ): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId, useStickyEvents ? slotDescription : undefined); const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, @@ -100,39 +134,62 @@ export function makeMockRoom( }) as unknown as Mocked void }>; } -function makeMockRoomState(membershipData: MembershipData[], roomId: string) { +function makeMockRoomState(membershipData: MembershipData[], roomId: string, slotDescription?: SlotDescription) { const events = membershipData.map((m) => mockRTCEvent(m, roomId)); const keysAndEvents = events.map((e) => { const data = e.getContent() as SessionMembershipData; return [`_${e.sender?.userId}_${data.device_id}`]; }); + let slotEvent: MatrixEvent|undefined; + + if (slotDescription) { + // Add a slot + const stateKey = slotDescriptionToId(slotDescription); + slotEvent = mkMatrixEvent({ + stateKey: stateKey, + roomId, + sender: "@anyadmin:example.org", + type: EventType.RTCSlot, + content: { + application: { + type: slotDescription.application, + }, + slot_id: slotDescriptionToId(slotDescription), + } satisfies RtcSlotEventContent, + }); + } return { on: jest.fn(), off: jest.fn(), - getStateEvents: (_: string, stateKey: string) => { + getStateEvents: (type: string, stateKey: string) => { + if (slotEvent && type === EventType.RTCSlot && stateKey === slotEvent.getStateKey()) return slotEvent; + if (type !== EventType.GroupCallMemberPrefix) return null; if (stateKey !== undefined) return keysAndEvents.find(([k]) => k === stateKey)?.[1]; return events; }, - events: - events.length === 0 - ? new Map() - : new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey), - get: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey)?.[1], - values: () => events, - }, - ], - ]), + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey), + get: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey)?.[1], + values: () => events, + }, + ], + ...(slotEvent ? [[EventType.RTCSlot, { + size: () => true, + has: (stateKey: string) => slotEvent.getStateKey() === stateKey, + get: (stateKey: string) => slotEvent.getStateKey() === stateKey ? slotEvent : undefined, + values: () => [slotEvent], + }]] : []), + ] as any), }; } -export function mockRoomState(room: Room, membershipData: MembershipData[]): void { - room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId)); +export function mockRoomState(room: Room, membershipData: MembershipData[], slotDescription?: SlotDescription): void { + room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId, slotDescription)); } export function makeMockEvent( diff --git a/src/@types/event.ts b/src/@types/event.ts index e3f2d7e651..747409d124 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -59,11 +59,12 @@ import { type RtcSlotEventContent, } from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; -import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; import { type POLICIES_ACCOUNT_EVENT_TYPE } from "../models/invites-ignorer-types.ts"; +import { RtcMembershipData } from "src/matrixrtc/membership/rtc.ts"; +import { SessionMembershipData } from "src/matrixrtc/membership/legacy.ts"; export enum EventType { // Room state events @@ -149,6 +150,10 @@ export enum EventType { // Group call events GroupCallPrefix = "org.matrix.msc3401.call", + /** + * Legacy call membership. + * @see RTCMembership + */ GroupCallMemberPrefix = "org.matrix.msc3401.call.member", // MatrixRTC events diff --git a/src/matrixrtc/CallApplication.ts b/src/matrixrtc/CallApplication.ts index e0413a0c9c..901d0460dc 100644 --- a/src/matrixrtc/CallApplication.ts +++ b/src/matrixrtc/CallApplication.ts @@ -2,7 +2,7 @@ import { RtcSlotEventContent, SlotDescription } from "./types"; export const DefaultCallApplicationDescription: SlotDescription = { id: "", - application: "m.call", + application: "m.call" }; /** @@ -13,6 +13,7 @@ export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { "type": "m.call"; "m.call.id"?: string; }; + slot_id: `${string}#${string}`, } /** * Default slot for a room using "m.call". @@ -20,6 +21,6 @@ export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { export const DefaultCallApplicationSlot: CallSlotEventContent = { application: { "type": "m.call", - "m.call.id": "", }, + slot_id: "m.call#", }; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index b0fba94b9c..70cd8298d8 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { deepCompare } from "../utils.ts"; -import { type LivekitFocusSelection } from "./LivekitTransport.ts"; import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; import { logger } from "../logger.ts"; @@ -90,7 +89,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case MembershipKind.RTC: - return data.member.user_id; + return data.member.claimed_user_id; case MembershipKind.Session: default: return this.matrixEventData.sender; @@ -120,7 +119,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case MembershipKind.RTC: - return data.member.device_id; + return data.member.claimed_device_id; case MembershipKind.Session: default: return data.device_id; @@ -280,27 +279,16 @@ export class CallMembership { case MembershipKind.RTC: return data.rtc_transports[0]; case MembershipKind.Session: - switch (data.focus_active.focus_selection) { - case "multi_sfu": - return data.foci_preferred[0]; - case "oldest_membership": - if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; - if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); - break; + if (data.focus_active.focus_selection === "oldest_membership") { + // For legacy events we only support "oldest_membership" + if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; + if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); } + break; } return undefined; } - /** - * The focus_active filed of the session membership (m.call.member). - * @deprecated focus_active is not used and will be removed in future versions. - */ - public getFocusActive(): LivekitFocusSelection | undefined { - const { kind, data } = this.membershipData; - if (kind === MembershipKind.Session) return data.focus_active; - return undefined; - } /** * The value of the `rtc_transports` field for RTC memberships (m.rtc.member). * Or the value of the `foci_preferred` field for legacy session memberships (m.call.member). diff --git a/src/matrixrtc/LivekitTransport.ts b/src/matrixrtc/LivekitTransport.ts index eda11f554e..d9b1478864 100644 --- a/src/matrixrtc/LivekitTransport.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -30,17 +30,3 @@ export interface LivekitTransport extends LivekitTransportConfig { export const isLivekitTransport = (object: any): object is LivekitTransport => isLivekitTransportConfig(object) && "livekit_alias" in object; - -/** - * @deprecated, this is just needed for the old focus active / focus fields of a call membership. - * Not needed for new implementations. - */ -export interface LivekitFocusSelection extends Transport { - type: "livekit"; - focus_selection: "oldest_membership" | "multi_sfu"; -} -/** - * @deprecated see LivekitFocusSelection - */ -export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => - object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 1bae53bcd4..63da7c8132 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Logger, logger as rootLogger } from "../logger.ts"; +import { logger, type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { Direction, EventTimeline } from "../models/event-timeline.ts"; import { type Room } from "../models/room.ts"; @@ -22,7 +22,7 @@ import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; -import { CallMembership, RtcMembershipData } from "./CallMembership.ts"; +import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; @@ -56,6 +56,7 @@ import { type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; import { DefaultCallApplicationSlot } from "./CallApplication.ts"; import { slotDescriptionToId } from "./utils.ts"; +import { RtcMembershipData } from "./membership/rtc.ts"; /** * Events emitted by MatrixRTCSession @@ -304,16 +305,19 @@ export class MatrixRTCSession extends TypedEventEmitter< * @returns The contents of the slot event, or null if no matching slot found. */ public static getRtcSlot( - room: Pick, + room: Pick, slotDescription: SlotDescription, ): RtcSlotEventContent | null { const slotId = slotDescriptionToId(slotDescription); const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); if (!slot) { + console.log(room.getLiveTimeline().getState(Direction.Forward)?.events); + logger.debug(`No slot found for ${room.roomId}`); return null; } const slotContent = slot.getContent>(); if (!slotContent.application || typeof slotContent.application !== "object") { + logger.debug(`Invalid app for ${room.roomId}`); // Invalid slot content. return null; } @@ -321,16 +325,22 @@ export class MatrixRTCSession extends TypedEventEmitter< "type" in slotContent.application === false || slotContent.application.type !== slotDescription.application ) { - // Mistmached or missing application type. + logger.debug(`Mismatched app for ${room.roomId}`); + // Mismached or missing application type. return null; } - // Check the parameters of the slot match the expected parameters. + if ( - deepCompare({ type: slotDescription.application, ...slotDescription.parameters }, slotContent.application) + "slot_id" in slotContent === false || + typeof slotContent.slot_id !== "string" || + !slotContent.slot_id.startsWith(slotContent.application.type + "#") ) { - return slotContent as RtcSlotEventContent; + logger.debug(`Mismatched app for ${room.roomId}`, slotContent); + // Mismached or missing application type. + return null; } - return null; + + return slotContent as RtcSlotEventContent; } /** @@ -357,18 +367,20 @@ export class MatrixRTCSession extends TypedEventEmitter< // Has a slot and the application parameters match, fetch sticky members. callMemberEvents = [...room._unstable_getStickyEvents()].filter((e) => { if (e.getType() !== EventType.RTCMembership) { + console.log("Invalid type"); return false; } const content = e.getContent(); // Ensure the slot ID of the membership matches the state if (content.slot_id !== slotId) { + console.log("Invalid slot ID", content.slot_id, slotId); + return false; + } + if (content.application.type !== slotDescription.application) { + console.log("Invalid application.type", content.application.type, slotDescription.application); return false; } - // Ensure the application data matches. - return deepCompare(e.getContent().application, { - type: slotDescription.application, - ...slotDescription.parameters, - }); + return true; }); } // otherwise, the slot wasn't valid and we can skip these members if (listenForMemberStateEvents) { @@ -417,10 +429,9 @@ export class MatrixRTCSession extends TypedEventEmitter< } try { const membership = new CallMembership(memberEvent, membershipData); - if (!deepCompare(membership.slotDescription, slotDescription)) { logger.info( - `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`, + `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)} !== ${JSON.stringify(slotDescription)}`, ); continue; } @@ -448,6 +459,7 @@ export class MatrixRTCSession extends TypedEventEmitter< ); } + return callMemberships; } @@ -711,13 +723,6 @@ export class MatrixRTCSession extends TypedEventEmitter< return oldestMembership?.getTransport(oldestMembership); } - /** - * The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus) - * @deprecated does not work with m.rtc.member. Do not rely on it. - */ - public getActiveFocus(): Transport | undefined { - return this.getOldestMembership()?.getFocusActive(); - } public getOldestMembership(): CallMembership | undefined { return this.memberships[0]; } diff --git a/src/matrixrtc/membership/legacy.ts b/src/matrixrtc/membership/legacy.ts index 6937cbd5f3..3574403caa 100644 --- a/src/matrixrtc/membership/legacy.ts +++ b/src/matrixrtc/membership/legacy.ts @@ -1,5 +1,4 @@ import { EventType, IContent } from "../../matrix"; -import { LivekitFocusSelection } from "../LivekitTransport"; import { RTCCallIntent, Transport } from "../types"; import { MatrixRTCMembershipParseError } from "./common"; @@ -29,8 +28,12 @@ export type SessionMembershipData = { /** * The focus selection system this user/membership is using. + * NOTE: This is still included for legacy reasons, but not consumed by the SDK. */ - "focus_active": LivekitFocusSelection; + "focus_active": { + type: string, + focus_selection: "oldest_membership"|string, + }; /** * A list of possible foci this user knows about. One of them might be used based on the focus_active @@ -67,10 +70,6 @@ export type SessionMembershipData = { * something else. */ "m.call.intent"?: RTCCallIntent; - /** - * The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device. - */ - "msc4354_sticky_key"?: string; }; export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => { @@ -79,10 +78,12 @@ export const checkSessionsMembershipData = (data: IContent): data is SessionMemb if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); - if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); if (data.focus_active === undefined) { errors.push(prefix + "focus_active has an invalid type"); } + if (typeof data.focus_active?.type !== "string") { + errors.push(prefix + "focus_active.type must be a string"); + } if ( data.foci_preferred !== undefined && !( diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts index 39d0ddeb2e..3ce60bdec3 100644 --- a/src/matrixrtc/membership/rtc.ts +++ b/src/matrixrtc/membership/rtc.ts @@ -2,14 +2,17 @@ import { EventType, IContent, MXID_PATTERN, RelationType } from "../../matrix"; import { RtcSlotEventContent, Transport } from "../types"; import { MatrixRTCMembershipParseError } from "./common"; -type Member = { user_id: string; device_id: string; id: string }; /** * Represents the current form of MSC4143. */ export interface RtcMembershipData { "slot_id": string; - "member": Member; + "member": { + claimed_user_id: string; + claimed_device_id: string; + id: string + }; "m.relates_to"?: { event_id: string; rel_type: RelationType.Reference; @@ -37,13 +40,13 @@ export const checkRtcMembershipData = ( if (typeof data.member !== "object" || data.member === null) { errors.push(prefix + "member must be an object"); } else { - if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); - else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); + if (typeof data.member.claimed_user_id !== "string") errors.push(prefix + "member.claimed_user_id must be string"); + else if (!MXID_PATTERN.test(data.member.claimed_user_id)) errors.push(prefix + "member.claimed_user_id must be a valid mxid"); // This is not what the spec enforces but there currently are no rules what power levels are required to // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there // is a proper definition when this is allowed. - else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender"); - if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); + else if (data.member.claimed_user_id !== referenceUserId) errors.push(prefix + "member.claimed_user_id must match the sender"); + if (typeof data.member.claimed_device_id !== "string") errors.push(prefix + "member.claimed_device_id must be string"); if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); } if (typeof data.application !== "object" || data.application === null) { diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 6dd187ce4e..569927c4f8 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -207,13 +207,13 @@ export interface RtcSlotEventContent { // other application specific keys [key: string]: unknown; }; + slot_id: string, } /** * The session description is used to identify a session. Used in the state event. */ export interface SlotDescription { - id: string; + id?: string; application: string; - parameters?: Record; } From 3e5c1ee6f5af8e542bfa30d4c93f47fe68742cfc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 3 Nov 2025 12:07:33 +0000 Subject: [PATCH 06/13] Split membership manager into legacy variant to improve readability --- spec/unit/matrixrtc/MembershipManager.spec.ts | 1402 +++++++++-------- src/matrixrtc/MatrixRTCSession.ts | 4 +- src/matrixrtc/MembershipManager.ts | 124 +- 3 files changed, 778 insertions(+), 752 deletions(-) diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 8324c87699..f860f08433 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -31,8 +31,9 @@ import { type Transport, } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; -import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { LegacyMembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; import { SessionMembershipData } from "src/matrixrtc/membership/legacy.ts"; +import { RtcMembershipData } from "src/matrixrtc/membership/rtc.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -90,573 +91,652 @@ describe("MembershipManager", () => { jest.useFakeTimers(); client = makeMockClient("@alice:example.org", "AAAAAAA"); room = makeMockRoom([sessionMembershipTemplate]); - // Provide a default mock that is like the default "non error" server behaviour. - (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); - (client._unstable_sendStickyEvent as Mock).mockResolvedValue({ event_id: "id" }); - (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); - (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); }); afterEach(() => { jest.useRealTimers(); // There is no need to clean up mocks since we will recreate the client. }); + describe("LegacyMembershipManager", () => { - describe("isActivated()", () => { - it("defaults to false", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.isActivated()).toEqual(false); - }); + beforeEach(() => { + // Provide a default mock that is like the default "non error" server behaviour. + (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); + }) + + describe("isActivated()", () => { + it("defaults to false", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.isActivated()).toEqual(false); + }); - it("returns true after join()", () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); + it("returns true after join()", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + }); }); - }); - describe("join()", () => { - describe("sends a membership event", () => { - it("sends a membership event and schedules delayed leave when joining a call", async () => { - // Spys/Mocks + describe("join()", () => { + describe("sends a membership event", () => { + it("sends a membership event and schedules delayed leave when joining a call", async () => { + // Spys/Mocks - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); - // Test - const memberManager = new MembershipManager(undefined, room, client, callSession); - memberManager.join([focus], undefined); - // expects - await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - "org.matrix.msc3401.call.member", - { - application: "m.call", - call_id: "", - device_id: "AAAAAAA", - expires: 14400000, - foci_preferred: [focus], - focus_active: focusActive, - scope: "m.room", - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - updateDelayedEventHandle.resolve?.(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( - room.roomId, - { delay: 8000 }, - "org.matrix.msc3401.call.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - }); + // Test + const memberManager = new LegacyMembershipManager(undefined, room, client, callSession); + memberManager.join([focus], undefined); + // expects + await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + "org.matrix.msc3401.call.member", + { + application: "m.call", + call_id: "", + device_id: "AAAAAAA", + expires: 14400000, + foci_preferred: [focus], + focus_active: focusActive, + scope: "m.room", + }, + "_@alice:example.org_AAAAAAA_m.call", + ); + updateDelayedEventHandle.resolve?.(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 8000 }, + "org.matrix.msc3401.call.member", + {}, + "_@alice:example.org_AAAAAAA_m.call", + ); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); - it("reschedules delayed leave event if sending state cancels it", async () => { - const memberManager = new MembershipManager(undefined, room, client, callSession); - const waitForSendState = waitForMockCall(client.sendStateEvent); - const waitForUpdateDelaye = waitForMockCallOnce( - client._unstable_updateDelayedEvent, - Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), - ); - memberManager.join([focus], focusActive); - await waitForSendState; - await waitForUpdateDelaye; - await jest.advanceTimersByTimeAsync(1); - // Once for the initial event and once because of the errcode: "M_NOT_FOUND" - // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); - }); - - describe("does not prefix the state key with _ for rooms that support user-owned state events", () => { - async function testJoin(useOwnedStateEvents: boolean): Promise { - // TODO: this test does quiet a bit. Its more a like a test story summarizing to: - // - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten) - // - run into rate limit for sending delayed event - // - run into rate limit when setting membership state. - if (useOwnedStateEvents) { - room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); - } - const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); - const sentDelayedState = waitForMockCall( - client._unstable_sendDelayedStateEvent, - Promise.resolve({ - delay_id: "id", - }), + it("reschedules delayed leave event if sending state cancels it", async () => { + const memberManager = new LegacyMembershipManager(undefined, room, client, callSession); + const waitForSendState = waitForMockCall(client.sendStateEvent); + const waitForUpdateDelaye = waitForMockCallOnce( + client._unstable_updateDelayedEvent, + Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), ); + memberManager.join([focus], focusActive); + await waitForSendState; + await waitForUpdateDelaye; + await jest.advanceTimersByTimeAsync(1); + // Once for the initial event and once because of the errcode: "M_NOT_FOUND" + // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); - // preparing the delayed disconnect should handle the delay being too long - const sendDelayedStateExceedAttempt = new Promise((resolve) => { - const error = new MatrixError({ - "errcode": "M_UNKNOWN", - "org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED", - "org.matrix.msc4140.max_delay": 7500, + describe("does not prefix the state key with _ for rooms that support user-owned state events", () => { + async function testJoin(useOwnedStateEvents: boolean): Promise { + // TODO: this test does quiet a bit. Its more a like a test story summarizing to: + // - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten) + // - run into rate limit for sending delayed event + // - run into rate limit when setting membership state. + if (useOwnedStateEvents) { + room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); + } + const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); + const sentDelayedState = waitForMockCall( + client._unstable_sendDelayedStateEvent, + Promise.resolve({ + delay_id: "id", + }), + ); + + // preparing the delayed disconnect should handle the delay being too long + const sendDelayedStateExceedAttempt = new Promise((resolve) => { + const error = new MatrixError({ + "errcode": "M_UNKNOWN", + "org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED", + "org.matrix.msc4140.max_delay": 7500, + }); + (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); - (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); + + const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`; + // preparing the delayed disconnect should handle ratelimiting + const sendDelayedStateAttempt = new Promise((resolve) => { + const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }); + (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); - }); - const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`; - // preparing the delayed disconnect should handle ratelimiting - const sendDelayedStateAttempt = new Promise((resolve) => { - const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }); - (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); + // setting the membership state should handle ratelimiting (also with a retry-after value) + const sendStateEventAttempt = new Promise((resolve) => { + const error = new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ); + (client.sendStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); + const manager = new LegacyMembershipManager( + { + delayedLeaveEventDelayMs: 9000, + }, + room, + client, + callSession, + ); + manager.join([focus]); + + await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches + await sendDelayedStateAttempt; + const callProps = (d: number) => { + return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey]; + }; + expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000)); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500)); + + await jest.advanceTimersByTimeAsync(5000); + + await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches + + await jest.advanceTimersByTimeAsync(1000); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room!.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + expires: 14400000, + device_id: "AAAAAAA", + foci_preferred: [focus], + focus_active: focusActive, + } satisfies SessionMembershipData, + userStateKey, + ); + await sentDelayedState; + + // should have prepared the heartbeat to keep delaying the leave event while still connected + await updatedDelayedEvent; + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + + // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. + await jest.advanceTimersByTimeAsync(5000); + // should update delayed disconnect + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + } + + it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { + await testJoin(false); }); - // setting the membership state should handle ratelimiting (also with a retry-after value) - const sendStateEventAttempt = new Promise((resolve) => { - const error = new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ); - (client.sendStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); - }); + it("does not prefix the state key with _ for rooms that support user-owned state events", async () => { + await testJoin(true); }); - const manager = new MembershipManager( - { - delayedLeaveEventDelayMs: 9000, - }, - room, - client, - callSession, + }); + }); + + describe("delayed leave event", () => { + it("does not try again to schedule a delayed leave event if not supported", () => { + const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus]); + delayedHandle.reject?.( + new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "sendDelayedStateEvent", + ), ); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); + it("does try to schedule a delayed leave event again if rate limited", async () => { + const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - - await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches - await sendDelayedStateAttempt; - const callProps = (d: number) => { - return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey]; - }; - expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000)); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500)); - + delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); await jest.advanceTimersByTimeAsync(5000); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); + it("uses delayedLeaveEventDelayMs from config", () => { + const manager = new LegacyMembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + manager.join([focus]); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 123456 }, + "org.matrix.msc3401.call.member", + {}, + "_@alice:example.org_AAAAAAA_m.call", + ); + }); + }); - await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches - - await jest.advanceTimersByTimeAsync(1000); + it("rejoins if delayed event is not found (404)", async () => { + const RESTART_DELAY = 15000; + const manager = new LegacyMembershipManager( + { delayedLeaveEventRestartMs: RESTART_DELAY }, + room, + client, - expect(client.sendStateEvent).toHaveBeenCalledWith( - room!.roomId, - EventType.GroupCallMemberPrefix, - { - application: "m.call", - scope: "m.room", - call_id: "", - expires: 14400000, - device_id: "AAAAAAA", - foci_preferred: [focus], - focus_active: focusActive, - } satisfies SessionMembershipData, - userStateKey, - ); - await sentDelayedState; + callSession, + ); + // Join with the membership manager + manager.join([focus]); + expect(manager.status).toBe(Status.Connecting); + // Let the scheduler run one iteration so that we can send the join state event + await jest.runOnlyPendingTimersAsync(); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(manager.status).toBe(Status.Connected); + // Now that we are connected, we set up the mocks. + // We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave: + // - We wait until the delayed event gets sent and then mock its response to be "not found." + // - We enforce a race condition between the sync that informs us that our call membership state event was set to "left" + // and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent. + // - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left." + // (onRTCSessionMemberUpdate) + // - Only then do we resolve the sending of the delayed event. + // - We test that the manager acknowledges the leave and sends a new membership state event. + (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + new MatrixError({ errcode: "M_NOT_FOUND" }), + ); - // should have prepared the heartbeat to keep delaying the leave event while still connected - await updatedDelayedEvent; - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); + await jest.advanceTimersByTimeAsync(RESTART_DELAY); + // first simulate the sync, then resolve sending the delayed event. + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + resolve({ delay_id: "id" }); + // Let the scheduler run one iteration so that the new join gets sent + await jest.runOnlyPendingTimersAsync(); + expect(client.sendStateEvent).toHaveBeenCalledTimes(2); + }); - // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. - await jest.advanceTimersByTimeAsync(5000); - // should update delayed disconnect - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); - } + it("uses membershipEventExpiryMs from config", async () => { + const manager = new LegacyMembershipManager( + { membershipEventExpiryMs: 1234567 }, + room, + client, - it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { - await testJoin(false); - }); + callSession, + ); - it("does not prefix the state key with _ for rooms that support user-owned state events", async () => { - await testJoin(true); - }); + manager.join([focus]); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + device_id: "AAAAAAA", + expires: 1234567, + foci_preferred: [focus], + focus_active: { + focus_selection: "oldest_membership", + type: "livekit", + }, + }, + "_@alice:example.org_AAAAAAA_m.call", + ); }); - }); - describe("delayed leave event", () => { - it("does not try again to schedule a delayed leave event if not supported", () => { - const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, callSession); + it("does nothing if join called when already joined", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - delayedHandle.reject?.( - new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "sendDelayedStateEvent", - ), - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + manager.join([focus]); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); - it("does try to schedule a delayed leave event again if rate limited", async () => { - const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, callSession); + }); + + describe("leave()", () => { + // TODO add rate limit cases. + it("resolves delayed leave event when leave is called", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); - await jest.advanceTimersByTimeAsync(5000); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(1); + await manager.leave(); + expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); + expect(client.sendStateEvent).toHaveBeenCalled(); }); - it("uses delayedLeaveEventDelayMs from config", () => { - const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + it("send leave event when leave is called and resolving delayed leave fails", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + await jest.advanceTimersByTimeAsync(1); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); + await manager.leave(); + + // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. + expect(client.sendStateEvent).toHaveBeenLastCalledWith( room.roomId, - { delay: 123456 }, "org.matrix.msc3401.call.member", {}, "_@alice:example.org_AAAAAAA_m.call", ); }); + it("does nothing if not joined", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(async () => await manager.leave()).not.toThrow(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); }); - it("rejoins if delayed event is not found (404)", async () => { - const RESTART_DELAY = 15000; - const manager = new MembershipManager( - { delayedLeaveEventRestartMs: RESTART_DELAY }, - room, - client, - - callSession, - ); - // Join with the membership manager - manager.join([focus]); - expect(manager.status).toBe(Status.Connecting); - // Let the scheduler run one iteration so that we can send the join state event - await jest.runOnlyPendingTimersAsync(); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(manager.status).toBe(Status.Connected); - // Now that we are connected, we set up the mocks. - // We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave: - // - We wait until the delayed event gets sent and then mock its response to be "not found." - // - We enforce a race condition between the sync that informs us that our call membership state event was set to "left" - // and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent. - // - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left." - // (onRTCSessionMemberUpdate) - // - Only then do we resolve the sending of the delayed event. - // - We test that the manager acknowledges the leave and sends a new membership state event. - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( - new MatrixError({ errcode: "M_NOT_FOUND" }), - ); - - const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); - await jest.advanceTimersByTimeAsync(RESTART_DELAY); - // first simulate the sync, then resolve sending the delayed event. - await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); - resolve({ delay_id: "id" }); - // Let the scheduler run one iteration so that the new join gets sent - await jest.runOnlyPendingTimersAsync(); - expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - }); + describe("onRTCSessionMemberUpdate()", () => { + it("does nothing if not joined", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + await jest.advanceTimersToNextTimerAsync(); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + }); + it("does nothing if own membership still present", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; + // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); + + await manager.onRTCSessionMemberUpdate([ + mockCallMembership(sessionMembershipTemplate, room.roomId), + mockCallMembership( + { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, + room.roomId, + ), + ]); - it("uses membershipEventExpiryMs from config", async () => { - const manager = new MembershipManager( - { membershipEventExpiryMs: 1234567 }, - room, - client, - - callSession, - ); - - manager.join([focus]); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - EventType.GroupCallMemberPrefix, - { - application: "m.call", - scope: "m.room", - call_id: "", - device_id: "AAAAAAA", - expires: 1234567, - foci_preferred: [focus], - focus_active: { - focus_selection: "oldest_membership", - type: "livekit", - }, - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - }); + await jest.advanceTimersByTimeAsync(1); - it("does nothing if join called when already joined", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - manager.join([focus]); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - }); - }); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + }); + it("recreates membership if it is missing", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - describe("leave()", () => { - // TODO add rate limit cases. - it("resolves delayed leave event when leave is called", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await jest.advanceTimersByTimeAsync(1); - await manager.leave(); - expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - it("send leave event when leave is called and resolving delayed leave fails", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await jest.advanceTimersByTimeAsync(1); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); - await manager.leave(); - - // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. - expect(client.sendStateEvent).toHaveBeenLastCalledWith( - room.roomId, - "org.matrix.msc3401.call.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - }); - it("does nothing if not joined", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(async () => await manager.leave()).not.toThrow(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - }); - }); + // Our own membership is removed: + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + await jest.advanceTimersByTimeAsync(1); + expect(client.sendStateEvent).toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - describe("onRTCSessionMemberUpdate()", () => { - it("does nothing if not joined", async () => { - const manager = new MembershipManager({}, room, client, callSession); - await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); - await jest.advanceTimersToNextTimerAsync(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); - }); - it("does nothing if own membership still present", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; - // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - await manager.onRTCSessionMemberUpdate([ - mockCallMembership(sessionMembershipTemplate, room.roomId), - mockCallMembership( - { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, - room.roomId, - ), - ]); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + }); - await jest.advanceTimersByTimeAsync(1); + it("updates the UpdateExpiry entry in the action scheduler", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); - }); - it("recreates membership if it is missing", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - // Our own membership is removed: - await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); - await jest.advanceTimersByTimeAsync(1); - expect(client.sendStateEvent).toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); - }); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + new MatrixError({ errcode: "M_NOT_FOUND" }), + ); + + const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); + await jest.advanceTimersByTimeAsync(10_000); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + resolve({ delay_id: "id" }); + await jest.advanceTimersByTimeAsync(10_000); - it("updates the UpdateExpiry entry in the action scheduler", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( - new MatrixError({ errcode: "M_NOT_FOUND" }), - ); - - const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); - await jest.advanceTimersByTimeAsync(10_000); - await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); - resolve({ delay_id: "id" }); - await jest.advanceTimersByTimeAsync(10_000); - - expect(client.sendStateEvent).toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); - expect(manager.status).toBe(Status.Connected); + expect(client.sendStateEvent).toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(manager.status).toBe(Status.Connected); + }); }); - }); - // TODO: Not sure about this name - describe("background timers", () => { - it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => { - const manager = new MembershipManager( - { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, - room, - client, - { id: "", application: "m.call" }, - ); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - - // The first call is from checking id the server deleted the delayed event - // so it does not need a `advanceTimersByTime` - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); - - for (let i = 2; i <= 12; i++) { - // flush promises before advancing the timers to make sure schedulers are setup - await jest.advanceTimersByTimeAsync(10_000); + // TODO: Not sure about this name + describe("background timers", () => { + it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => { + const manager = new LegacyMembershipManager( + { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, + room, + client, + { id: "", application: "m.call" }, + ); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + // The first call is from checking id the server deleted the delayed event + // so it does not need a `advanceTimersByTime` + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); // TODO: Check that update delayed event is called with the correct HTTP request timeout // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); - } - }); - // because the expires logic was removed for the legacy call manager. - // Delayed events should replace it entirely but before they have wide adoption - // the expiration logic still makes sense. - // TODO: Add git commit when we removed it. - async function testExpires(expire: number, headroom?: number) { - const manager = new MembershipManager( - { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, - room, - client, - - { id: "", application: "m.call" }, - ); - manager.join([focus], focusActive); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; - expect(sentMembership.expires).toBe(expire); - for (let i = 2; i <= 12; i++) { - await jest.advanceTimersByTimeAsync(expire); - expect(client.sendStateEvent).toHaveBeenCalledTimes(i); - const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData; - expect(sentMembership.expires).toBe(expire * i); + for (let i = 2; i <= 12; i++) { + // flush promises before advancing the timers to make sure schedulers are setup + await jest.advanceTimersByTimeAsync(10_000); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + // TODO: Check that update delayed event is called with the correct HTTP request timeout + // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + } + }); + + // because the expires logic was removed for the legacy call manager. + // Delayed events should replace it entirely but before they have wide adoption + // the expiration logic still makes sense. + // TODO: Add git commit when we removed it. + async function testExpires(expire: number, headroom?: number) { + const manager = new LegacyMembershipManager( + { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, + room, + client, + + { id: "", application: "m.call" }, + ); + manager.join([focus], focusActive); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; + expect(sentMembership.expires).toBe(expire); + for (let i = 2; i <= 12; i++) { + await jest.advanceTimersByTimeAsync(expire); + expect(client.sendStateEvent).toHaveBeenCalledTimes(i); + const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData; + expect(sentMembership.expires).toBe(expire * i); + } } - } - it("extends `expires` when call still active", async () => { - await testExpires(10_000); - }); - it("extends `expires` using headroom configuration", async () => { - await testExpires(10_000, 1_000); + it("extends `expires` when call still active", async () => { + await testExpires(10_000); + }); + it("extends `expires` using headroom configuration", async () => { + await testExpires(10_000, 1_000); + }); }); - }); - describe("status updates", () => { - it("starts 'Disconnected'", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.status).toBe(Status.Disconnected); - }); - it("emits 'Connection' and 'Connected' after join", async () => { - const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const handleStateEvent = createAsyncHandle(client.sendStateEvent); - - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.status).toBe(Status.Disconnected); - const connectEmit = jest.fn(); - manager.on(MembershipManagerEvent.StatusChanged, connectEmit); - manager.join([focus], focusActive); - expect(manager.status).toBe(Status.Connecting); - handleDelayedEvent.resolve(); - await jest.advanceTimersByTimeAsync(1); - expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting); - handleStateEvent.resolve(); - await jest.advanceTimersByTimeAsync(1); - expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); - }); - it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { - const manager = new MembershipManager({}, room, client, callSession); - const connectEmit = jest.fn(); - manager.on(MembershipManagerEvent.StatusChanged, connectEmit); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - await manager.leave(); - expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting); - expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected); + describe("status updates", () => { + it("starts 'Disconnected'", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.status).toBe(Status.Disconnected); + }); + it("emits 'Connection' and 'Connected' after join", async () => { + const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); + const handleStateEvent = createAsyncHandle(client.sendStateEvent); + + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.status).toBe(Status.Disconnected); + const connectEmit = jest.fn(); + manager.on(MembershipManagerEvent.StatusChanged, connectEmit); + manager.join([focus], focusActive); + expect(manager.status).toBe(Status.Connecting); + handleDelayedEvent.resolve(); + await jest.advanceTimersByTimeAsync(1); + expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting); + handleStateEvent.resolve(); + await jest.advanceTimersByTimeAsync(1); + expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); + }); + it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + const connectEmit = jest.fn(); + manager.on(MembershipManagerEvent.StatusChanged, connectEmit); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + await manager.leave(); + expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting); + expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected); + }); }); - }); - describe("server error handling", () => { - // Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout - describe("retries sending delayed leave event", () => { - it("sends retry if call membership event is still valid at time of retry", async () => { - const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); + describe("server error handling", () => { + // Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout + describe("retries sending delayed leave event", () => { + it("sends retry if call membership event is still valid at time of retry", async () => { + const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - handle.reject?.( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), - ); - await jest.advanceTimersByTimeAsync(1000); + handle.reject?.( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + await jest.advanceTimersByTimeAsync(1000); + + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); + it("abandons retry loop and sends new own membership if not present anymore", async () => { + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the + // RateLimit error. + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); + // Remove our own membership so that there is no reason the send the delayed leave anymore. + // the membership is no longer present on the homeserver + await manager.onRTCSessionMemberUpdate([]); + // Wait for all timers to be setup + await jest.advanceTimersByTimeAsync(1000); + // We should send the first own membership and a new delayed event after the rate limit timeout. + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + }); + it("abandons retry loop if leave() was called before sending state event", async () => { + const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + handle.reject?.( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + // the user terminated the call locally + await manager.leave(); + + // Wait for all timers to be setup + await jest.advanceTimersByTimeAsync(1000); + + // No new events should have been sent: + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); }); - it("abandons retry loop and sends new own membership if not present anymore", async () => { + describe("retries sending update delayed leave event restart", () => { + it("resends the initial check delayed update event", async () => { + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + + // Hit rate limit + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + + // Hit second rate limit. + await jest.advanceTimersByTimeAsync(1000); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + + // Setup resolve + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + await jest.advanceTimersByTimeAsync(1000); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + describe("unrecoverable errors", () => { + // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. + it("throws, when reaching maximum number of retries for initial delayed event creation", async () => { + const delayEventSendError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, undefined, undefined, - new Headers({ "Retry-After": "1" }), + new Headers({ "Retry-After": "2" }), ), ); - const manager = new MembershipManager({}, room, client, callSession); - // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the - // RateLimit error. - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, delayEventSendError); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); - // Remove our own membership so that there is no reason the send the delayed leave anymore. - // the membership is no longer present on the homeserver - await manager.onRTCSessionMemberUpdate([]); - // Wait for all timers to be setup - await jest.advanceTimersByTimeAsync(1000); - // We should send the first own membership and a new delayed event after the rate limit timeout. - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + for (let i = 0; i < 10; i++) { + await jest.advanceTimersByTimeAsync(2000); + } + expect(delayEventSendError).toHaveBeenCalled(); }); - it("abandons retry loop if leave() was called before sending state event", async () => { - const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - handle.reject?.( + // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. + it("throws, when reaching maximum number of retries", async () => { + const delayEventRestartError = jest.fn(); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -665,225 +745,149 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, delayEventRestartError); + for (let i = 0; i < 10; i++) { + await jest.advanceTimersByTimeAsync(1000); + } + expect(delayEventRestartError).toHaveBeenCalled(); + }); + it("falls back to using pure state events when some error occurs while sending delayed events", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, unrecoverableError); + await waitForMockCall(client.sendStateEvent); + expect(unrecoverableError).not.toHaveBeenCalledWith(); + expect(client.sendStateEvent).toHaveBeenCalled(); + }); + it("retries before failing in case its a network error", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 501)); + const manager = new LegacyMembershipManager( + { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, + room, + client, + callSession, + ); + manager.join([focus], focusActive, unrecoverableError); + for (let retries = 0; retries < 7; retries++) { + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1); + await jest.advanceTimersByTimeAsync(1000); + } + expect(unrecoverableError).toHaveBeenCalled(); + expect(unrecoverableError.mock.lastCall![0].message).toMatch( + "The MembershipManager shut down because of the end condition", + ); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( + new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - // the user terminated the call locally - await manager.leave(); - - // Wait for all timers to be setup - await jest.advanceTimersByTimeAsync(1000); - // No new events should have been sent: - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + expect(unrecoverableError).not.toHaveBeenCalled(); + expect(client.sendStateEvent).toHaveBeenCalled(); }); }); - describe("retries sending update delayed leave event restart", () => { - it("resends the initial check delayed update event", async () => { - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), + describe("probablyLeft", () => { + it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => { + const manager = new LegacyMembershipManager( + { delayedLeaveEventDelayMs: 10000 }, + room, + client, + + callSession, ); - const manager = new MembershipManager({}, room, client, callSession); + const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); + const probablyLeftEmit = jest.fn(); + manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit); manager.join([focus], focusActive); + try { + // Let the scheduler run one iteration so that we can send the join state event + await waitForMockCall(client._unstable_updateDelayedEvent); + + // We never resolve the delayed event so that we can test the probablyLeft event. + // This simulates the case where the server does not respond to the delayed event. + client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(manager.status).toBe(Status.Connected); + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + // We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000. + // We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding. + // The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test. + await jest.advanceTimersByTimeAsync(5000); + // No emission after 5s + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - // Hit rate limit - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - - // Hit second rate limit. - await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); - - // Setup resolve - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); - await jest.advanceTimersByTimeAsync(1000); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(4999); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + + // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); + + // Emit after 10s + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); + expect(probablyLeftEmit).toHaveBeenCalledWith(true); + + // Mock a sync which does not include our own membership + await manager.onRTCSessionMemberUpdate([]); + // Wait for the current ongoing delayed event sending to finish + await jest.advanceTimersByTimeAsync(1); + // We should send a new state event and an associated delayed leave event. + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + expect(client.sendStateEvent).toHaveBeenCalledTimes(2); + // At the same time we expect the probablyLeft event to be emitted with false so we are back operational. + expect(probablyLeftEmit).toHaveBeenCalledWith(false); + } finally { + rejectStuckPromise(); + } }); }); - }); - describe("unrecoverable errors", () => { - // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. - it("throws, when reaching maximum number of retries for initial delayed event creation", async () => { - const delayEventSendError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "2" }), - ), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, delayEventSendError); - - for (let i = 0; i < 10; i++) { - await jest.advanceTimersByTimeAsync(2000); - } - expect(delayEventSendError).toHaveBeenCalled(); - }); - // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. - it("throws, when reaching maximum number of retries", async () => { - const delayEventRestartError = jest.fn(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, delayEventRestartError); - - for (let i = 0; i < 10; i++) { - await jest.advanceTimersByTimeAsync(1000); - } - expect(delayEventRestartError).toHaveBeenCalled(); - }); - it("falls back to using pure state events when some error occurs while sending delayed events", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, unrecoverableError); - await waitForMockCall(client.sendStateEvent); - expect(unrecoverableError).not.toHaveBeenCalledWith(); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - it("retries before failing in case its a network error", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 501)); - const manager = new MembershipManager( - { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, - room, - client, - callSession, - ); - manager.join([focus], focusActive, unrecoverableError); - for (let retries = 0; retries < 7; retries++) { - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1); - await jest.advanceTimersByTimeAsync(1000); - } - expect(unrecoverableError).toHaveBeenCalled(); - expect(unrecoverableError.mock.lastCall![0].message).toMatch( - "The MembershipManager shut down because of the end condition", - ); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - }); - it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( - new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, unrecoverableError); - await jest.advanceTimersByTimeAsync(1); - - expect(unrecoverableError).not.toHaveBeenCalled(); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - }); - describe("probablyLeft", () => { - it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => { - const manager = new MembershipManager( - { delayedLeaveEventDelayMs: 10000 }, - room, - client, - - callSession, - ); - const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); - const probablyLeftEmit = jest.fn(); - manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit); - manager.join([focus], focusActive); - try { - // Let the scheduler run one iteration so that we can send the join state event - await waitForMockCall(client._unstable_updateDelayedEvent); - // We never resolve the delayed event so that we can test the probablyLeft event. - // This simulates the case where the server does not respond to the delayed event. - client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(manager.status).toBe(Status.Connected); - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - // We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000. - // We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding. - // The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test. - await jest.advanceTimersByTimeAsync(5000); - // No emission after 5s - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - - await jest.advanceTimersByTimeAsync(4999); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - - // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); - - // Emit after 10s - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); - expect(probablyLeftEmit).toHaveBeenCalledWith(true); + describe("updateCallIntent()", () => { + it("should fail if the user has not joined the call", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + // After joining we want our own focus to be the one we select. + try { + await manager.updateCallIntent("video"); + throw Error("Should have thrown"); + } catch {} + }); - // Mock a sync which does not include our own membership - await manager.onRTCSessionMemberUpdate([]); - // Wait for the current ongoing delayed event sending to finish - await jest.advanceTimersByTimeAsync(1); - // We should send a new state event and an associated delayed leave event. - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + it("can adjust the intent", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + const membership = mockCallMembership({ ...sessionMembershipTemplate, user_id: client.getUserId()! }, room.roomId); + await manager.onRTCSessionMemberUpdate([membership]); + await manager.updateCallIntent("video"); expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - // At the same time we expect the probablyLeft event to be emitted with false so we are back operational. - expect(probablyLeftEmit).toHaveBeenCalledWith(false); - } finally { - rejectStuckPromise(); - } - }); - }); + const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; + expect(eventContent["created_ts"]).toEqual(membership.createdTs()); + expect(eventContent["m.call.intent"]).toEqual("video"); + }); - describe("updateCallIntent()", () => { - it("should fail if the user has not joined the call", async () => { - const manager = new MembershipManager({}, room, client, callSession); - // After joining we want our own focus to be the one we select. - try { + it("does nothing if the intent doesn't change", async () => { + const manager = new LegacyMembershipManager({ callIntent: "video" }, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + const membership = mockCallMembership( + { ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, + room.roomId, + ); + await manager.onRTCSessionMemberUpdate([membership]); await manager.updateCallIntent("video"); - throw Error("Should have thrown"); - } catch {} - }); - - it("can adjust the intent", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership({ ...sessionMembershipTemplate, user_id: client.getUserId()! }, room.roomId); - await manager.onRTCSessionMemberUpdate([membership]); - await manager.updateCallIntent("video"); - expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; - expect(eventContent["created_ts"]).toEqual(membership.createdTs()); - expect(eventContent["m.call.intent"]).toEqual("video"); - }); - - it("does nothing if the intent doesn't change", async () => { - const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership( - { ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, - room.roomId, - ); - await manager.onRTCSessionMemberUpdate([membership]); - await manager.updateCallIntent("video"); - expect(client.sendStateEvent).toHaveBeenCalledTimes(0); + expect(client.sendStateEvent).toHaveBeenCalledTimes(0); + }); }); }); @@ -914,15 +918,15 @@ describe("MembershipManager", () => { { application: { type: "m.call" }, member: { - user_id: "@alice:example.org", + claimed_user_id: "@alice:example.org", id: "_@alice:example.org_AAAAAAA_m.call", - device_id: "AAAAAAA", + claimed_device_id: "AAAAAAA", }, slot_id: "m.call#", rtc_transports: [focus], versions: [], msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", - }, + } satisfies RtcMembershipData, ); updateDelayedEventHandle.resolve?.(); @@ -949,7 +953,7 @@ it("Should prefix log with MembershipManager used", () => { const client = makeMockClient("@alice:example.org", "AAAAAAA"); const room = makeMockRoom([sessionMembershipTemplate]); - const membershipManager = new MembershipManager(undefined, room, client, callSession); + const membershipManager = new LegacyMembershipManager(undefined, room, client, callSession); const spy = jest.spyOn(console, "error"); // Double join diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 63da7c8132..c6daf8fa9a 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,7 +24,7 @@ import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; -import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; +import { LegacyMembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; import { @@ -618,7 +618,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.slotDescription, this.logger, ) - : new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); + : new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index a4829832c8..9112dde9a6 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -29,9 +29,13 @@ import { type Room } from "../models/room.ts"; import { type CallMembership, DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, - type SessionMembershipData, } from "./CallMembership.ts"; +import type { + RtcMembershipData, +} from "./membership/rtc.ts" +import type { + SessionMembershipData, +} from "./membership/legacy.ts" import { type Transport, isMyMembership, type RTCCallIntent, Status, SlotDescription } from "./types.ts"; import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts"; import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; @@ -181,7 +185,7 @@ type MembershipManagerClient = Pick< * - Stop the timer for the delay refresh * - Stop the timer for updating the state event */ -export class MembershipManager +export abstract class MembershipManager extends TypedEventEmitter implements IMembershipManager { @@ -382,7 +386,7 @@ export class MembershipManager protected memberId: string; protected rtcTransport?: Transport; /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ - private fociPreferred?: Transport[]; + protected fociPreferred?: Transport[]; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -473,15 +477,7 @@ export class MembershipManager } } - // an abstraction to switch between sending state or a sticky event - protected clientSendDelayedDisconnectMembership: () => Promise = () => - this.client._unstable_sendDelayedStateEvent( - this.room.roomId, - { delay: this.delayedLeaveEventDelayMs }, - EventType.GroupCallMemberPrefix, - {}, - this.memberId, - ); + protected abstract clientSendDelayedDisconnectMembership: () => Promise; // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { @@ -669,16 +665,9 @@ export class MembershipManager }); } - protected clientSendMembership: ( - myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, - ) => Promise = (myMembership) => { - return this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - myMembership as EmptyObject | SessionMembershipData, - this.memberId, - ); - }; + protected abstract clientSendMembership: ( + myMembership: MembershipData | EmptyObject, + ) => Promise; private async sendJoinEvent(): Promise { return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) @@ -771,30 +760,7 @@ export class MembershipManager /** * Constructs our own membership */ - protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { - const ownMembership = this.ownMembership; - - const focusObjects = - this.rtcTransport === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { - focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, - foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], - }; - return { - "application": this.slotDescription.application, - "call_id": this.slotDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "m.call.intent": this.callIntent, - ...focusObjects, - ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), - }; - } + protected abstract makeMyMembership(expires: number): MembershipData; // Error checks and handlers @@ -1020,11 +986,65 @@ export class MembershipManager } } +/** + * Handles sending membership for MSC3401 RTC events. + */ +export class LegacyMembershipManager extends MembershipManager { + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ); + + protected makeMyMembership(expires: number): SessionMembershipData { + const ownMembership = this.ownMembership; + + const focusObjects = + this.rtcTransport === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { + focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, + foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], + }; + return { + "application": this.slotDescription.application, + "call_id": this.slotDescription.id ?? "", + "scope": "m.room", + "device_id": this.deviceId, + expires, + "m.call.intent": this.callIntent, + ...focusObjects, + ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), + }; + } + + + protected clientSendMembership: ( + myMembership: SessionMembershipData | EmptyObject, + ) => Promise = (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership, + this.memberId, + ); + }; + +} + /** * Implementation of the Membership manager that uses sticky events * rather than state events. + * + * This exclusively sends RTCMembershipData */ -export class StickyEventMembershipManager extends MembershipManager { +export class StickyEventMembershipManager extends MembershipManager { public constructor( joinConfig: (SessionConfig & MembershipConfig) | undefined, room: Pick, @@ -1036,6 +1056,8 @@ export class StickyEventMembershipManager extends MembershipManager { super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger); } + protected readonly eventType = EventType.RTCMembership; + protected clientSendDelayedDisconnectMembership: () => Promise = () => this.clientWithSticky._unstable_sendStickyDelayedEvent( this.room.roomId, @@ -1047,7 +1069,7 @@ export class StickyEventMembershipManager extends MembershipManager { ); protected clientSendMembership: ( - myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, + myMembership: RtcMembershipData | EmptyObject, ) => Promise = (myMembership) => { return this.clientWithSticky._unstable_sendStickyEvent( this.room.roomId, @@ -1066,7 +1088,7 @@ export class StickyEventMembershipManager extends MembershipManager { return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); } - protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + protected makeMyMembership(_expires: number): RtcMembershipData { const ownMembership = this.ownMembership; const relationObject = ownMembership?.eventId @@ -1079,7 +1101,7 @@ export class StickyEventMembershipManager extends MembershipManager { }, slot_id: slotDescriptionToId(this.slotDescription), rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], - member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + member: { claimed_device_id: this.deviceId, claimed_user_id: this.client.getUserId()!, id: this.memberId }, versions: [], ...relationObject, }; From addb9c0d2a613ccf527163aa5c3e731baa489c5b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 3 Nov 2025 13:48:11 +0000 Subject: [PATCH 07/13] more test fixes --- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 4 ++-- src/matrixrtc/CallMembership.ts | 21 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index ee52e9498d..e35e04031b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -21,7 +21,7 @@ import { makeMockRoom, type MembershipData, sessionMembershipTemplate, mockRoomS import { logger } from "../../../src/logger"; import { slotDescriptionToId } from "../../../src/matrixrtc"; -describe.each([{ eventKind: "sticky" }, /*{ eventKind: "memberState" }*/])( +describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( "MatrixRTCSessionManager ($eventKind)", ({ eventKind }) => { let client: MatrixClient; @@ -42,7 +42,7 @@ describe.each([{ eventKind: "sticky" }, /*{ eventKind: "memberState" }*/])( beforeEach(() => { client = new MatrixClient({ baseUrl: "base_url" }); client.matrixRTC.start(); - membershipTemplate = eventKind ? rtcMembershipTemplate : sessionMembershipTemplate; + membershipTemplate = eventKind === "sticky" ? rtcMembershipTemplate : sessionMembershipTemplate; }); afterEach(() => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 70cd8298d8..9f34044099 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -22,6 +22,7 @@ import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; import { checkSessionsMembershipData, SessionMembershipData } from "./membership/legacy.ts"; import { checkRtcMembershipData, RtcMembershipData } from "./membership/rtc.ts"; import { EventType } from "../matrix.ts"; +import { MatrixRTCMembershipParseError } from "./membership/common.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -70,13 +71,21 @@ export class CallMembership { if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); if (sender === undefined) throw new Error("parentEvent is missing sender field"); - if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { - this.membershipData = { kind: MembershipKind.RTC, data }; - } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { - this.membershipData = { kind: MembershipKind.Session, data }; - } else { - throw Error(`'${evType} is not a known call membership type`); + try { + if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { + this.membershipData = { kind: MembershipKind.RTC, data }; + } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { + this.membershipData = { kind: MembershipKind.Session, data }; + } else { + throw Error(`'${evType} is not a known call membership type`); + } + } catch (ex) { + if (ex instanceof MatrixRTCMembershipParseError) { + logger.debug("CallMembership.MatrixRTCMembershipParseError provided data", data); + } + throw ex; } + this.matrixEventData = { eventId, sender }; } From f16760e600d2538a2db1c096fb5e377dd1ab5451 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 4 Nov 2025 13:07:19 +0000 Subject: [PATCH 08/13] fix imports --- spec/unit/matrixrtc/CallMembership.spec.ts | 4 ++-- spec/unit/matrixrtc/MembershipManager.spec.ts | 4 ++-- spec/unit/matrixrtc/mocks.ts | 4 ++-- src/@types/event.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 2193f96bd9..f2d12880ca 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -14,14 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SessionMembershipData } from "src/matrixrtc/membership/legacy"; +import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; import { EventType, type MatrixEvent } from "../../../src"; import { CallMembership, DEFAULT_EXPIRE_DURATION, } from "../../../src/matrixrtc/CallMembership"; import { sessionMembershipTemplate } from "./mocks"; -import { RtcMembershipData } from "src/matrixrtc/membership/rtc"; +import { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; function makeMockEvent(eventType: EventType.RTCMembership|EventType.GroupCallMemberPrefix, originTs = 0): MatrixEvent { return { diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index f860f08433..11b430318a 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -32,8 +32,8 @@ import { } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { LegacyMembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; -import { SessionMembershipData } from "src/matrixrtc/membership/legacy.ts"; -import { RtcMembershipData } from "src/matrixrtc/membership/rtc.ts"; +import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy.ts"; +import { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc.ts"; /** * Create a promise that will resolve once a mocked method is called. diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 306c070073..08a1ab66a5 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -22,8 +22,8 @@ import { CallMembership } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; import { DefaultCallApplicationDescription, RtcSlotEventContent, SlotDescription, slotDescriptionToId } from "../../../src/matrixrtc"; import { mkMatrixEvent } from "../../../src/testing"; -import type { SessionMembershipData } from "src/matrixrtc/membership/legacy"; -import type { RtcMembershipData } from "src/matrixrtc/membership/rtc"; +import type { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; +import type { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; diff --git a/src/@types/event.ts b/src/@types/event.ts index 747409d124..8a40ee5f5c 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -63,8 +63,8 @@ import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; import { type POLICIES_ACCOUNT_EVENT_TYPE } from "../models/invites-ignorer-types.ts"; -import { RtcMembershipData } from "src/matrixrtc/membership/rtc.ts"; -import { SessionMembershipData } from "src/matrixrtc/membership/legacy.ts"; +import { RtcMembershipData } from "../matrixrtc/membership/rtc.ts"; +import { SessionMembershipData } from "../matrixrtc/membership/legacy.ts"; export enum EventType { // Room state events From 7b391703686092d61113608e873bb552189602b4 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 6 Nov 2025 11:12:11 +0100 Subject: [PATCH 09/13] more test changes --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 80 ++++++++++++++------ spec/unit/matrixrtc/mocks.ts | 11 ++- src/matrixrtc/CallMembership.ts | 60 +++++++-------- src/matrixrtc/MatrixRTCSession.ts | 2 +- 4 files changed, 91 insertions(+), 62 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 31f480620b..99d6aa51b1 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -36,6 +36,7 @@ import { type MembershipData, mockRoomState, mockRTCEvent, + testStickyDurationMs, } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; @@ -127,8 +128,8 @@ describe("MatrixRTCSession", () => { ); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].slotDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].scope).toEqual(testConfig.testCreateSticky ? undefined : "m.room"); + expect(sess?.memberships[0].applicationData.type).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); expect(sess?.slotDescription.id).toEqual(""); @@ -154,10 +155,14 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, sessionMembershipTemplate, { - call_id: "not-empty", - scope: "m.room", - }); + const testMembership = testConfig.testCreateSticky ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar" + }, + } : { ...sessionMembershipTemplate, application: "m.call", call_id: "foobar" } const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -170,12 +175,23 @@ describe("MatrixRTCSession", () => { it("ignores expired memberships events", () => { jest.useFakeTimers(); - const expiredMembership = Object.assign({}, sessionMembershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([sessionMembershipTemplate, expiredMembership], testConfig.testCreateSticky); + const expiredMembership = testConfig.testCreateSticky ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar" + }, + __test_sticky_expiry: 3000, + } : { ...sessionMembershipTemplate, device_id: "EXPIRED", expires: 3000 } + jest.setSystemTime(0); + const mockRoom = makeMockRoom([testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate, expiredMembership], testConfig.testCreateSticky); - jest.advanceTimersByTime(2000); + jest.advanceTimersByTime(3000); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -215,9 +231,19 @@ describe("MatrixRTCSession", () => { it("honours created_ts", () => { jest.useFakeTimers(); jest.setSystemTime(500); - const expiredMembership = Object.assign({}, sessionMembershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; + const expiredMembership = testConfig.testCreateSticky ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar" + }, + __test_sticky_expiry: 1500, + } : { ...sessionMembershipTemplate, device_id: "EXPIRED", created_ts: 500, expires: 1000 }; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -336,14 +362,18 @@ describe("MatrixRTCSession", () => { }); it("combines sticky and membership events when both exist", () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([sessionMembershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate], false, callSession, true); const stickyUserId = "@stickyev:user.example"; mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...sessionMembershipTemplate, + ...rtcMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, + member: { + ...rtcMembershipTemplate.member, + claimed_user_id: stickyUserId, + }, + sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, @@ -361,8 +391,8 @@ describe("MatrixRTCSession", () => { expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(stickyUserId); expect(memberships[0].slotDescription.id).toEqual(""); - expect(memberships[0].scope).toEqual("m.room"); - expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].scope).toEqual(undefined); + expect(memberships[0].applicationData.type).toEqual("m.call"); expect(memberships[0].deviceId).toEqual("AAAAAAA"); expect(memberships[0].isExpired()).toEqual(false); @@ -371,8 +401,8 @@ describe("MatrixRTCSession", () => { expect(sess?.slotDescription.id).toEqual(""); }); - it("handles an incoming sticky event to an existing session", () => { - const mockRoom = makeMockRoom([sessionMembershipTemplate]); + it.skip("handles an incoming sticky event to an existing session", () => { + const mockRoom = makeMockRoom([sessionMembershipTemplate], false, callSession, true); const stickyUserId = "@stickyev:user.example"; sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { @@ -382,9 +412,13 @@ describe("MatrixRTCSession", () => { expect(sess.memberships.length).toEqual(1); const stickyEv = mockRTCEvent( { - ...sessionMembershipTemplate, + ...rtcMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, + member: { + ...rtcMembershipTemplate.member, + claimed_user_id: stickyUserId, + }, + sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 08a1ab66a5..b18bad7582 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -27,6 +27,8 @@ import type { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; +export const testStickyDurationMs = 10000; + export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { application: "m.call", call_id: "", @@ -48,7 +50,7 @@ export const sessionMembershipTemplate: SessionMembershipData & { user_id: strin ], }; -export const rtcMembershipTemplate: RtcMembershipData&{user_id: string} = { +export const rtcMembershipTemplate: RtcMembershipData&{user_id: string, __test_sticky_expiry?: number} = { slot_id: "m.call#", application: { type: "m.call", @@ -110,10 +112,11 @@ export function makeMockRoom( membershipData: MembershipData[], useStickyEvents = false, slotDescription = DefaultCallApplicationDescription, + addRTCSlot = useStickyEvents, ): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId, useStickyEvents ? slotDescription : undefined); + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId, addRTCSlot ? slotDescription : undefined); const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, @@ -125,7 +128,7 @@ export function makeMockRoom( _unstable_getStickyEvents: jest .fn() .mockImplementation(() => - useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], + useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, (m as typeof rtcMembershipTemplate).__test_sticky_expiry ?? testStickyDurationMs, ts)) : [], ) as any, }); return Object.assign(room, { @@ -227,7 +230,7 @@ export function mockRTCEvent( timestamp, !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", ), - unstableStickyExpiresAt: stickyDuration, + unstableStickyExpiresAt: stickyDuration ? Date.now() + stickyDuration : undefined, } as unknown as MatrixEvent; } diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 9f34044099..1c9c8be391 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -136,39 +136,34 @@ export class CallMembership { } public get callIntent(): RTCCallIntent | undefined { - const { kind, data } = this.membershipData; - switch (kind) { - case MembershipKind.RTC: { - const intent = data.application["m.call.intent"]; - if (typeof intent === "string") { - return intent; - } - logger.warn("RTC membership has invalid m.call.intent"); - return undefined; - } - case MembershipKind.Session: - default: - return data["m.call.intent"]; + const intent = this.applicationData["m.call.intent"]; + if (typeof intent === "string") { + return intent; } + logger.warn("RTC membership has invalid m.call.intent"); + return undefined; } /** * Parsed `slot_id` (format `{application}#{id}`) into its components (application and id). */ public get slotDescription(): SlotDescription { + // TODO: Should this use content.application? return slotIdToDescription(this.slotId); } + /** + * The application `type`. + * @deprecated Use @see applicationData + */ public get application(): string { - const { kind, data } = this.membershipData; - switch (kind) { - case MembershipKind.RTC: - return data.application.type; - case MembershipKind.Session: - default: - return data.application; - } + return this.applicationData.type; } + + /** + * Information about the application being used for the RTC session. + * May contain extra keys specific to the application. + */ public get applicationData(): { type: string; [key: string]: unknown } { const { kind, data } = this.membershipData; switch (kind) { @@ -176,6 +171,7 @@ export class CallMembership { return data.application; case MembershipKind.Session: default: + // XXX: This is a hack around return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -226,7 +222,7 @@ export class CallMembership { const { kind, data } = this.membershipData; switch (kind) { case MembershipKind.RTC: - return undefined; + return this.matrixEvent.unstableStickyExpiresAt; case MembershipKind.Session: default: // TODO: calculate this from the MatrixRTCSession join configuration directly @@ -236,19 +232,14 @@ export class CallMembership { /** * @returns The number of milliseconds until the membership expires or undefined if applicable + * @deprecated Not used by RTC events. */ public getMsUntilExpiry(): number | undefined { - const { kind } = this.membershipData; - switch (kind) { - case MembershipKind.RTC: - return undefined; - case MembershipKind.Session: - default: - // Assume that local clock is sufficiently in sync with other clocks in the distributed system. - // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. - // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 - return this.getAbsoluteExpiry()! - Date.now(); - } + const absExpiry = this.getAbsoluteExpiry(); + // Assume that local clock is sufficiently in sync with other clocks in the distributed system. + // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. + // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 + return absExpiry ? absExpiry - Date.now() : undefined; } /** @@ -258,7 +249,8 @@ export class CallMembership { const { kind } = this.membershipData; switch (kind) { case MembershipKind.RTC: - return false; + console.log("isExpired", this.matrixEvent.unstableStickyExpiresAt, Date.now()); + return this.matrixEvent.unstableStickyExpiresAt ? Date.now() > this.matrixEvent.unstableStickyExpiresAt: false; case MembershipKind.Session: default: return this.getMsUntilExpiry()! <= 0; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index b48abc925e..68bce45b61 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -306,7 +306,6 @@ export class MatrixRTCSession extends TypedEventEmitter< const slotId = slotDescriptionToId(slotDescription); const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); if (!slot) { - console.log(room.getLiveTimeline().getState(Direction.Forward)?.events); logger.debug(`No slot found for ${room.roomId}`); return null; } @@ -818,6 +817,7 @@ export class MatrixRTCSession extends TypedEventEmitter< "lifetime": 30_000, // 30 seconds }; if (callIntent) { + // TODO: Should be in a different field?1 content["m.call.intent"] = callIntent; } const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content); From dcf7e87799e319b852bced3bc027fb4e79a31fef Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 6 Nov 2025 10:38:27 +0000 Subject: [PATCH 10/13] Cleanup tests --- spec/unit/matrixrtc/CallMembership.spec.ts | 70 ++++++-- spec/unit/matrixrtc/LivekitTransport.spec.ts | 5 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 165 +++++++++++------- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 77 +++++--- spec/unit/matrixrtc/MembershipManager.spec.ts | 25 +-- spec/unit/matrixrtc/mocks.ts | 58 ++++-- 6 files changed, 273 insertions(+), 127 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index f2d12880ca..7b10828c1a 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -16,14 +16,14 @@ limitations under the License. import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; import { EventType, type MatrixEvent } from "../../../src"; -import { - CallMembership, - DEFAULT_EXPIRE_DURATION, -} from "../../../src/matrixrtc/CallMembership"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; import { sessionMembershipTemplate } from "./mocks"; import { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; -function makeMockEvent(eventType: EventType.RTCMembership|EventType.GroupCallMemberPrefix, originTs = 0): MatrixEvent { +function makeMockEvent( + eventType: EventType.RTCMembership | EventType.GroupCallMemberPrefix, + originTs = 0, +): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), @@ -54,24 +54,36 @@ describe("CallMembership", () => { it("rejects membership with no device_id", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { device_id: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { device_id: undefined }), + ); }).toThrow(); }); it("rejects membership with no call_id", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { call_id: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { call_id: undefined }), + ); }).toThrow(); }); it("allow membership with no scope", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), Object.assign({}, membershipTemplate, { scope: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { scope: undefined }), + ); }).not.toThrow(); }); it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix, 12345), membershipTemplate); + const membership = new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix, 12345), + membershipTemplate, + ); expect(membership.createdTs()).toEqual(12345); }); @@ -104,7 +116,10 @@ describe("CallMembership", () => { describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), membershipTemplate); + const oldestMembership = new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + membershipTemplate, + ); it("gets the correct active transport with oldest_membership", () => { const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, @@ -193,23 +208,35 @@ describe("CallMembership", () => { it("rejects membership with no slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: undefined, + }); }).toThrow(); }); it("rejects membership with invalid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: "invalid_slot_id" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: "invalid_slot_id", + }); }).toThrow(); }); it("accepts membership with valid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, slot_id: "m.call#" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: "m.call#", + }); }).not.toThrow(); }); it("rejects membership with no application", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, application: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + application: undefined, + }); }).toThrow(); }); @@ -224,13 +251,19 @@ describe("CallMembership", () => { it("rejects membership with no member", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + member: undefined, + }); }).toThrow(); }); it("rejects membership with incorrect member", () => { expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { i: "test" } }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + member: { i: "test" }, + }); }).toThrow(); expect(() => { new CallMembership(makeMockEvent(EventType.RTCMembership), { @@ -276,7 +309,10 @@ describe("CallMembership", () => { }); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + msc4354_sticky_key: undefined, + }); }).toThrow(); expect(() => { new CallMembership(makeMockEvent(EventType.RTCMembership), { diff --git a/spec/unit/matrixrtc/LivekitTransport.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts index 94e82af8ff..6a444f98f8 100644 --- a/spec/unit/matrixrtc/LivekitTransport.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -14,10 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - isLivekitTransport, - isLivekitTransportConfig, -} from "../../../src/matrixrtc/LivekitTransport"; +import { isLivekitTransport, isLivekitTransportConfig } from "../../../src/matrixrtc/LivekitTransport"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 99d6aa51b1..afa6da7bb5 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -36,7 +36,6 @@ import { type MembershipData, mockRoomState, mockRTCEvent, - testStickyDurationMs, } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; @@ -98,7 +97,9 @@ describe("MatrixRTCSession", () => { "sessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { - const membershipTemplate = testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate; + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue(undefined), @@ -117,7 +118,9 @@ describe("MatrixRTCSession", () => { }); it("creates a room-scoped session from room state", () => { - const membershipTemplate = testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate; + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( @@ -136,14 +139,16 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where application is not m.call", () => { - const testMembership = testConfig.testCreateSticky ? { - ...rtcMembershipTemplate, - slot_id: "not-m.call#", - application: { - ...rtcMembershipTemplate.application, - type: "not-m.call", - }, - } : { ...sessionMembershipTemplate, application: "not-m.call#" } + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "not-m.call#", + application: { + ...rtcMembershipTemplate.application, + type: "not-m.call", + }, + } + : { ...sessionMembershipTemplate, application: "not-m.call#" }; const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -155,14 +160,16 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where callId is not empty", () => { - const testMembership = testConfig.testCreateSticky ? { - ...rtcMembershipTemplate, - slot_id: "m.call#foobar", - application: { - ...rtcMembershipTemplate.application, - "m.call.id": "foobar" - }, - } : { ...sessionMembershipTemplate, application: "m.call", call_id: "foobar" } + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar", + }, + } + : { ...sessionMembershipTemplate, application: "m.call", call_id: "foobar" }; const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -175,21 +182,29 @@ describe("MatrixRTCSession", () => { it("ignores expired memberships events", () => { jest.useFakeTimers(); - const expiredMembership = testConfig.testCreateSticky ? { - ...rtcMembershipTemplate, - slot_id: "m.call#foobar", - member: { - ...rtcMembershipTemplate.member, - claimed_device_id: "EXPIRED", - }, - application: { - ...rtcMembershipTemplate.application, - "m.call.id": "foobar" - }, - __test_sticky_expiry: 3000, - } : { ...sessionMembershipTemplate, device_id: "EXPIRED", expires: 3000 } + const expiredMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar", + }, + __test_sticky_expiry: 3000, + } + : { ...sessionMembershipTemplate, device_id: "EXPIRED", expires: 3000 }; jest.setSystemTime(0); - const mockRoom = makeMockRoom([testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate, expiredMembership], testConfig.testCreateSticky); + const mockRoom = makeMockRoom( + [ + testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate, + expiredMembership, + ], + testConfig.testCreateSticky, + ); jest.advanceTimersByTime(3000); sess = MatrixRTCSession.sessionForSlot( @@ -204,7 +219,10 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([sessionMembershipTemplate], testConfig.testCreateSticky); + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -217,7 +235,10 @@ describe("MatrixRTCSession", () => { it("ignores memberships events with no sender", () => { // Force the sender to be undefined. - const mockRoom = makeMockRoom([{ ...sessionMembershipTemplate, user_id: "" }], testConfig.testCreateSticky); + const membershipTemplate = testConfig.testCreateSticky + ? { ...rtcMembershipTemplate, member: { claimed_user_id: "" } } + : { ...sessionMembershipTemplate, user_id: "" }; + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -231,19 +252,21 @@ describe("MatrixRTCSession", () => { it("honours created_ts", () => { jest.useFakeTimers(); jest.setSystemTime(500); - const expiredMembership = testConfig.testCreateSticky ? { - ...rtcMembershipTemplate, - slot_id: "m.call#foobar", - member: { - ...rtcMembershipTemplate.member, - claimed_device_id: "EXPIRED", - }, - application: { - ...rtcMembershipTemplate.application, - "m.call.id": "foobar" - }, - __test_sticky_expiry: 1500, - } : { ...sessionMembershipTemplate, device_id: "EXPIRED", created_ts: 500, expires: 1000 }; + const expiredMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "", + }, + __test_sticky_expiry: 1000, + } + : { ...sessionMembershipTemplate, device_id: "EXPIRED", created_ts: 500, expires: 1000 }; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -251,6 +274,7 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + expect(sess.memberships[0]).toBeDefined(); expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); jest.useRealTimers(); }); @@ -275,7 +299,10 @@ describe("MatrixRTCSession", () => { getLocalAge: jest.fn().mockReturnValue(0), } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline().getState(Direction.Forward)?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); + mockRoom + .getLiveTimeline() + .getState(Direction.Forward) + ?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -294,7 +321,10 @@ describe("MatrixRTCSession", () => { getLocalAge: jest.fn().mockReturnValue(0), } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline().getState(Direction.Forward)?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); + mockRoom + .getLiveTimeline() + .getState(Direction.Forward) + ?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -305,9 +335,16 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, sessionMembershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); + const membershipTemplate = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: undefined, + }, + } + : { ...sessionMembershipTemplate, device_id: undefined }; + const mockRoom = makeMockRoom([membershipTemplate]); const sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -317,9 +354,13 @@ describe("MatrixRTCSession", () => { expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, sessionMembershipTemplate); - (testMembership.call_id as string | undefined) = undefined; + it("ignores memberships with a different slot description", () => { + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#fibble", + } + : { ...sessionMembershipTemplate, call_id: undefined }; const mockRoom = makeMockRoom([testMembership]); sess = MatrixRTCSession.sessionForSlot( client, @@ -401,7 +442,7 @@ describe("MatrixRTCSession", () => { expect(sess?.slotDescription.id).toEqual(""); }); - it.skip("handles an incoming sticky event to an existing session", () => { + it("handles an incoming sticky event to an existing session", () => { const mockRoom = makeMockRoom([sessionMembershipTemplate], false, callSession, true); const stickyUserId = "@stickyev:user.example"; @@ -703,7 +744,10 @@ describe("MatrixRTCSession", () => { // Simulate a join, including the update to the room state sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - mockRoomState(mockRoom, [sessionMembershipTemplate, { ...sessionMembershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [ + sessionMembershipTemplate, + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + ]); sess!.onRTCSessionMemberUpdate(); expect(client.sendEvent).not.toHaveBeenCalled(); @@ -717,7 +761,10 @@ describe("MatrixRTCSession", () => { // from someone else, starting the call before our own state event has been sent mockRoomState(mockRoom, [sessionMembershipTemplate]); sess!.onRTCSessionMemberUpdate(); - mockRoomState(mockRoom, [sessionMembershipTemplate, { ...sessionMembershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [ + sessionMembershipTemplate, + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + ]); sess!.onRTCSessionMemberUpdate(); // We assume that the responsibility to send a notification, if any, lies with the other diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index e35e04031b..90a93ae064 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -17,7 +17,14 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, type MembershipData, sessionMembershipTemplate, mockRoomState, mockRTCEvent, rtcMembershipTemplate } from "./mocks"; +import { + makeMockRoom, + type MembershipData, + sessionMembershipTemplate, + mockRoomState, + mockRTCEvent, + rtcMembershipTemplate, +} from "./mocks"; import { logger } from "../../../src/logger"; import { slotDescriptionToId } from "../../../src/matrixrtc"; @@ -111,29 +118,49 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( try { // Create a session for applicaation m.other, we ignore this session because it has the wrong application type. - const room1MembershipData: MembershipData[] = eventKind === "sticky" ? [{ ...membershipTemplate, application: { - ...rtcMembershipTemplate.application, - type: "m.call" - }}] : [{ ...membershipTemplate, application: "m.call" }]; - const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky", { application: "m.call", id: ""}); + const room1MembershipData: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: "m.call", + }, + }, + ] + : [{ ...membershipTemplate, application: "m.call" }]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky", { + application: "m.call", + id: "", + }); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); expect(onStarted).not.toHaveBeenCalled(); onStarted.mockClear(); // Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id - const room2MembershipData: MembershipData[] = eventKind === "sticky" ? [ - { ...membershipTemplate, application: { - ...rtcMembershipTemplate.application, - type: slotDescription.application, - }, slot_id: slotDescriptionToId(slotDescription) }, - ] : [{ - ...membershipTemplate, - application: slotDescription.application, - call_id: slotDescription.id, - }]; + const room2MembershipData: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: slotDescription.application, + }, + slot_id: slotDescriptionToId(slotDescription), + }, + ] + : [ + { + ...membershipTemplate, + application: slotDescription.application, + call_id: slotDescription.id, + }, + ]; const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky", slotDescription); - console.log({room2: room2.roomId}) + console.log({ room2: room2.roomId }); jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); expect(onStarted).toHaveBeenCalled(); @@ -158,10 +185,18 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( it("Doesn't fire event if unrelated sessions ends", () => { const onEnded = jest.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const membership: MembershipData[] = eventKind === "sticky" ? [{ ...membershipTemplate, application: { - ...rtcMembershipTemplate.application, - type: "m.other_app", - }}] : [{ ...membershipTemplate, application: "m.other_app" }]; + const membership: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: "m.other_app", + }, + }, + ] + : [{ ...membershipTemplate, application: "m.other_app" }]; const room1 = makeMockRoom(membership, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRoom").mockReturnValue(room1); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 11b430318a..78fc005d09 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -25,11 +25,7 @@ import { type Room, MAX_STICKY_DURATION_MS, } from "../../../src"; -import { - MembershipManagerEvent, - Status, - type Transport, -} from "../../../src/matrixrtc"; +import { MembershipManagerEvent, Status, type Transport } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { LegacyMembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy.ts"; @@ -98,13 +94,12 @@ describe("MembershipManager", () => { // There is no need to clean up mocks since we will recreate the client. }); describe("LegacyMembershipManager", () => { - beforeEach(() => { // Provide a default mock that is like the default "non error" server behaviour. (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); - }) + }); describe("isActivated()", () => { it("defaults to false", () => { @@ -124,7 +119,9 @@ describe("MembershipManager", () => { it("sends a membership event and schedules delayed leave when joining a call", async () => { // Spys/Mocks - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + const updateDelayedEventHandle = createAsyncHandle( + client._unstable_updateDelayedEvent as Mock, + ); // Test const memberManager = new LegacyMembershipManager(undefined, room, client, callSession); @@ -308,7 +305,12 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); }); it("uses delayedLeaveEventDelayMs from config", () => { - const manager = new LegacyMembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + const manager = new LegacyMembershipManager( + { delayedLeaveEventDelayMs: 123456 }, + room, + client, + callSession, + ); manager.join([focus]); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, @@ -867,7 +869,10 @@ describe("MembershipManager", () => { const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership({ ...sessionMembershipTemplate, user_id: client.getUserId()! }, room.roomId); + const membership = mockCallMembership( + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + room.roomId, + ); await manager.onRTCSessionMemberUpdate([membership]); await manager.updateCallIntent("video"); expect(client.sendStateEvent).toHaveBeenCalledTimes(2); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index b18bad7582..02518378d1 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -20,7 +20,12 @@ import { type Mocked } from "jest-mock"; import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; import { CallMembership } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; -import { DefaultCallApplicationDescription, RtcSlotEventContent, SlotDescription, slotDescriptionToId } from "../../../src/matrixrtc"; +import { + DefaultCallApplicationDescription, + RtcSlotEventContent, + SlotDescription, + slotDescriptionToId, +} from "../../../src/matrixrtc"; import { mkMatrixEvent } from "../../../src/testing"; import type { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; import type { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; @@ -50,17 +55,17 @@ export const sessionMembershipTemplate: SessionMembershipData & { user_id: strin ], }; -export const rtcMembershipTemplate: RtcMembershipData&{user_id: string, __test_sticky_expiry?: number} = { +export const rtcMembershipTemplate: RtcMembershipData & { user_id: string; __test_sticky_expiry?: number } = { slot_id: "m.call#", application: { - type: "m.call", - "m.call.id": "" + "type": "m.call", + "m.call.id": "", }, user_id: "@mock:user.example", member: { claimed_user_id: "@mock:user.example", claimed_device_id: "AAAAAAA", - id: "ea2MaingeeMo" + id: "ea2MaingeeMo", }, sticky_key: "ea2MaingeeMo", rtc_transports: [ @@ -78,7 +83,6 @@ export const rtcMembershipTemplate: RtcMembershipData&{user_id: string, __test_s versions: [], }; - export type MockClient = Pick< MatrixClient, | "getUserId" @@ -116,7 +120,11 @@ export function makeMockRoom( ): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId, addRTCSlot ? slotDescription : undefined); + const roomState = makeMockRoomState( + useStickyEvents ? [] : membershipData, + roomId, + addRTCSlot ? slotDescription : undefined, + ); const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, @@ -128,7 +136,16 @@ export function makeMockRoom( _unstable_getStickyEvents: jest .fn() .mockImplementation(() => - useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, (m as typeof rtcMembershipTemplate).__test_sticky_expiry ?? testStickyDurationMs, ts)) : [], + useStickyEvents + ? membershipData.map((m) => + mockRTCEvent( + m, + roomId, + (m as typeof rtcMembershipTemplate).__test_sticky_expiry ?? testStickyDurationMs, + ts, + ), + ) + : [], ) as any, }); return Object.assign(room, { @@ -143,7 +160,7 @@ function makeMockRoomState(membershipData: MembershipData[], roomId: string, slo const data = e.getContent() as SessionMembershipData; return [`_${e.sender?.userId}_${data.device_id}`]; }); - let slotEvent: MatrixEvent|undefined; + let slotEvent: MatrixEvent | undefined; if (slotDescription) { // Add a slot @@ -181,18 +198,27 @@ function makeMockRoomState(membershipData: MembershipData[], roomId: string, slo values: () => events, }, ], - ...(slotEvent ? [[EventType.RTCSlot, { - size: () => true, - has: (stateKey: string) => slotEvent.getStateKey() === stateKey, - get: (stateKey: string) => slotEvent.getStateKey() === stateKey ? slotEvent : undefined, - values: () => [slotEvent], - }]] : []), + ...(slotEvent + ? [ + [ + EventType.RTCSlot, + { + size: () => true, + has: (stateKey: string) => slotEvent.getStateKey() === stateKey, + get: (stateKey: string) => (slotEvent.getStateKey() === stateKey ? slotEvent : undefined), + values: () => [slotEvent], + }, + ], + ] + : []), ] as any), }; } export function mockRoomState(room: Room, membershipData: MembershipData[], slotDescription?: SlotDescription): void { - room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId, slotDescription)); + room.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState(membershipData, room.roomId, slotDescription)); } export function makeMockEvent( From c5fb48944a3c5bfbbb4aa8c106c05f0338250b28 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 6 Nov 2025 10:48:23 +0000 Subject: [PATCH 11/13] More lint --- spec/unit/matrixrtc/CallMembership.spec.ts | 4 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 2 +- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 1 - spec/unit/matrixrtc/MembershipManager.spec.ts | 4 +- spec/unit/matrixrtc/mocks.ts | 4 +- src/@types/event.ts | 6 +-- src/matrixrtc/CallApplication.ts | 8 ++-- src/matrixrtc/CallMembership.ts | 17 ++++--- src/matrixrtc/MatrixRTCSession.ts | 20 ++++---- src/matrixrtc/MembershipManager.ts | 48 ++++++++----------- src/matrixrtc/membership/common.ts | 10 +++- src/matrixrtc/membership/legacy.ts | 20 +++++--- src/matrixrtc/membership/rtc.ts | 40 ++++++++++------ src/matrixrtc/types.ts | 2 +- 14 files changed, 101 insertions(+), 85 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 7b10828c1a..2869b9078d 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; +import { type SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; import { EventType, type MatrixEvent } from "../../../src"; import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; import { sessionMembershipTemplate } from "./mocks"; -import { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; +import { type RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; function makeMockEvent( eventType: EventType.RTCMembership | EventType.GroupCallMemberPrefix, diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index afa6da7bb5..65d4b67b56 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -26,7 +26,7 @@ import { } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; -import { SlotDescription, Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; +import { type SlotDescription, Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { makeMockEvent, makeMockRoom, diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 90a93ae064..acbfbdd074 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -160,7 +160,6 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( }, ]; const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky", slotDescription); - console.log({ room2: room2.roomId }); jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); expect(onStarted).toHaveBeenCalled(); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 78fc005d09..fc565ec7dd 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -28,8 +28,8 @@ import { import { MembershipManagerEvent, Status, type Transport } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { LegacyMembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; -import { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy.ts"; -import { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc.ts"; +import { type SessionMembershipData } from "../../../src/matrixrtc/membership/legacy.ts"; +import { type RtcMembershipData } from "../../../src/matrixrtc/membership/rtc.ts"; /** * Create a promise that will resolve once a mocked method is called. diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 02518378d1..6c9513e37a 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -22,8 +22,8 @@ import { CallMembership } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; import { DefaultCallApplicationDescription, - RtcSlotEventContent, - SlotDescription, + type RtcSlotEventContent, + type SlotDescription, slotDescriptionToId, } from "../../../src/matrixrtc"; import { mkMatrixEvent } from "../../../src/testing"; diff --git a/src/@types/event.ts b/src/@types/event.ts index 8a40ee5f5c..7b2fc889d1 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -63,8 +63,8 @@ import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; import { type POLICIES_ACCOUNT_EVENT_TYPE } from "../models/invites-ignorer-types.ts"; -import { RtcMembershipData } from "../matrixrtc/membership/rtc.ts"; -import { SessionMembershipData } from "../matrixrtc/membership/legacy.ts"; +import { type RtcMembershipData } from "../matrixrtc/membership/rtc.ts"; +import { type SessionMembershipData } from "../matrixrtc/membership/legacy.ts"; export enum EventType { // Room state events @@ -161,7 +161,7 @@ export enum EventType { CallNotify = "org.matrix.msc4075.call.notify", RTCNotification = "org.matrix.msc4075.rtc.notification", RTCDecline = "org.matrix.msc4310.rtc.decline", - RTCSlot = "org.matrix.msc4143.rtc.slot" + RTCSlot = "org.matrix.msc4143.rtc.slot", } export enum RelationType { diff --git a/src/matrixrtc/CallApplication.ts b/src/matrixrtc/CallApplication.ts index 901d0460dc..301e2a4d0c 100644 --- a/src/matrixrtc/CallApplication.ts +++ b/src/matrixrtc/CallApplication.ts @@ -1,8 +1,8 @@ -import { RtcSlotEventContent, SlotDescription } from "./types"; +import { type RtcSlotEventContent, type SlotDescription } from "./types.ts"; export const DefaultCallApplicationDescription: SlotDescription = { id: "", - application: "m.call" + application: "m.call", }; /** @@ -13,14 +13,14 @@ export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { "type": "m.call"; "m.call.id"?: string; }; - slot_id: `${string}#${string}`, + slot_id: `${string}#${string}`; } /** * Default slot for a room using "m.call". */ export const DefaultCallApplicationSlot: CallSlotEventContent = { application: { - "type": "m.call", + type: "m.call", }, slot_id: "m.call#", }; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 1c9c8be391..5087c1fc3d 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -19,8 +19,8 @@ import { type RTCCallIntent, type Transport, type SlotDescription } from "./type import { type IContent, type MatrixEvent } from "../models/event.ts"; import { logger } from "../logger.ts"; import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; -import { checkSessionsMembershipData, SessionMembershipData } from "./membership/legacy.ts"; -import { checkRtcMembershipData, RtcMembershipData } from "./membership/rtc.ts"; +import { checkSessionsMembershipData, type SessionMembershipData } from "./membership/legacy.ts"; +import { checkRtcMembershipData, type RtcMembershipData } from "./membership/rtc.ts"; import { EventType } from "../matrix.ts"; import { MatrixRTCMembershipParseError } from "./membership/common.ts"; @@ -45,8 +45,9 @@ enum MembershipKind { Session = "session", } - -type MembershipData = { kind: MembershipKind.RTC; data: RtcMembershipData } | { kind: MembershipKind.Session; data: SessionMembershipData }; +type MembershipData = + | { kind: MembershipKind.RTC; data: RtcMembershipData } + | { kind: MembershipKind.Session; data: SessionMembershipData }; // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a?: CallMembership, b?: CallMembership): boolean { @@ -72,6 +73,7 @@ export class CallMembership { if (sender === undefined) throw new Error("parentEvent is missing sender field"); try { + // Event types are strictly checked here. if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { this.membershipData = { kind: MembershipKind.RTC, data }; } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { @@ -171,7 +173,7 @@ export class CallMembership { return data.application; case MembershipKind.Session: default: - // XXX: This is a hack around + // XXX: This is a hack around return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -249,8 +251,9 @@ export class CallMembership { const { kind } = this.membershipData; switch (kind) { case MembershipKind.RTC: - console.log("isExpired", this.matrixEvent.unstableStickyExpiresAt, Date.now()); - return this.matrixEvent.unstableStickyExpiresAt ? Date.now() > this.matrixEvent.unstableStickyExpiresAt: false; + return this.matrixEvent.unstableStickyExpiresAt + ? Date.now() > this.matrixEvent.unstableStickyExpiresAt + : false; case MembershipKind.Session: default: return this.getMsUntilExpiry()! <= 0; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 68bce45b61..4740dac19c 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -50,7 +50,7 @@ import { type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; import { DefaultCallApplicationSlot } from "./CallApplication.ts"; import { slotDescriptionToId } from "./utils.ts"; -import { RtcMembershipData } from "./membership/rtc.ts"; +import { type RtcMembershipData } from "./membership/rtc.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; /** @@ -300,7 +300,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @returns The contents of the slot event, or null if no matching slot found. */ public static getRtcSlot( - room: Pick, + room: Pick, slotDescription: SlotDescription, ): RtcSlotEventContent | null { const slotId = slotDescriptionToId(slotDescription); @@ -325,8 +325,8 @@ export class MatrixRTCSession extends TypedEventEmitter< } if ( - "slot_id" in slotContent === false || - typeof slotContent.slot_id !== "string" || + "slot_id" in slotContent === false || + typeof slotContent.slot_id !== "string" || !slotContent.slot_id.startsWith(slotContent.application.type + "#") ) { logger.debug(`Mismatched app for ${room.roomId}`, slotContent); @@ -361,17 +361,14 @@ export class MatrixRTCSession extends TypedEventEmitter< // Has a slot and the application parameters match, fetch sticky members. callMemberEvents = [...room._unstable_getStickyEvents()].filter((e) => { if (e.getType() !== EventType.RTCMembership) { - console.log("Invalid type"); return false; } const content = e.getContent(); // Ensure the slot ID of the membership matches the state if (content.slot_id !== slotId) { - console.log("Invalid slot ID", content.slot_id, slotId); return false; } if (content.application.type !== slotDescription.application) { - console.log("Invalid application.type", content.application.type, slotDescription.application); return false; } return true; @@ -453,7 +450,6 @@ export class MatrixRTCSession extends TypedEventEmitter< ); } - return callMemberships; } @@ -612,7 +608,13 @@ export class MatrixRTCSession extends TypedEventEmitter< this.slotDescription, this.logger, ) - : new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); + : new LegacyMembershipManager( + joinConfig, + this.roomSubset, + this.client, + this.slotDescription, + this.logger, + ); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 9112dde9a6..6ad4289dc1 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -26,17 +26,10 @@ import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; import { type Logger, logger as rootLogger } from "../logger.ts"; import { type Room } from "../models/room.ts"; -import { - type CallMembership, - DEFAULT_EXPIRE_DURATION, -} from "./CallMembership.ts"; -import type { - RtcMembershipData, -} from "./membership/rtc.ts" -import type { - SessionMembershipData, -} from "./membership/legacy.ts" -import { type Transport, isMyMembership, type RTCCallIntent, Status, SlotDescription } from "./types.ts"; +import { type CallMembership, DEFAULT_EXPIRE_DURATION } from "./CallMembership.ts"; +import type { RtcMembershipData } from "./membership/rtc.ts"; +import type { SessionMembershipData } from "./membership/legacy.ts"; +import { type Transport, isMyMembership, type RTCCallIntent, Status, type SlotDescription } from "./types.ts"; import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts"; import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; @@ -185,7 +178,7 @@ type MembershipManagerClient = Pick< * - Stop the timer for the delay refresh * - Stop the timer for updating the state event */ -export abstract class MembershipManager +export abstract class MembershipManager extends TypedEventEmitter implements IMembershipManager { @@ -1024,25 +1017,22 @@ export class LegacyMembershipManager extends MembershipManager Promise = (myMembership) => { - return this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - myMembership, - this.memberId, - ); - }; - + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership, + this.memberId, + ); + }; } /** * Implementation of the Membership manager that uses sticky events * rather than state events. - * - * This exclusively sends RTCMembershipData + * + * This exclusively sends RTCMembershipData */ export class StickyEventMembershipManager extends MembershipManager { public constructor( @@ -1068,9 +1058,9 @@ export class StickyEventMembershipManager extends MembershipManager Promise = (myMembership) => { + protected clientSendMembership: (myMembership: RtcMembershipData | EmptyObject) => Promise = ( + myMembership, + ) => { return this.clientWithSticky._unstable_sendStickyEvent( this.room.roomId, MEMBERSHIP_STICKY_DURATION_MS, diff --git a/src/matrixrtc/membership/common.ts b/src/matrixrtc/membership/common.ts index c492fd2dd9..81bcd9522f 100644 --- a/src/matrixrtc/membership/common.ts +++ b/src/matrixrtc/membership/common.ts @@ -1,5 +1,11 @@ +/** + * Thrown when an event does not look valid for use with MatrixRTC. + */ export class MatrixRTCMembershipParseError extends Error { - constructor(public readonly type: string, public readonly errors: string[]) { + public constructor( + public readonly type: string, + public readonly errors: string[], + ) { super(`Does not match ${type}:\n${errors.join("\n")}`); } -} \ No newline at end of file +} diff --git a/src/matrixrtc/membership/legacy.ts b/src/matrixrtc/membership/legacy.ts index 3574403caa..8aac1ba85f 100644 --- a/src/matrixrtc/membership/legacy.ts +++ b/src/matrixrtc/membership/legacy.ts @@ -1,6 +1,6 @@ -import { EventType, IContent } from "../../matrix"; -import { RTCCallIntent, Transport } from "../types"; -import { MatrixRTCMembershipParseError } from "./common"; +import { EventType, type IContent } from "../../matrix.ts"; +import { type RTCCallIntent, type Transport } from "../types.ts"; +import { MatrixRTCMembershipParseError } from "./common.ts"; /** * **Legacy** (MatrixRTC) session membership data. @@ -31,8 +31,8 @@ export type SessionMembershipData = { * NOTE: This is still included for legacy reasons, but not consumed by the SDK. */ "focus_active": { - type: string, - focus_selection: "oldest_membership"|string, + type: string; + focus_selection: "oldest_membership" | string; }; /** @@ -72,6 +72,12 @@ export type SessionMembershipData = { "m.call.intent"?: RTCCallIntent; }; +/** + * Validates that `data` matches the format expected by the legacy form of MSC4143. + * @param data The event content. + * @returns true if `data` is valid SessionMembershipData + * @throws {MatrixRTCMembershipParseError} if the content is not valid + */ export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => { const prefix = " - "; const errors: string[] = []; @@ -108,8 +114,8 @@ export const checkSessionsMembershipData = (data: IContent): data is SessionMemb } if (errors.length) { - throw new MatrixRTCMembershipParseError(EventType.GroupCallMemberPrefix, errors) + throw new MatrixRTCMembershipParseError(EventType.GroupCallMemberPrefix, errors); } return true; -}; \ No newline at end of file +}; diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts index 3ce60bdec3..e12c7ebe0a 100644 --- a/src/matrixrtc/membership/rtc.ts +++ b/src/matrixrtc/membership/rtc.ts @@ -1,7 +1,6 @@ -import { EventType, IContent, MXID_PATTERN, RelationType } from "../../matrix"; -import { RtcSlotEventContent, Transport } from "../types"; -import { MatrixRTCMembershipParseError } from "./common"; - +import { EventType, type IContent, MXID_PATTERN, type RelationType } from "../../matrix.ts"; +import { type RtcSlotEventContent, type Transport } from "../types.ts"; +import { MatrixRTCMembershipParseError } from "./common.ts"; /** * Represents the current form of MSC4143. @@ -11,7 +10,7 @@ export interface RtcMembershipData { "member": { claimed_user_id: string; claimed_device_id: string; - id: string + id: string; }; "m.relates_to"?: { event_id: string; @@ -24,10 +23,14 @@ export interface RtcMembershipData { "sticky_key"?: string; } -export const checkRtcMembershipData = ( - data: IContent, - referenceUserId: string, -): data is RtcMembershipData => { +/** + * Validates that `data` matches the format expected by MSC4143. + * @param data The event content. + * @param sender The sender of the event. + * @returns true if `data` is valid RtcMembershipData + * @throws {MatrixRTCMembershipParseError} if the content is not valid + */ +export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => { const errors: string[] = []; const prefix = " - "; @@ -40,13 +43,20 @@ export const checkRtcMembershipData = ( if (typeof data.member !== "object" || data.member === null) { errors.push(prefix + "member must be an object"); } else { - if (typeof data.member.claimed_user_id !== "string") errors.push(prefix + "member.claimed_user_id must be string"); - else if (!MXID_PATTERN.test(data.member.claimed_user_id)) errors.push(prefix + "member.claimed_user_id must be a valid mxid"); + if (typeof data.member.claimed_user_id !== "string") { + errors.push(prefix + "member.claimed_user_id must be string"); + } else if (!MXID_PATTERN.test(data.member.claimed_user_id)) { + errors.push(prefix + "member.claimed_user_id must be a valid mxid"); + } // This is not what the spec enforces but there currently are no rules what power levels are required to // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there // is a proper definition when this is allowed. - else if (data.member.claimed_user_id !== referenceUserId) errors.push(prefix + "member.claimed_user_id must match the sender"); - if (typeof data.member.claimed_device_id !== "string") errors.push(prefix + "member.claimed_device_id must be string"); + else if (data.member.claimed_user_id !== sender) { + errors.push(prefix + "member.claimed_user_id must match the sender"); + } + if (typeof data.member.claimed_device_id !== "string") { + errors.push(prefix + "member.claimed_device_id must be string"); + } if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); } if (typeof data.application !== "object" || data.application === null) { @@ -103,8 +113,8 @@ export const checkRtcMembershipData = ( } if (errors.length) { - throw new MatrixRTCMembershipParseError(EventType.RTCMembership, errors) + throw new MatrixRTCMembershipParseError(EventType.RTCMembership, errors); } return true; -}; \ No newline at end of file +}; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 569927c4f8..1466448b86 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -207,7 +207,7 @@ export interface RtcSlotEventContent { // other application specific keys [key: string]: unknown; }; - slot_id: string, + slot_id: string; } /** From 32761ea9327f45dffaa68095a1b9312310c59125 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 6 Nov 2025 11:54:58 +0000 Subject: [PATCH 12/13] fix circular imports --- src/matrixrtc/CallMembership.ts | 2 +- src/matrixrtc/membership/legacy.ts | 4 ++-- src/matrixrtc/membership/rtc.ts | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 5087c1fc3d..a207283c00 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -21,8 +21,8 @@ import { logger } from "../logger.ts"; import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; import { checkSessionsMembershipData, type SessionMembershipData } from "./membership/legacy.ts"; import { checkRtcMembershipData, type RtcMembershipData } from "./membership/rtc.ts"; -import { EventType } from "../matrix.ts"; import { MatrixRTCMembershipParseError } from "./membership/common.ts"; +import { EventType } from "../@types/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. diff --git a/src/matrixrtc/membership/legacy.ts b/src/matrixrtc/membership/legacy.ts index 8aac1ba85f..4f57a1d2da 100644 --- a/src/matrixrtc/membership/legacy.ts +++ b/src/matrixrtc/membership/legacy.ts @@ -1,4 +1,4 @@ -import { EventType, type IContent } from "../../matrix.ts"; +import { type IContent } from "../../matrix.ts"; import { type RTCCallIntent, type Transport } from "../types.ts"; import { MatrixRTCMembershipParseError } from "./common.ts"; @@ -114,7 +114,7 @@ export const checkSessionsMembershipData = (data: IContent): data is SessionMemb } if (errors.length) { - throw new MatrixRTCMembershipParseError(EventType.GroupCallMemberPrefix, errors); + throw new MatrixRTCMembershipParseError("bar", errors); } return true; diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts index e12c7ebe0a..a23c23ed3f 100644 --- a/src/matrixrtc/membership/rtc.ts +++ b/src/matrixrtc/membership/rtc.ts @@ -1,4 +1,6 @@ -import { EventType, type IContent, MXID_PATTERN, type RelationType } from "../../matrix.ts"; +import { MXID_PATTERN } from "../../models/room-member.ts"; +import { IContent } from "../../models/event.ts"; +import { RelationType } from "../../types.ts"; import { type RtcSlotEventContent, type Transport } from "../types.ts"; import { MatrixRTCMembershipParseError } from "./common.ts"; @@ -113,7 +115,7 @@ export const checkRtcMembershipData = (data: IContent, sender: string): data is } if (errors.length) { - throw new MatrixRTCMembershipParseError(EventType.RTCMembership, errors); + throw new MatrixRTCMembershipParseError("foo", errors); } return true; From c56fe525aa4dcb8c3b8629dc303b95f4dd4988bd Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 6 Nov 2025 13:25:05 +0000 Subject: [PATCH 13/13] fix lint --- src/matrixrtc/membership/rtc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts index a23c23ed3f..f643e8453d 100644 --- a/src/matrixrtc/membership/rtc.ts +++ b/src/matrixrtc/membership/rtc.ts @@ -1,6 +1,6 @@ import { MXID_PATTERN } from "../../models/room-member.ts"; -import { IContent } from "../../models/event.ts"; -import { RelationType } from "../../types.ts"; +import type { IContent } from "../../models/event.ts"; +import type { RelationType } from "../../types.ts"; import { type RtcSlotEventContent, type Transport } from "../types.ts"; import { MatrixRTCMembershipParseError } from "./common.ts";