diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 60d1b2807..2734b682f 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,23 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type IContent, type MatrixEvent } from "../../../src"; -import { - CallMembership, - type SessionMembershipData, - DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, -} from "../../../src/matrixrtc/CallMembership"; -import { membershipTemplate } from "./mocks"; - -function makeMockEvent(originTs = 0): MatrixEvent { - return { - getTs: vi.fn().mockReturnValue(originTs), - getSender: vi.fn().mockReturnValue("@alice:example.org"), - getId: vi.fn().mockReturnValue("$eventid"), - getContent: vi.fn().mockReturnValue({}), - } as unknown as MatrixEvent; -} +import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts"; +import { type IContent, type MatrixEvent } from "../../../src/models/event.ts"; +import { EventType } from "../../../src/@types/event.ts"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts"; function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership { vi.mocked(ev.getContent).mockReturnValue(content); @@ -40,6 +27,15 @@ function createCallMembership(ev: MatrixEvent, content: IContent): CallMembershi describe("CallMembership", () => { describe("SessionMembershipData", () => { + function makeMockEvent(originTs = 0): MatrixEvent { + return { + getTs: vi.fn().mockReturnValue(originTs), + getSender: vi.fn().mockReturnValue("@alice:example.org"), + getId: vi.fn().mockReturnValue("$eventid"), + getContent: vi.fn().mockReturnValue({}), + getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + } as unknown as MatrixEvent; + } beforeEach(() => { vi.useFakeTimers(); }); @@ -212,9 +208,40 @@ describe("CallMembership", () => { expect(membership.isExpired()).toBe(true); }); }); + describe("expiry calculation", () => { + let fakeEvent: MatrixEvent; + let membership: CallMembership; + + beforeEach(() => { + // server origin timestamp for this event is 1000 + fakeEvent = makeMockEvent(1000); + membership = createCallMembership(fakeEvent!, membershipTemplate); + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useFakeTimers(); + }); + + it("calculates time until expiry", () => { + vi.setSystemTime(2000); + // should be using absolute expiry time + expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); + }); + }); }); describe("RtcMembershipData", () => { + function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent { + return { + getTs: vi.fn().mockReturnValue(originTs), + getSender: vi.fn().mockReturnValue("@alice:example.org"), + getId: vi.fn().mockReturnValue("$eventid"), + getContent: vi.fn().mockReturnValue(content), + getType: vi.fn().mockReturnValue(EventType.RTCMembership), + } as unknown as MatrixEvent; + } const membershipTemplate: RtcMembershipData = { slot_id: "m.call#", application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, @@ -234,6 +261,11 @@ describe("CallMembership", () => { createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" }); }).toThrow(); }); + it("rejects membership with slot_id that contains extra #", () => { + expect(() => { + createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" }); + }).toThrow(); + }); it("accepts membership with valid slot_id", () => { expect(() => { createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" }); @@ -334,13 +366,9 @@ describe("CallMembership", () => { }).toThrow(); }); - it.skip("considers memberships unexpired if local age low enough", () => { - // TODO link prev event - }); - - it.skip("considers memberships expired if local age large enough", () => { - // TODO link prev event - }); + // TODO link prev event + it.todo("considers memberships unexpired if local age low enough"); + it.todo("considers memberships expired if local age large enough"); describe("getTransport", () => { it("gets the correct active transport with oldest_membership", () => { @@ -397,44 +425,9 @@ describe("CallMembership", () => { expect(membership.isExpired()).toBe(false); }); }); - }); - - describe("expiry calculation", () => { - let fakeEvent: MatrixEvent; - let membership: CallMembership; - - beforeEach(() => { - // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = createCallMembership(fakeEvent!, membershipTemplate); - - vi.useFakeTimers(); + it("uses unpadded base64 for RTC backend identities", async () => { + const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate })); + expect(membership.rtcBackendIdentity).toBe("j9N1u04ZbvI9qKf3cxrf2NauD-fIGJ4uAcYkfI9V7SY"); }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("calculates time until expiry", () => { - vi.setSystemTime(2000); - // should be using absolute expiry time - expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000); - }); - }); - - it("uses unpadded base64 for RTC backend identities", async () => { - expect( - await CallMembership.computeRtcBackendIdentity(makeMockEvent(), { - kind: "rtc", - data: { - 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: "xyzRANDOMxyz" }, - rtc_transports: [{ type: "livekit" }], - versions: [], - msc4354_sticky_key: "abc123", - }, - }), - ).toBe("2+h2ELE1XY/NsuveToZOekORCoyQMO6V0W7XZUWk5Q4"); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index cd431074b..ad88f13e5 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,25 +25,31 @@ import { type MatrixEvent, type Room, } from "../../../src"; -import { KnownMembership } from "../../../src/@types/membership"; -import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; -import { MembershipManagerEvent } from "../../../src/matrixrtc/IMembershipManager"; -import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; +import { KnownMembership } from "../../../src/@types/membership.ts"; +import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession.ts"; +import { MembershipManagerEvent } from "../../../src/matrixrtc/IMembershipManager.ts"; +import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types.ts"; import { makeMockEvent, makeMockRoom, - membershipTemplate, + sessionMembershipTemplate, makeKey, type MembershipData, mockRoomState, mockRTCEvent, owmMemberIdentity, -} from "./mocks"; + rtcMembershipTemplate, +} from "./mocks.ts"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager.ts"; import { flushPromises } from "../../test-utils/flushPromises.ts"; +import { + computeRtcIdentityRaw, + type RtcMembershipData, + type SessionMembershipData, +} from "../../../src/matrixrtc/membershipData/index.ts"; const mockFocus = { type: "mock" }; @@ -64,6 +70,8 @@ describe("MatrixRTCSession", () => { }); afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); client.stopClient(); client.matrixRTC.stop(); if (sess) await sess.stop(); @@ -91,17 +99,50 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, testCreateSticky: true, + createWithDefaults: false, }, { listenForStickyEvents: true, listenForMemberStateEvents: false, testCreateSticky: true, + createWithDefaults: false, }, ])( "roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { + function generateMembership( + opts: { type: string; callId?: string; createdTs?: number; expires?: number; deviceId?: string } = { + type: "m.call", + }, + ): MembershipData { + if (testConfig.testCreateSticky) { + // Ignoring createdTs, expires which are legacy + return { + ...rtcMembershipTemplate, + member: { + ...rtcMembershipTemplate.member, + device_id: opts.deviceId ?? rtcMembershipTemplate.member.device_id, + }, + slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id, + application: { + ...rtcMembershipTemplate.application, + type: opts.type, + }, + } satisfies RtcMembershipData & { user_id: string }; + } + + return { + ...sessionMembershipTemplate, + application: opts.type, + device_id: opts.deviceId ?? sessionMembershipTemplate.device_id, + call_id: opts.callId ?? sessionMembershipTemplate.call_id, + created_ts: opts.createdTs, + expires: opts.expires, + } satisfies SessionMembershipData & { user_id: string }; + } + it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, async () => { - const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky); mockRoom.getLiveTimeline.mockReturnValue({ getState: vi.fn().mockReturnValue(undefined), } as unknown as EventTimeline); @@ -111,8 +152,8 @@ describe("MatrixRTCSession", () => { const stateWarningWasLogged = () => warnLogSpy.mock.calls.find((call) => (call[1] as string).includes("Couldn't get state for room")); - MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); - await flushPromises(); + const sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); + await sess.initialMembershipCalculated; if (testConfig.listenForMemberStateEvents) { // eslint-disable-next-line @vitest/no-conditional-expect @@ -124,7 +165,7 @@ describe("MatrixRTCSession", () => { }); it("creates a room-scoped session from room state", async () => { - const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -132,18 +173,18 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM"); - 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).toEqual({ type: "m.call" }); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); expect(sess?.slotDescription.id).toEqual("ROOM"); }); it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { + const testMembership = Object.assign({}, sessionMembershipTemplate, { application: "not-m.call", }); const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); @@ -157,7 +198,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", }); @@ -171,12 +212,10 @@ describe("MatrixRTCSession", () => { expect(sess?.memberships).toHaveLength(0); }); - it("ignores expired memberships events", async () => { + it("ignores expired memberships events if legacy session", async () => { vi.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + const expiredMembership = generateMembership({ type: "m.call", expires: 1000, deviceId: "EXPIRED" }); + const mockRoom = makeMockRoom([generateMembership(), expiredMembership], testConfig.testCreateSticky); vi.advanceTimersByTime(2000); sess = MatrixRTCSession.sessionForSlot( @@ -184,15 +223,14 @@ describe("MatrixRTCSession", () => { mockRoom, callSession, testConfig.createWithDefaults ? undefined : testConfig, - ); - await flushPromises(); - expect(sess?.memberships.length).toEqual(1); + )!; + await sess.initialMembershipCalculated; + expect(sess?.memberships.length).toEqual(testConfig.testCreateSticky ? 2 : 1); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - vi.useRealTimers(); }); - it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + it("ignores memberships events of members not in the room", async () => { + const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -200,12 +238,16 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + await sess.initialMembershipCalculated; expect(sess?.memberships.length).toEqual(0); }); - it("ignores memberships events with no sender", () => { + it("ignores memberships events with no sender", async () => { // 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, @@ -213,15 +255,14 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + await sess.initialMembershipCalculated; expect(sess?.memberships.length).toEqual(0); }); it("honours created_ts", async () => { vi.useFakeTimers(); vi.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; + const expiredMembership = generateMembership({ type: "m.call", createdTs: 500, expires: 1000 }); const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -229,12 +270,13 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); - await flushPromises(); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - vi.useRealTimers(); + await sess.initialMembershipCalculated; + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual( + testConfig.testCreateSticky ? undefined : 1500, + ); }); - it("returns empty session if no membership events are present", () => { + it("returns empty session if no membership events are present", async () => { const mockRoom = makeMockRoom([], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -242,6 +284,7 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + await sess.initialMembershipCalculated; expect(sess?.memberships).toHaveLength(0); }); @@ -317,8 +360,8 @@ describe("MatrixRTCSession", () => { expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); + it("ignores memberships with no device_id", async () => { + const testMembership = Object.assign({}, sessionMembershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); const sess = MatrixRTCSession.sessionForSlot( @@ -327,11 +370,12 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + await sess.initialMembershipCalculated; expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); + it("ignores memberships with no call_id", async () => { + const testMembership = Object.assign({}, sessionMembershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); sess = MatrixRTCSession.sessionForSlot( @@ -340,21 +384,30 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + await sess.initialMembershipCalculated; expect(sess.memberships).toHaveLength(0); }); it("assigns RTC backend identities to memberships", async () => { - const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, testConfig.createWithDefaults ? undefined : testConfig, ); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess?.memberships.length).toEqual(1); // Backend identity is expected to not be hashed with a legacy (session) membership - expect(sess?.memberships[0].rtcBackendIdentity).toEqual("@mock:user.example:AAAAAAA"); + expect(sess?.memberships[0].rtcBackendIdentity).toEqual( + testConfig.testCreateSticky + ? await computeRtcIdentityRaw( + rtcMembershipTemplate.member.user_id, + rtcMembershipTemplate.member.device_id, + rtcMembershipTemplate.member.id, + ) + : "@mock:user.example:AAAAAAA", + ); }); }, ); @@ -362,14 +415,15 @@ describe("MatrixRTCSession", () => { describe("roomsessionForSlot combined state", () => { it("perfers sticky events when both membership and sticky events appear for the same user", async () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([rtcMembershipTemplate]); mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...membershipTemplate, - msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + ...rtcMembershipTemplate, + msc4354_sticky_key: `_${rtcMembershipTemplate.user_id}_${rtcMembershipTemplate.member.device_id}`, }, mockRoom.roomId, + 5000, ); return [ev as StickyMatrixEvent]; }); @@ -379,10 +433,10 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM"); - expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].scope).toEqual(undefined); expect(sess?.memberships[0].application).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); @@ -390,14 +444,18 @@ describe("MatrixRTCSession", () => { }); it("combines sticky and membership events when both exist", async () => { // 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, + ...rtcMembershipTemplate, + member: { + ...rtcMembershipTemplate.member, + user_id: stickyUserId, + }, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`, }, mockRoom.roomId, 15000, @@ -410,37 +468,42 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - await flushPromises(); + await sess.initialMembershipCalculated; const memberships = sess.memberships; expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(stickyUserId); expect(memberships[0].slotDescription.id).toEqual("ROOM"); - expect(memberships[0].scope).toEqual("m.room"); - expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].scope).toEqual(undefined); + expect(memberships[0].applicationData).toEqual({ type: "m.call" }); expect(memberships[0].deviceId).toEqual("AAAAAAA"); 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("ROOM"); }); it("handles an incoming sticky event to an existing session", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate], false); const stickyUserId = "@stickyev:user.example"; sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess.memberships.length).toEqual(1); + const membershipRecalculated = new Promise((r) => sess?.once(MatrixRTCSessionEvent.MembershipsChanged, r)); const stickyEv = mockRTCEvent( { - ...membershipTemplate, + ...rtcMembershipTemplate, + member: { + ...rtcMembershipTemplate.member, + user_id: stickyUserId, + }, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`, }, mockRoom.roomId, 15000, @@ -450,7 +513,7 @@ describe("MatrixRTCSession", () => { return [stickyEv]; }); mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []); - await flushPromises(); + await membershipRecalculated; expect(sess.memberships.length).toEqual(2); }); }); @@ -460,13 +523,13 @@ describe("MatrixRTCSession", () => { vi.useFakeTimers(); vi.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); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess.getOldestMembership()!.deviceId).toEqual("old"); vi.useRealTimers(); }); @@ -483,12 +546,12 @@ describe("MatrixRTCSession", () => { vi.useFakeTimers(); vi.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); - await flushPromises(); + await sess.initialMembershipCalculated; expect(sess.getConsensusCallIntent()).toEqual(result); vi.useRealTimers(); }); @@ -505,13 +568,13 @@ describe("MatrixRTCSession", () => { vi.useFakeTimers(); vi.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); @@ -550,13 +613,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); @@ -590,7 +653,7 @@ describe("MatrixRTCSession", () => { mockRoom = makeMockRoom([]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; }); afterEach(async () => { @@ -630,7 +693,7 @@ describe("MatrixRTCSession", () => { sess!.joinRTCSession(owmMemberIdentity, [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()! }]); await sess!._onRTCSessionMemberUpdate(); const ownMembershipId = sess?.memberships[0].eventId; @@ -679,7 +742,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", @@ -720,13 +783,16 @@ 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]); await sess!._onRTCSessionMemberUpdate(); // Simulate a join, including the update to the room state sess!.joinRTCSession(owmMemberIdentity, [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()! }, + ]); await sess!._onRTCSessionMemberUpdate(); // check we send out join event @@ -741,9 +807,12 @@ 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]); await sess!._onRTCSessionMemberUpdate(); - mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [ + sessionMembershipTemplate, + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + ]); await sess!._onRTCSessionMemberUpdate(); // check we send out join event @@ -758,9 +827,9 @@ describe("MatrixRTCSession", () => { describe("onMembershipsChanged", () => { it("only emit if membership changes", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; const onMembershipsChanged = vi.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -940,12 +1009,12 @@ describe("MatrixRTCSession", () => { vi.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); - await flushPromises(); + await sess.initialMembershipCalculated; // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); @@ -959,14 +1028,14 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); // member2 leaves triggering key rotation - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); await 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]); await sess._onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit vi.advanceTimersByTime(1000); @@ -992,7 +1061,7 @@ describe("MatrixRTCSession", () => { it("re-sends key if a new member joins", async () => { vi.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { @@ -1013,11 +1082,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = vi.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]); await sess._onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -1036,8 +1105,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", }); @@ -1081,16 +1150,16 @@ 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", }; const mockRoom = makeMockRoom([member1, member2]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -1150,12 +1219,12 @@ describe("MatrixRTCSession", () => { vi.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); - await flushPromises(); + await sess.initialMembershipCalculated; const onMyEncryptionKeyChanged = vi.fn(); sess.on( MatrixRTCSessionEvent.EncryptionKeyChanged, @@ -1189,7 +1258,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); await sess._onRTCSessionMemberUpdate(); vi.advanceTimersByTime(KEY_DELAY); @@ -1236,7 +1305,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}` })); } vi.useFakeTimers(); try { @@ -1251,7 +1320,7 @@ describe("MatrixRTCSession", () => { if (i === 0) { // if first time around then set up the session sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true, }); @@ -1278,9 +1347,9 @@ describe("MatrixRTCSession", () => { const realSetTimeout = setTimeout; vi.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -1295,11 +1364,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = vi.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]); await sess._onRTCSessionMemberUpdate(); await new Promise((resolve) => { @@ -1320,10 +1389,10 @@ describe("MatrixRTCSession", () => { sendToDeviceMock.mockImplementation(resolve); }); - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); - sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { + await sess.initialMembershipCalculated; + sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true, useExperimentalToDeviceTransport: true, }); @@ -1342,17 +1411,17 @@ describe("MatrixRTCSession", () => { }); describe("receiving", () => { - beforeAll(() => { + beforeEach(() => { vi.useFakeTimers(); }); - afterAll(() => { + afterEach(() => { vi.useRealTimers(); }); it("collects keys from encryption events", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1381,9 +1450,9 @@ describe("MatrixRTCSession", () => { }); it("collects keys at non-zero indices", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); - await flushPromises(); + await sess.initialMembershipCalculated; sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1413,7 +1482,7 @@ describe("MatrixRTCSession", () => { }); it("collects keys by merging", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1479,7 +1548,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!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1543,7 +1612,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!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1592,7 +1661,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!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true }); @@ -1615,7 +1684,7 @@ describe("MatrixRTCSession", () => { it("tracks total age statistics for collected keys", async () => { vi.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // defaults to getTs() @@ -1671,7 +1740,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); @@ -1687,7 +1756,7 @@ describe("MatrixRTCSession", () => { }); it("returns membershipStatus once joinRTCSession got called", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.membershipStatus).toBe(undefined); @@ -1696,7 +1765,7 @@ describe("MatrixRTCSession", () => { }); }); it("reemits membershipManager events", () => { - sess = MatrixRTCSession.sessionForSlot(client, makeMockRoom([membershipTemplate]), callSession); + sess = MatrixRTCSession.sessionForSlot(client, makeMockRoom([sessionMembershipTemplate]), callSession); const delayIdChanged = vi.fn(); sess.on(MembershipManagerEvent.DelayIdChanged, delayIdChanged); const statusChanged = vi.fn(); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index ea6978115..8328561b5 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -16,23 +16,50 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc"; -import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; +import { + makeMockRoom, + type MembershipData, + sessionMembershipTemplate, + mockRoomState, + mockRTCEvent, + rtcMembershipTemplate, +} from "./mocks.ts"; import { logger } from "../../../src/logger"; import { flushPromises } from "../../test-utils/flushPromises"; +import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData"; describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( "MatrixRTCSessionManager ($eventKind)", ({ eventKind }) => { let client: MatrixClient; + function generateMembership(opts: { type: string; callId?: string } = { type: "m.call" }): MembershipData { + if (eventKind === "sticky") { + return { + ...rtcMembershipTemplate, + slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id, + application: { + ...rtcMembershipTemplate.application, + type: opts.type, + }, + } satisfies RtcMembershipData & { user_id: string }; + } + + return { + ...sessionMembershipTemplate, + application: opts.type, + call_id: opts.callId ?? sessionMembershipTemplate.call_id, // approximate version. + } satisfies SessionMembershipData & { user_id: string }; + } + async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise { if (eventKind === "memberState") { - mockRoomState(room, [{ user_id: membershipTemplate.user_id }]); + mockRoomState(room, [{ user_id: sessionMembershipTemplate.user_id }]); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; client.emit(RoomStateEvent.Events, membEvent, roomState, null); } else { - membershipData.splice(0, 1, { user_id: membershipTemplate.user_id }); + membershipData.splice(0, 1, { user_id: sessionMembershipTemplate.user_id }); client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000)); } await flushPromises(); @@ -46,22 +73,17 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( afterEach(() => { client.stopClient(); client.matrixRTC.stop(); + vi.resetAllMocks(); }); it("Fires event when session starts", async () => { - const onStarted = vi.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky"); - vi.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - await flushPromises(); - expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - } + const room1 = makeMockRoom([generateMembership({ type: "m.call" })], eventKind === "sticky"); + vi.spyOn(client, "getRooms").mockReturnValue([room1]); + const sessionStartedPromise = new Promise((resolve) => + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve), + ); + client.emit(ClientEvent.Room, room1); + await expect(sessionStartedPromise).resolves.toBeTruthy(); }); it("Doesn't fire event if unrelated sessions starts", () => { @@ -69,7 +91,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky"); + const room1 = makeMockRoom([generateMembership({ type: "m.other" })], eventKind === "sticky"); vi.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); @@ -80,17 +102,24 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( }); it("Fires event when session ends", async () => { - const onEnded = vi.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const membershipData: MembershipData[] = [membershipTemplate]; + const sessionStartedPromise = new Promise((resolve) => + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve), + ); + const sessionEndedPromise = new Promise((resolve) => + client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)), + ); + const membershipData: MembershipData[] = [generateMembership()]; const room1 = makeMockRoom(membershipData, eventKind === "sticky"); vi.spyOn(client, "getRooms").mockReturnValue([room1]); vi.spyOn(client, "getRoom").mockReturnValue(room1); client.emit(ClientEvent.Room, room1); - await flushPromises(); + await sessionStartedPromise; await sendLeaveMembership(room1, membershipData); - expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + await expect(sessionEndedPromise).resolves.toStrictEqual([ + room1.roomId, + client.matrixRTC.getActiveRoomSession(room1), + ]); }); it("Fires correctly with custom sessionDescription", async () => { @@ -106,48 +135,44 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( sessionManager.start(); sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + const sessionStartedPromise = new Promise((resolve) => + sessionManager.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve), + ); + const sessionEndedPromise = new Promise((resolve) => + sessionManager.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)), + ); - 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"); - vi.spyOn(client, "getRooms").mockReturnValue([room1]); - client.emit(ClientEvent.Room, room1); - await flushPromises(); - expect(onStarted).not.toHaveBeenCalled(); - onStarted.mockClear(); + // Create a session for applicaation m.other, we ignore this session because it lacks a call_id + const room1MembershipData: MembershipData[] = [generateMembership({ type: "m.other" })]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky"); + vi.spyOn(client, "getRooms").mockReturnValue([room1]); + client.emit(ClientEvent.Room, room1); + await flushPromises(); + expect(onStarted).not.toHaveBeenCalled(); - // 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"); - vi.spyOn(client, "getRooms").mockReturnValue([room1, room2]); - client.emit(ClientEvent.Room, room2); - await flushPromises(); - expect(onStarted).toHaveBeenCalled(); - onStarted.mockClear(); + // Create a session for applicaation m.notCall. We expect this call to be tracked because it has matching call_id + const room2MembershipData: MembershipData[] = [generateMembership({ type: "m.notCall", callId: "test" })]; + const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky"); + vi.spyOn(client, "getRooms").mockReturnValue([room2]); + client.emit(ClientEvent.Room, room2); + await flushPromises(); + await sessionStartedPromise; - // Stop room1's RTC session. Tracked. - vi.spyOn(client, "getRoom").mockReturnValue(room2); - await sendLeaveMembership(room2, room2MembershipData); - expect(onEnded).toHaveBeenCalled(); - onEnded.mockClear(); + // Stop room1's RTC session. Not tracked. + vi.spyOn(client, "getRoom").mockReturnValue(room1); + await sendLeaveMembership(room1, room1MembershipData); + expect(onEnded).not.toHaveBeenCalled(); - // Stop room1's RTC session. Not tracked. - vi.spyOn(client, "getRoom").mockReturnValue(room1); - await sendLeaveMembership(room1, room1MembershipData); - expect(onEnded).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - } + // Stop room2's RTC session. Tracked. + vi.spyOn(client, "getRoom").mockReturnValue(room2); + await sendLeaveMembership(room2, room2MembershipData); + await sessionEndedPromise; }); it("Doesn't fire event if unrelated sessions ends", async () => { const onEnded = vi.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const membership: MembershipData[] = [generateMembership({ type: "m.other_app" })]; const room1 = makeMockRoom(membership, eventKind === "sticky"); vi.spyOn(client, "getRooms").mockReturnValue([room1]); vi.spyOn(client, "getRoom").mockReturnValue(room1); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index e2e918f76..6bcb3970e 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,15 +25,16 @@ import { type Room, MAX_STICKY_DURATION_MS, } from "../../../src"; +import { MembershipManagerEvent, Status, type Transport, type LivekitFocusSelection } from "../../../src/matrixrtc"; import { - MembershipManagerEvent, - Status, - type Transport, - type SessionMembershipData, - type LivekitFocusSelection, -} from "../../../src/matrixrtc"; -import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; + makeMockClient, + makeMockRoom, + sessionMembershipTemplate, + mockCallMembership, + type MockClient, +} from "./mocks.ts"; import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -90,7 +91,7 @@ describe("MembershipManager", () => { // Default to fake timers. vi.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. vi.mocked(client._unstable_sendDelayedStateEvent).mockResolvedValue({ delay_id: "id" }); vi.mocked(client._unstable_updateDelayedEvent).mockResolvedValue({}); @@ -400,7 +401,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await vi.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 vi.runOnlyPendingTimersAsync(); @@ -506,7 +507,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 vi.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); @@ -529,7 +530,7 @@ describe("MembershipManager", () => { vi.mocked(client._unstable_sendDelayedStateEvent).mockClear(); await manager.onRTCSessionMemberUpdate([ - mockCallMembership(membershipTemplate, room.roomId), + mockCallMembership(sessionMembershipTemplate, room.roomId), mockCallMembership( { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, room.roomId, @@ -555,7 +556,7 @@ describe("MembershipManager", () => { vi.mocked(client._unstable_sendDelayedStateEvent).mockClear(); // Our own membership is removed: - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); await vi.advanceTimersByTimeAsync(1); expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); @@ -578,7 +579,7 @@ describe("MembershipManager", () => { const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); await vi.advanceTimersByTimeAsync(10_000); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); resolve({ delay_id: "id" }); await vi.advanceTimersByTimeAsync(10_000); @@ -950,7 +951,10 @@ 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); @@ -964,7 +968,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]); @@ -1039,7 +1043,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 5dfbef7ac..0e2f55705 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -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 { logger } from "../../../src/logger.ts"; import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts"; @@ -983,7 +983,7 @@ describe("RTCEncryptionManager", () => { rtcBackendIdentity: string, ): 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", rtcBackendIdentity, ); @@ -998,7 +998,7 @@ describe("RTCEncryptionManager", () => { */ function aStateBaseMembership(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", `${userId}|${deviceId}`, ); diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index 4d3540d01..682321e34 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ limitations under the License. import { type Mocked } from "vitest"; -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks"; +import { makeMockEvent, makeMockRoom, sessionMembershipTemplate, makeKey } from "./mocks.ts"; import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport"; import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; import { EventType, MatrixClient, RoomEvent } from "../../../src"; @@ -49,7 +49,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/ToDeviceKeyTransport.spec.ts b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts index 8f03e0a8c..d2f578c0c 100644 --- a/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import { type Mocked } from "vitest"; -import { makeMockEvent } from "./mocks"; +import { makeMockEvent } from "./mocks.ts"; import { ClientEvent, EventType, type MatrixClient } from "../../../src"; import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { getMockClientWithEventEmitter } from "../../test-utils/client.ts"; diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 0a92f312f..d3573a0d9 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,12 +18,12 @@ import { EventEmitter } from "stream"; import { type Mocked, type MockedObject } from "vitest"; import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; -import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc"; +import { CallMembership } from "../../../src/matrixrtc"; import { secureRandomString } from "../../../src/randomstring"; +import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData"; import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager"; -import { logger } from "../../../src/logger.ts"; -export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; +export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; export const owmMemberIdentity: CallMembershipIdentityParts = { deviceId: "AAAAAAA", @@ -31,7 +31,7 @@ export const owmMemberIdentity: CallMembershipIdentityParts = { userId: "@alice:example.org", }; -export const membershipTemplate: SessionMembershipData & { user_id: string } = { +export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { application: "m.call", call_id: "", user_id: "@mock:user.example", @@ -52,6 +52,39 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { ], }; +export const rtcMembershipTemplate: RtcMembershipData & { user_id: string } = { + user_id: "@mock:user.example", + application: { + type: "m.call", + }, + member: { + id: "IDIDID", + user_id: "@mock:user.example", + device_id: "AAAAAAA", + }, + slot_id: "m.call#ROOM", + versions: [], + rtc_transports: [ + { + type: "livekit", + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, + foci_preferred: [ + { + 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", + }, + ], + }, + ], + msc4354_sticky_key: "m.call#", +}; + export type MockClient = MockedObject< Pick< MatrixClient, @@ -198,7 +231,7 @@ export function mockCallMembership( const ev = mockRTCEvent(membershipData, roomId); vi.mocked(ev.getContent).mockReturnValue(membershipData); const data = CallMembership.membershipDataFromMatrixEvent(ev); - return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger); + return new CallMembership(ev, data, rtcBackendIdentity ?? "xx"); } export function makeKey(id: number, key: string): { key: string; index: number } { diff --git a/src/@types/event.ts b/src/@types/event.ts index 65a810c77..ec1051f0f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -60,7 +60,7 @@ import { type ICallNotifyContent, } 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 RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts"; import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 49c7228c1..8d884d693 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,16 +14,20 @@ 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 { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; -import type { RTCCallIntent, Transport } from "./types.ts"; -import { type MatrixEvent, type IContent } from "../models/event.ts"; -import { type RelationType } from "../@types/event.ts"; -import { sha256 } from "../digest.ts"; -import { encodeUnpaddedBase64 } from "../base64.ts"; -import { type Logger } from "../logger.ts"; +import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts"; +import { type MatrixEvent } from "../models/event.ts"; +import { type Logger, logger } from "../logger.ts"; +import { computeSlotId, slotIdToDescription } from "./utils.ts"; +import { + checkRtcMembershipData, + computeRtcIdentityRaw, + type RtcMembershipData, + checkSessionsMembershipData, + type SessionMembershipData, + MatrixRTCMembershipParseError, +} from "./membershipData/index.ts"; +import { EventType } from "../@types/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -32,331 +36,118 @@ import { type Logger } from "../logger.ts"; */ export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4; -type CallScope = "m.room" | "m.user"; -type Member = { - user_id: string; - device_id: string; +/** + * Describes the source event type that provided the membership data. + */ +enum MembershipKind { /** - * The id used on the media backend. - * (With livekit this is the participant identity on the LK SFU) - * This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`. + * The modern MSC4143 format event. */ - id: string; -}; - -export interface RtcMembershipData { - "slot_id": string; - "member": Member; - "m.relates_to"?: { - event_id: string; - rel_type: RelationType.Reference; - }; - "application": { - type: string; - // other application specific keys - [key: string]: unknown; - }; - "rtc_transports": Transport[]; - "versions": string[]; - "msc4354_sticky_key"?: string; - "sticky_key"?: string; + RTC = "rtc", + /** + * The legacy call event type. + */ + Session = "session", } -const checkRtcMembershipData = ( - data: IContent, - errors: string[], - referenceUserId: string, -): data is RtcMembershipData => { - const prefix = " - "; +type MembershipData = + | { kind: MembershipKind.RTC; data: RtcMembershipData } + | { kind: MembershipKind.Session; data: SessionMembershipData }; - // 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; - - /** - * 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; - - /** - * The id used on the media backend. - * (With livekit this is the participant identity on the LK SFU) - * This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`. - * - * It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}` - */ - "membershipID"?: 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"); - } - - return errors.length === 0; -}; - -type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; -// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. +type LimitedEvent = Pick; +// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion. export class CallMembership { + /** + * Parse the membershipdata from a call membership event. + * @param matrixEvent The Matrix event to read. + * @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format. + * @throws If the content is neither format. + */ + public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData { + const sender = matrixEvent.getSender(); + const evType = matrixEvent.getType(); + const data = matrixEvent.getContent(); + if (sender === undefined) throw new Error("matrixEvent is missing sender field"); + try { + // Event types are strictly checked here. + if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { + return { kind: MembershipKind.RTC, data }; + } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { + return { 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 invalid data", data); + } + throw ex; + } + } + + /** + * Parse the contents of a MatrixEvent and create a CallMembership instance. + * @param matrixEvent The Matrix event to read. + */ + public static async parseFromEvent(matrixEvent: LimitedEvent): Promise { + const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent); + const rtcBackendIdentity = + membershipData.kind === MembershipKind.RTC + ? await computeRtcIdentityRaw( + membershipData.data.member.user_id, + membershipData.data.member.device_id, + membershipData.data.member.id, + ) + : `${matrixEvent.getSender()}:${membershipData.data.device_id}`; + return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity); + } + public static equal(a?: CallMembership, b?: CallMembership): boolean { return deepCompare(a?.membershipData, b?.membershipData); } - private logger?: Logger; + private logger: Logger; + /** The parsed data from the Matrix event. * To access checked eventId and sender from the matrixEvent. * Class construction will fail if these values cannot get obtained. */ - private readonly matrixEventData: { eventId: string; sender: string; ts: number }; - - public constructor( - /** The required parts of the Matrix event that this membership is based on */ - matrixEvent: Pick, - - /** - * The type checked membership data {data: (content of the matrix event), kind: (type hint)} - * - */ - private readonly membershipData: MembershipData, - - /** - * - * Anonymized identity to use with the RTC backend. - * - * The rtcBackendIdentity is a hashed version of all the identity parts: - * `sha256(${this.userId}|${this.deviceId}|${this.memberId})` - * - * It is used to anonymize the identity of the user in the RTC backend. - */ - public readonly rtcBackendIdentity: string, - /** - * The constructor will automatically create a properly tagged child logger instance. - */ - logger?: Logger, - ) { - const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()]; - if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); - if (sender === undefined) throw new Error("parentEvent is missing sender field"); - - this.matrixEventData = { eventId, sender, ts }; - - this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`); - } + private readonly matrixEventData: { eventId: string; sender: string }; /** - * sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc) - * `${this.userId}:${this.deviceId}` for state events (kind = session) + * Use `parseFromEvent`. + * Constructor should only be used by tests. + * @private + * @param matrixEvent + * @param membershipData + * @param rtcBackendIdentity */ - public static async computeRtcBackendIdentity( - matrixEvent: Pick, - membershipData: MembershipData, - ): Promise { - const { kind, data } = membershipData; - switch (kind) { - case "rtc": { - return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id); - } - case "session": - return `${matrixEvent.getSender()}:${data.device_id}`; - } - } - - public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise { - return encodeUnpaddedBase64(await sha256(`${userId}|${deviceId}|${memberId}`)); - } - - public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData { - const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()]; + public constructor( + /** The Matrix event that this membership is based on */ + private readonly matrixEvent: LimitedEvent, + private readonly membershipData: MembershipData, + public readonly rtcBackendIdentity: string, + ) { + const eventId = matrixEvent.getId(); + const sender = matrixEvent.getSender(); 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(content, sessionErrors)) { - return { kind: "session", data: content }; - } else if (checkRtcMembershipData(content, rtcErrors, sender)) { - return { kind: "rtc", data: content }; - } 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(content).replaceAll('"', "'"); - throw Error(`unknown CallMembership data.\n` + details + json); - } + this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`); + this.matrixEventData = { eventId, sender }; } /** @deprecated use userId instead */ 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; } @@ -374,11 +165,11 @@ export class CallMembership { const { kind, data } = this.membershipData; if (data.application === "m.call") { switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.slot_id; - case "session": + case MembershipKind.Session: default: { - const [application, id] = [this.application, data.call_id]; + const [application, id] = [data.application, data.call_id]; // INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture) // The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call. @@ -400,7 +191,7 @@ export class CallMembership { } else { compatibilityAdaptedId = id; } - return slotDescriptionToId({ + return computeSlotId({ application, id: compatibilityAdaptedId, }); @@ -412,89 +203,82 @@ export class CallMembership { // This is what the function should look like for any other application that did not // go through a `""`=> `"ROOM"` rename switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.slot_id; - case "session": - default: { - const [application, id] = [this.application, data.call_id]; - return slotDescriptionToId({ application, id }); - } + case MembershipKind.Session: + default: + return computeSlotId({ application: data.application, id: data.call_id }); } } 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; } } public get callIntent(): RTCCallIntent | undefined { - const { kind, data } = this.membershipData; - switch (kind) { - case "rtc": { - const intent = data.application["m.call.intent"]; - if (typeof intent === "string") { - return intent; - } - this.logger?.warn("RTC membership has invalid m.call.intent"); - return undefined; - } - case "session": - default: - return data["m.call.intent"]; + const intent = this.applicationData["m.call.intent"]; + if (typeof intent === "string") { + return intent; } + this.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 { + const { kind, data } = this.membershipData; + if (kind === MembershipKind.RTC) { + const id = data.slot_id.slice(`${data.application.type}#`.length); + return { application: data.application.type, id }; + } return slotIdToDescription(this.slotId); } + /** + * The application `type`. + * @deprecated Use @see applicationData + */ public get application(): string { - const { kind, data } = this.membershipData; - switch (kind) { - case "rtc": - return data.application.type; - case "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) { - case "rtc": + case MembershipKind.RTC: return data.application; - case "session": + case MembershipKind.Session: default: + // SessionData does not have application data as such. We return specific + // properties in use by other getters in this class, for compatibility. 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; } } - /** - * @deprecated renamed to `memberId` - */ - public get membershipID(): string { - return this.memberId; - } /** * This computes the membership ID for the membership. @@ -519,25 +303,33 @@ export class CallMembership { case "rtc": return data.member.id; case "session": - default: return ( // best case we have a client already publishing the right custom membershipId data.membershipID ?? // alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0) `${this.matrixEventData.sender}:${data.device_id}` ); + default: + throw Error("Not possible to get memberID without knowing the membership event kind"); } } + /** + * @deprecated renamed to `memberId` + */ + public get membershipID(): string { + return this.memberId; + } + 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.matrixEventData.ts; - case "session": + return this.matrixEvent.getTs(); + case MembershipKind.Session: default: - return data.created_ts ?? this.matrixEventData.ts; + return data.created_ts ?? this.matrixEvent.getTs(); } } @@ -548,9 +340,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); @@ -559,19 +351,20 @@ 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 "rtc": - return undefined; - case "session": - default: + if (kind === MembershipKind.Session) { + const absExpiry = this.getAbsoluteExpiry(); + if (absExpiry) { // 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(); + return absExpiry - Date.now(); + } } + return undefined; } /** @@ -580,9 +373,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; } @@ -608,30 +401,26 @@ 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]; case "oldest_membership": if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); break; + case "multi_sfu": + return data.foci_preferred[0]; + default: + // `focus_selection` not understood. + return undefined; } + break; + default: + return undefined; } - 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 === "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). @@ -639,14 +428,11 @@ 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; } } - public get kind(): MembershipData["kind"] { - return this.membershipData.kind; - } } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 6df0e8d1e..7a9b433ce 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2024 The Matrix.org Foundation C.I.C. +Copyright 2023 - 2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -34,6 +34,7 @@ import type { IRTCNotificationContent, RTCCallIntent, Transport, + SlotDescription, } from "./types.ts"; import { MembershipManagerEvent, @@ -46,6 +47,7 @@ import { TypedReEmitter } from "../ReEmitter.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; +import { computeSlotId } from "./utils.ts"; /** * Events emitted by MatrixRTCSession @@ -96,21 +98,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; -} -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. @@ -275,6 +262,16 @@ export class MatrixRTCSession extends TypedEventEmitter< public memberships: CallMembership[] = []; + /** + * Resolves when the session has calculated the initial membership of the session. + */ + public readonly initialMembershipCalculated: Promise; + /** + * Does membership need to be recalculated? This is set to false upon + * recalculation. + */ + private membershipNeedsRecalculation = false; + /** * The statistics for this session. */ @@ -315,7 +312,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * The slotId is the property that, per definition, groups memberships into one call. */ public get slotId(): string | undefined { - return slotDescriptionToId(this.slotDescription); + return computeSlotId(this.slotDescription); } /** @@ -326,18 +323,20 @@ export class MatrixRTCSession extends TypedEventEmitter< */ public static async sessionMembershipsForSlot( room: Pick, - slotId: string, + slotDescription: SlotDescription, // default both true this implied we combine sticky and state events for the final call state // (prefer sticky events in case of a duplicate) options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS, ): Promise { - const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); + const logger = rootLogger.getChild( + `[MatrixRTCSession ${room.roomId} ${slotDescription.application}#${slotDescription.id}]`, + ); const callMemberEvents = collectMembersEvents(room, options, logger); const callMemberships = await computeBackendIdentityAndVerifyMemberEvents( room, callMemberEvents, - slotId, + slotDescription, logger, ); @@ -419,14 +418,14 @@ export class MatrixRTCSession extends TypedEventEmitter< private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts, ) { super(); - this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); + this.logger = rootLogger.getChild( + `[MatrixRTCSession ${roomSubset.roomId} ${slotDescription.application}#${slotDescription.id}]`, + ); this.roomSubset.on(RoomStateEvent.Members, this.onRoomMemberUpdate); this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); - // We can ignore this promise because `recalculateSessionMembers` will emit - // `MatrixRTCSessionEvent.MembershipsChanged` once it has completed. - this.ensureRecalculateSessionMembers(); + this.initialMembershipCalculated = this.ensureRecalculateSessionMembers(); this.setExpiryTimer(); } /* @@ -620,13 +619,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]; } @@ -695,7 +687,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } if (soonestExpiry != undefined) { - this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry); + this.expiryTimeout = setTimeout(() => void this.ensureRecalculateSessionMembers(), soonestExpiry); } } @@ -747,7 +739,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * Call this when the Matrix room members have changed. */ private readonly onRoomMemberUpdate = (): void => { - this.ensureRecalculateSessionMembers(); + void this.ensureRecalculateSessionMembers(); }; /** @@ -763,7 +755,7 @@ export class MatrixRTCSession extends TypedEventEmitter< (e) => e.getType() === EventType.RTCMembership, ) ) { - this.ensureRecalculateSessionMembers(); + void this.ensureRecalculateSessionMembers(); } }; @@ -777,22 +769,24 @@ export class MatrixRTCSession extends TypedEventEmitter< }; // helper variables to make sure we do not have parallel running recalculations. + private recalculateSessionMembersPromise: Promise = Promise.resolve(); - private recalculateSessionMembersDirty = false; - private recalculateSessionMembersPromise: Promise | undefined = undefined; - - private ensureRecalculateSessionMembers(): void { - if (this.recalculateSessionMembersPromise === undefined) { - this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => { - this.recalculateSessionMembersPromise = undefined; - if (this.recalculateSessionMembersDirty) { - this.ensureRecalculateSessionMembers(); - this.recalculateSessionMembersDirty = false; - } - }); - } else { - this.recalculateSessionMembersDirty = true; + /** + * Ensures that membership is recalculated when the state of the session may have changed. + * Also ensures that only one recalculation is made at a time. + * @returns A promise resolving when the state has been recalculated. + */ + private ensureRecalculateSessionMembers(): Promise { + if (this.membershipNeedsRecalculation) { + // We have already requested recalcuation, don't attempt a new one. + return this.recalculateSessionMembersPromise; } + this.membershipNeedsRecalculation = true; + // Chain the recalculation. + this.recalculateSessionMembersPromise = this.recalculateSessionMembersPromise + .finally() + .then(() => this.recalculateSessionMembers()); + return this.recalculateSessionMembersPromise; } /** @@ -803,11 +797,13 @@ export class MatrixRTCSession extends TypedEventEmitter< * This function should be called when the room members or call memberships might have changed. */ private readonly recalculateSessionMembers = async (): Promise => { + // Clear the flag. + this.membershipNeedsRecalculation = false; const oldMemberships = this.memberships; this.memberships = await MatrixRTCSession.sessionMembershipsForSlot( this.room, - slotDescriptionToId(this.slotDescription), + this.slotDescription, this.calculateMembershipsOpts, ); @@ -857,7 +853,7 @@ export class MatrixRTCSession extends TypedEventEmitter< async function computeBackendIdentityAndVerifyMemberEvents( room: Pick, callMemberEvents: MatrixEvent[], - slotId: string, + slotDescription: SlotDescription, logger: Logger, ): Promise { const callMemberships: CallMembership[] = []; @@ -866,22 +862,14 @@ async function computeBackendIdentityAndVerifyMemberEvents( const content = memberEvent.getContent(); // Quick filter to avoid unneeded processing of invalid events or left events. - // A more thorough validation will be done later with CallMembership.membershipDataFromMatrixEvent. if (!quickFilterNonRelevantContents(content, logger)) { continue; } try { - const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent); + const membership = await CallMembership.parseFromEvent(memberEvent); - const membership = new CallMembership( - memberEvent, - membershipData, - await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData), - logger, - ); - - if (isValidMembership(membership, room, slotId, logger)) { + if (isValidMembership(membership, room, slotDescription, logger)) { callMemberships.push(membership); } } catch (e) { @@ -914,12 +902,12 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool function isValidMembership( membership: CallMembership, room: Pick, - slotId: string, + slotDescription: SlotDescription, logger: Logger, ): boolean { - if (membership.slotId !== slotId) { + if (membership.slotDescription.id !== slotDescription.id) { logger.info( - `Ignoring membership of user ${membership.userId} for a different slot: user: ${JSON.stringify(membership.slotDescription)}, slotId: ${slotId})`, + `Ignoring membership of user ${membership.userId} for a different slot. Theirs: ${JSON.stringify(membership.slotDescription)}, Expected: ${JSON.stringify(slotDescription)}`, ); return false; } diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 8150ac6f2..3a79ad5c2 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -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, type SlotDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; +import { type SlotDescription } from "./types.ts"; +import { computeSlotId } from "./utils.ts"; export enum MatrixRTCSessionManagerEvents { // A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously @@ -59,7 +61,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter = this.rtcTransport === undefined ? { focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, @@ -1099,7 +1092,11 @@ export class StickyEventMembershipManager extends MembershipManager { return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); } - protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + /** + * + * @returns Only returns `RtcMembershipData` + */ + protected makeMyMembership(): RtcMembershipData { const ownMembership = this.ownMembership; const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined; @@ -1111,7 +1108,7 @@ export class StickyEventMembershipManager extends MembershipManager { type: this.slotDescription.application, ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), }, - slot_id: slotDescriptionToId(this.slotDescription), + slot_id: computeSlotId(this.slotDescription), // Make sure we do not add the alias to the transport. // It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu. rtc_transports: livekitTransport diff --git a/src/matrixrtc/RTCEncryptionManager.ts b/src/matrixrtc/RTCEncryptionManager.ts index 3233ca82f..6bf5040a6 100644 --- a/src/matrixrtc/RTCEncryptionManager.ts +++ b/src/matrixrtc/RTCEncryptionManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ import { type IEncryptionManager, } from "./EncryptionManager.ts"; import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts"; -import { CallMembership } from "./CallMembership.ts"; +import type { CallMembership } from "./CallMembership.ts"; import { decodeBase64, encodeBase64 } from "../base64.ts"; import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts"; import { type Logger } from "../logger.ts"; @@ -33,6 +33,7 @@ import { type Statistics, } from "./types.ts"; import { OutdatedKeyFilter } from "./utils.ts"; +import { computeRtcIdentityRaw } from "./membershipData/rtc.ts"; /** * RTCEncryptionManager is used to manage the encryption keys for a call. @@ -139,7 +140,7 @@ export class RTCEncryptionManager implements IEncryptionManager { rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise, ) { this.logger = parentLogger?.getChild(`[EncryptionManager]`); - this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw; + this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw; } private async getOwnRtcBackendIdentity(): Promise { diff --git a/src/matrixrtc/RoomKeyTransport.ts b/src/matrixrtc/RoomKeyTransport.ts index 832030d28..54c4979e1 100644 --- a/src/matrixrtc/RoomKeyTransport.ts +++ b/src/matrixrtc/RoomKeyTransport.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index a9726421d..bb2a816a7 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -19,5 +19,6 @@ export * from "./LivekitTransport.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; export type * from "./types.ts"; +export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts"; export { Status, parseCallNotificationContent, isMyMembership } from "./types.ts"; export { MembershipManagerEvent } from "./IMembershipManager.ts"; diff --git a/src/matrixrtc/membershipData/common.ts b/src/matrixrtc/membershipData/common.ts new file mode 100644 index 000000000..889fdffad --- /dev/null +++ b/src/matrixrtc/membershipData/common.ts @@ -0,0 +1,27 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Thrown when an event is not valid for use with MatrixRTC. + */ +export class MatrixRTCMembershipParseError extends AggregateError { + public constructor( + public readonly type: string, + errors: string[], + ) { + super(errors, `Does not match ${type}:\n${errors.join("\n")}`); + } +} diff --git a/src/matrixrtc/membershipData/index.ts b/src/matrixrtc/membershipData/index.ts new file mode 100644 index 000000000..48dde9817 --- /dev/null +++ b/src/matrixrtc/membershipData/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts"; +export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts"; +export { MatrixRTCMembershipParseError } from "./common.ts"; diff --git a/src/matrixrtc/membershipData/rtc.ts b/src/matrixrtc/membershipData/rtc.ts new file mode 100644 index 000000000..91c29c14c --- /dev/null +++ b/src/matrixrtc/membershipData/rtc.ts @@ -0,0 +1,156 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MXID_PATTERN } from "../../models/room-member.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"; +import { sha256 } from "../../digest.ts"; +import { encodeUnpaddedBase64Url } from "../../base64.ts"; +import { slotIdToDescription } from "../utils.ts"; + +/** + * Represents the current form of MSC4143, which uses sticky events to store membership. + */ +export interface RtcMembershipData { + "slot_id": string; + "member": { + user_id: string; + device_id: string; + id: string; + }; + "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; +} + +/** + * 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 = " - "; + const expectedSlotPrefix = `${data?.application?.type}#`; + + // required fields + if (typeof data.slot_id !== "string") { + errors.push(prefix + "slot_id must be string"); + } else if (!data.slot_id.startsWith(expectedSlotPrefix)) { + errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`); + } else { + try { + slotIdToDescription(data.slot_id); + } catch (ex) { + errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`); + } + } + + 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 !== sender) { + 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("RtcMembership", errors); + } + + return true; +}; + +export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise { + const hashInput = `${userId}|${deviceId}|${memberId}`; + const hashBuffer = await sha256(hashInput); + const hashedString = encodeUnpaddedBase64Url(hashBuffer); + return hashedString; +} diff --git a/src/matrixrtc/membershipData/session.ts b/src/matrixrtc/membershipData/session.ts new file mode 100644 index 000000000..53d410131 --- /dev/null +++ b/src/matrixrtc/membershipData/session.ts @@ -0,0 +1,146 @@ +/* +Copyright 2026 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { type IContent } from "../../matrix.ts"; +import { type RTCCallIntent, type Transport } from "../types.ts"; +import { MatrixRTCMembershipParseError } from "./common.ts"; + +/** + * (MatrixRTC) session membership data. + * This represents the *OLD* form of MSC4143, which uses state events to store membership. + * 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. + * NOTE: This is still included for legacy reasons, but not consumed by the SDK. + */ + "focus_active": { + type: "livekit" | string; + focus_selection: "oldest_membership" | "multi_sfu" | string; + }; + + /** + * 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 id used on the media backend. + * (With livekit this is the participant identity on the LK SFU) + * This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`. + * + * It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}` + */ + "membershipID"?: string; +}; + +/** + * 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[] = []; + 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 (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 && + !( + 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("SessionMembership", errors); + } + + return true; +}; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 468361472..84cee4ca3 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Matrix.org Foundation C.I.C. +Copyright 2023-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,3 +199,29 @@ export interface Transport { type: string; [key: string]: unknown; } + +/** + * Event content for a `m.rtc.slot` state event. + */ +export interface RtcSlotEventContent { + application: { + type: T; + // 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 { + /** + * The application type. e.g. "m.call". + */ + application: string; + /** + * The application-specific slot ID. e.g. "ROOM". + */ + id: string; +} diff --git a/src/matrixrtc/utils.ts b/src/matrixrtc/utils.ts index 7aa446c05..de023d77a 100644 --- a/src/matrixrtc/utils.ts +++ b/src/matrixrtc/utils.ts @@ -1,5 +1,5 @@ /* -Copyright 2025 The Matrix.org Foundation C.I.C. +Copyright 2025-2026 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ limitations under the License. */ import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts"; -import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts"; +import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts"; /** * Detects when a key for a given index is outdated. @@ -47,3 +47,25 @@ export class OutdatedKeyFilter { return false; } } + +/** + * Converts a slot ID into it's component application and ID portions. + * @param slotId e.g. `m.call#call_id` + * @throws If the format of `slotId` is invalid. + */ +export function slotIdToDescription(slotId: string): SlotDescription { + const [application, id, ...unexpectedAdditionalValues] = slotId.split("#"); + if (unexpectedAdditionalValues.length) { + throw Error( + "MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.", + ); + } + return { application, id }; +} + +/** + * Converts a SlotDescription into it's slot ID format. + */ +export function computeSlotId(slotDescription: SlotDescription): string { + return `${slotDescription.application}#${slotDescription.id}`; +}