diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 95c7140b13..2869b9078d 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type MatrixEvent } from "../../../src"; -import { - CallMembership, - type SessionMembershipData, - DEFAULT_EXPIRE_DURATION, - type RtcMembershipData, -} from "../../../src/matrixrtc/CallMembership"; -import { membershipTemplate } from "./mocks"; - -function makeMockEvent(originTs = 0): MatrixEvent { +import { type SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; +import { EventType, type MatrixEvent } from "../../../src"; +import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership"; +import { sessionMembershipTemplate } from "./mocks"; +import { type RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; + +function makeMockEvent( + eventType: EventType.RTCMembership | EventType.GroupCallMemberPrefix, + originTs = 0, +): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), getId: jest.fn().mockReturnValue("$eventid"), + getType: jest.fn().mockReturnValue(eventType), } as unknown as MatrixEvent; } @@ -53,49 +54,61 @@ describe("CallMembership", () => { it("rejects membership with no device_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { device_id: undefined }), + ); }).toThrow(); }); it("rejects membership with no call_id", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { call_id: undefined }), + ); }).toThrow(); }); it("allow membership with no scope", () => { expect(() => { - new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined })); + new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + Object.assign({}, membershipTemplate, { scope: undefined }), + ); }).not.toThrow(); }); it("uses event timestamp if no created_ts", () => { - const membership = new CallMembership(makeMockEvent(12345), membershipTemplate); + const membership = new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix, 12345), + membershipTemplate, + ); expect(membership.createdTs()).toEqual(12345); }); it("uses created_ts if present", () => { const membership = new CallMembership( - makeMockEvent(12345), + makeMockEvent(EventType.GroupCallMemberPrefix, 12345), Object.assign({}, membershipTemplate, { created_ts: 67890 }), ); expect(membership.createdTs()).toEqual(67890); }); it("considers memberships unexpired if local age low enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION - 1)); expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(false); }); it("considers memberships expired if local age large enough", () => { - const fakeEvent = makeMockEvent(1000); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); fakeEvent.getTs = jest.fn().mockReturnValue(Date.now() - (DEFAULT_EXPIRE_DURATION + 1)); expect(new CallMembership(fakeEvent, membershipTemplate).isExpired()).toEqual(true); }); it("returns preferred foci", () => { - const fakeEvent = makeMockEvent(); + const fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix); const mockFocus = { type: "this_is_a_mock_focus" }; const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); expect(membership.transports).toEqual([mockFocus]); @@ -103,9 +116,12 @@ describe("CallMembership", () => { describe("getTransport", () => { const mockFocus = { type: "this_is_a_mock_focus" }; - const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + const oldestMembership = new CallMembership( + makeMockEvent(EventType.GroupCallMemberPrefix), + membershipTemplate, + ); it("gets the correct active transport with oldest_membership", () => { - const membership = new CallMembership(makeMockEvent(), { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "oldest_membership" }, @@ -118,21 +134,21 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); }); - it("gets the correct active transport with multi_sfu", () => { - const membership = new CallMembership(makeMockEvent(), { + it("no longer supports multi_sfu", () => { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "multi_sfu" }, }); // if we are the oldest member we use our focus. - expect(membership.getTransport(membership)).toStrictEqual(mockFocus); + expect(membership.getTransport(membership)).toStrictEqual(undefined); // If there is an older member we still use our own focus in multi sfu. - expect(membership.getTransport(oldestMembership)).toBe(mockFocus); + expect(membership.getTransport(oldestMembership)).toBe(undefined); }); it("does not provide focus if the selection method is unknown", () => { - const membership = new CallMembership(makeMockEvent(), { + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), { ...membershipTemplate, foci_preferred: [mockFocus], focus_active: { type: "livekit", focus_selection: "unknown" }, @@ -143,7 +159,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.GroupCallMemberPrefix), membershipTemplate); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -184,7 +200,7 @@ describe("CallMembership", () => { const membershipTemplate: RtcMembershipData = { slot_id: "m.call#", application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, - member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + member: { claimed_user_id: "@alice:example.org", claimed_device_id: "AAAAAAA", id: "xyzHASHxyz" }, rtc_transports: [{ type: "livekit" }], versions: [], msc4354_sticky_key: "abc123", @@ -192,29 +208,41 @@ describe("CallMembership", () => { it("rejects membership with no slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: undefined, + }); }).toThrow(); }); it("rejects membership with invalid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: "invalid_slot_id", + }); }).toThrow(); }); it("accepts membership with valid slot_id", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + slot_id: "m.call#", + }); }).not.toThrow(); }); it("rejects membership with no application", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + application: undefined, + }); }).toThrow(); }); it("rejects membership with incorrect application", () => { expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, application: { wrong_type_key: "unknown" }, }); @@ -223,34 +251,40 @@ describe("CallMembership", () => { it("rejects membership with no member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + member: undefined, + }); }).toThrow(); }); it("rejects membership with incorrect member", () => { expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + member: { i: "test" }, + }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id_wrong: "test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id: "@@test" }, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, }); @@ -258,41 +292,44 @@ describe("CallMembership", () => { }); it("rejects membership with incorrect sticky_key", () => { expect(() => { - new CallMembership(makeMockEvent(), membershipTemplate); + new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, sticky_key: 1, msc4354_sticky_key: undefined, }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, sticky_key: "1", msc4354_sticky_key: undefined, }); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined }); + new CallMembership(makeMockEvent(EventType.RTCMembership), { + ...membershipTemplate, + msc4354_sticky_key: undefined, + }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: 1, sticky_key: "valid", }); }).toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: "valid", sticky_key: "valid", }); }).not.toThrow(); expect(() => { - new CallMembership(makeMockEvent(), { + new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, msc4354_sticky_key: "valid_but_different", sticky_key: "valid", @@ -310,11 +347,11 @@ describe("CallMembership", () => { describe("getTransport", () => { it("gets the correct active transport with oldest_membership", () => { - const oldestMembership = new CallMembership(makeMockEvent(), { + const oldestMembership = new CallMembership(makeMockEvent(EventType.RTCMembership), { ...membershipTemplate, rtc_transports: [{ type: "oldest_transport" }], }); - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); // if we are the oldest member we use our focus. expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); @@ -324,7 +361,7 @@ describe("CallMembership", () => { }); }); describe("correct values from computed fields", () => { - const membership = new CallMembership(makeMockEvent(), membershipTemplate); + const membership = new CallMembership(makeMockEvent(EventType.RTCMembership), membershipTemplate); it("returns correct sender", () => { expect(membership.sender).toBe("@alice:example.org"); }); @@ -371,8 +408,8 @@ describe("CallMembership", () => { beforeEach(() => { // server origin timestamp for this event is 1000 - fakeEvent = makeMockEvent(1000); - membership = new CallMembership(fakeEvent!, membershipTemplate); + fakeEvent = makeMockEvent(EventType.GroupCallMemberPrefix, 1000); + membership = new CallMembership(fakeEvent!, sessionMembershipTemplate); jest.useFakeTimers(); }); diff --git a/spec/unit/matrixrtc/LivekitTransport.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts index 04f04a1357..6a444f98f8 100644 --- a/spec/unit/matrixrtc/LivekitTransport.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -14,11 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - isLivekitTransport, - isLivekitFocusSelection, - isLivekitTransportConfig, -} from "../../../src/matrixrtc/LivekitTransport"; +import { isLivekitTransport, isLivekitTransportConfig } from "../../../src/matrixrtc/LivekitTransport"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { @@ -40,16 +36,6 @@ describe("LivekitFocus", () => { isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), ).toBeFalsy(); }); - it("isLivekitFocusActive", () => { - expect( - isLivekitFocusSelection({ - type: "livekit", - focus_selection: "oldest_membership", - }), - ).toBeTruthy(); - expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); - }); it("isLivekitFocusConfig", () => { expect( isLivekitTransportConfig({ diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 8eb11ecdd1..65d4b67b56 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { + Direction, encodeBase64, type EventTimeline, EventType, @@ -25,11 +26,12 @@ import { } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; -import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; +import { type SlotDescription, Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { makeMockEvent, makeMockRoom, - membershipTemplate, + sessionMembershipTemplate, + rtcMembershipTemplate, makeKey, type MembershipData, mockRoomState, @@ -43,7 +45,7 @@ const mockFocus = { type: "mock" }; const textEncoder = new TextEncoder(); -const callSession = { id: "", application: "m.call" }; +const callSession: SlotDescription = { id: "", application: "m.call" }; describe("MatrixRTCSession", () => { let client: MatrixClient; @@ -92,9 +94,12 @@ describe("MatrixRTCSession", () => { testCreateSticky: true, }, ])( - "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", + "sessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue(undefined), @@ -113,6 +118,9 @@ describe("MatrixRTCSession", () => { }); it("creates a room-scoped session from room state", () => { + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( @@ -123,17 +131,24 @@ describe("MatrixRTCSession", () => { ); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].slotDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].scope).toEqual(testConfig.testCreateSticky ? undefined : "m.room"); + expect(sess?.memberships[0].applicationData.type).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); expect(sess?.slotDescription.id).toEqual(""); }); it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", - }); + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "not-m.call#", + application: { + ...rtcMembershipTemplate.application, + type: "not-m.call", + }, + } + : { ...sessionMembershipTemplate, application: "not-m.call#" }; const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -145,10 +160,16 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, membershipTemplate, { - call_id: "not-empty", - scope: "m.room", - }); + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar", + }, + } + : { ...sessionMembershipTemplate, application: "m.call", call_id: "foobar" }; const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); const sess = MatrixRTCSession.sessionForSlot( client, @@ -161,12 +182,31 @@ describe("MatrixRTCSession", () => { it("ignores expired memberships events", () => { jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + const expiredMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#foobar", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "foobar", + }, + __test_sticky_expiry: 3000, + } + : { ...sessionMembershipTemplate, device_id: "EXPIRED", expires: 3000 }; + jest.setSystemTime(0); + const mockRoom = makeMockRoom( + [ + testConfig.testCreateSticky ? rtcMembershipTemplate : sessionMembershipTemplate, + expiredMembership, + ], + testConfig.testCreateSticky, + ); - jest.advanceTimersByTime(2000); + jest.advanceTimersByTime(3000); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -179,6 +219,9 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships events of members not in the room", () => { + const membershipTemplate = testConfig.testCreateSticky + ? rtcMembershipTemplate + : sessionMembershipTemplate; const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( @@ -192,7 +235,10 @@ describe("MatrixRTCSession", () => { it("ignores memberships events with no sender", () => { // Force the sender to be undefined. - const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); + const membershipTemplate = testConfig.testCreateSticky + ? { ...rtcMembershipTemplate, member: { claimed_user_id: "" } } + : { ...sessionMembershipTemplate, user_id: "" }; + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForSlot( client, @@ -206,9 +252,21 @@ describe("MatrixRTCSession", () => { it("honours created_ts", () => { jest.useFakeTimers(); jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; + const expiredMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#", + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: "EXPIRED", + }, + application: { + ...rtcMembershipTemplate.application, + "m.call.id": "", + }, + __test_sticky_expiry: 1000, + } + : { ...sessionMembershipTemplate, device_id: "EXPIRED", created_ts: 500, expires: 1000 }; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); sess = MatrixRTCSession.sessionForSlot( client, @@ -216,6 +274,7 @@ describe("MatrixRTCSession", () => { callSession, testConfig.createWithDefaults ? undefined : testConfig, ); + expect(sess.memberships[0]).toBeDefined(); expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); jest.useRealTimers(); }); @@ -238,26 +297,12 @@ describe("MatrixRTCSession", () => { getSender: jest.fn().mockReturnValue("@mock:user.example"), getTs: jest.fn().mockReturnValue(1000), getLocalAge: jest.fn().mockReturnValue(0), - }; + } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline.mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - } as unknown as EventTimeline); + mockRoom + .getLiveTimeline() + .getState(Direction.Forward) + ?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -274,26 +319,12 @@ describe("MatrixRTCSession", () => { getSender: jest.fn().mockReturnValue("@mock:user.example"), getTs: jest.fn().mockReturnValue(1000), getLocalAge: jest.fn().mockReturnValue(0), - }; + } as unknown as MatrixEvent; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline.mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - } as unknown as EventTimeline); + mockRoom + .getLiveTimeline() + .getState(Direction.Forward) + ?.events.set(EventType.GroupCallMemberPrefix, new Map([[EventType.GroupCallMemberPrefix, event]])); sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -304,9 +335,16 @@ describe("MatrixRTCSession", () => { }); it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); + const membershipTemplate = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + member: { + ...rtcMembershipTemplate.member, + claimed_device_id: undefined, + }, + } + : { ...sessionMembershipTemplate, device_id: undefined }; + const mockRoom = makeMockRoom([membershipTemplate]); const sess = MatrixRTCSession.sessionForSlot( client, mockRoom, @@ -316,9 +354,13 @@ describe("MatrixRTCSession", () => { expect(sess.memberships).toHaveLength(0); }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; + it("ignores memberships with a different slot description", () => { + const testMembership = testConfig.testCreateSticky + ? { + ...rtcMembershipTemplate, + slot_id: "m.call#fibble", + } + : { ...sessionMembershipTemplate, call_id: undefined }; const mockRoom = makeMockRoom([testMembership]); sess = MatrixRTCSession.sessionForSlot( client, @@ -331,15 +373,15 @@ describe("MatrixRTCSession", () => { }, ); - describe("roomSessionForRoom combined state", () => { + describe("sessionForSlot combined state", () => { it("perfers sticky events when both membership and sticky events appear for the same user", () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...membershipTemplate, - msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + ...sessionMembershipTemplate, + msc4354_sticky_key: `_${sessionMembershipTemplate.user_id}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, ); @@ -361,14 +403,18 @@ describe("MatrixRTCSession", () => { }); it("combines sticky and membership events when both exist", () => { // Create a room with identical member state and sticky state for the same user. - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate], false, callSession, true); const stickyUserId = "@stickyev:user.example"; mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { - ...membershipTemplate, + ...rtcMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + member: { + ...rtcMembershipTemplate.member, + claimed_user_id: stickyUserId, + }, + sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, @@ -386,18 +432,18 @@ describe("MatrixRTCSession", () => { expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(stickyUserId); expect(memberships[0].slotDescription.id).toEqual(""); - expect(memberships[0].scope).toEqual("m.room"); - expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].scope).toEqual(undefined); + expect(memberships[0].applicationData.type).toEqual("m.call"); expect(memberships[0].deviceId).toEqual("AAAAAAA"); expect(memberships[0].isExpired()).toEqual(false); // Then state - expect(memberships[1].sender).toEqual(membershipTemplate.user_id); + expect(memberships[1].sender).toEqual(sessionMembershipTemplate.user_id); expect(sess?.slotDescription.id).toEqual(""); }); it("handles an incoming sticky event to an existing session", () => { - const mockRoom = makeMockRoom([membershipTemplate]); + const mockRoom = makeMockRoom([sessionMembershipTemplate], false, callSession, true); const stickyUserId = "@stickyev:user.example"; sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { @@ -407,9 +453,13 @@ describe("MatrixRTCSession", () => { expect(sess.memberships.length).toEqual(1); const stickyEv = mockRTCEvent( { - ...membershipTemplate, + ...rtcMembershipTemplate, user_id: stickyUserId, - msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + member: { + ...rtcMembershipTemplate.member, + claimed_user_id: stickyUserId, + }, + sticky_key: `_${stickyUserId}_${sessionMembershipTemplate.device_id}`, }, mockRoom.roomId, 15000, @@ -428,12 +478,12 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }), - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 3000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess.getOldestMembership()!.deviceId).toEqual("old"); jest.useRealTimers(); }); @@ -450,11 +500,11 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(4000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { "m.call.intent": intentA }), - Object.assign({}, membershipTemplate, { "m.call.intent": intentB }), + Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentA }), + Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentB }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess.getConsensusCallIntent()).toEqual(result); jest.useRealTimers(); }); @@ -470,16 +520,16 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); jest.setSystemTime(3000); const mockRoom = makeMockRoom([ - Object.assign({}, membershipTemplate, { + Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 500, foci_preferred: [firstPreferredFocus], }), - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }), + Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }), ]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -489,16 +539,16 @@ 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.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], { type: "livekit", @@ -525,7 +575,7 @@ describe("MatrixRTCSession", () => { client._unstable_updateDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); }); afterEach(async () => { @@ -567,7 +617,7 @@ describe("MatrixRTCSession", () => { sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [{ ...sessionMembershipTemplate, user_id: client.getUserId()! }]); sess!.onRTCSessionMemberUpdate(); const ownMembershipId = sess?.memberships[0].eventId; @@ -631,7 +681,7 @@ describe("MatrixRTCSession", () => { mockRoomState(mockRoom, [ { - ...membershipTemplate, + ...sessionMembershipTemplate, "user_id": client.getUserId()!, // This is what triggers the intent type on the notification event. "m.call.intent": "audio", @@ -688,13 +738,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]); sess!.onRTCSessionMemberUpdate(); // Simulate a join, including the update to the room state sess!.joinRoomSession([mockFocus], mockFocus, { notificationType: "ring" }); await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]); - mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [ + sessionMembershipTemplate, + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + ]); sess!.onRTCSessionMemberUpdate(); expect(client.sendEvent).not.toHaveBeenCalled(); @@ -706,9 +759,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]); sess!.onRTCSessionMemberUpdate(); - mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]); + mockRoomState(mockRoom, [ + sessionMembershipTemplate, + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + ]); sess!.onRTCSessionMemberUpdate(); // We assume that the responsibility to send a notification, if any, lies with the other @@ -719,8 +775,8 @@ describe("MatrixRTCSession", () => { describe("onMembershipsChanged", () => { it("does not emit if no membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -730,8 +786,8 @@ describe("MatrixRTCSession", () => { }); it("emits on membership changes", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); @@ -750,7 +806,7 @@ describe("MatrixRTCSession", () => { // const membership = Object.assign({}, membershipTemplate); // const mockRoom = makeMockRoom([membership]); - // sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + // sess = MatrixRTCSession.roomsessionForSlot(client, mockRoom); // const membershipObject = sess.memberships[0]; // const onMembershipsChanged = jest.fn(); @@ -786,7 +842,7 @@ describe("MatrixRTCSession", () => { client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); }); afterEach(async () => { @@ -901,11 +957,11 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { // session with two members - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // joining will trigger an initial key send const keysSentPromise1 = new Promise((resolve) => { @@ -920,14 +976,14 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1); // member2 leaves triggering key rotation - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess.onRTCSessionMemberUpdate(); // member2 re-joins which should trigger an immediate re-send const keysSentPromise2 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); // but, that immediate resend is throttled so we need to wait a bit jest.advanceTimersByTime(1000); @@ -953,8 +1009,8 @@ describe("MatrixRTCSession", () => { it("re-sends key if a new member joins", async () => { jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); @@ -974,11 +1030,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); await keysSentPromise2; @@ -997,15 +1053,15 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation(resolve); }); - const member1 = membershipTemplate; - const member2 = Object.assign({}, membershipTemplate, { + const member1 = sessionMembershipTemplate; + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); const mockRoom = makeMockRoom([member1, member2]); mockRoomState(mockRoom, [member1, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -1042,15 +1098,15 @@ 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.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); await keysSentPromise1; @@ -1110,11 +1166,11 @@ describe("MatrixRTCSession", () => { jest.useFakeTimers(); try { const KEY_DELAY = 3000; - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - const mockRoom = makeMockRoom([membershipTemplate, member2]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const onMyEncryptionKeyChanged = jest.fn(); sess.on( @@ -1143,7 +1199,7 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - mockRoomState(mockRoom, [membershipTemplate]); + mockRoomState(mockRoom, [sessionMembershipTemplate]); sess.onRTCSessionMemberUpdate(); jest.advanceTimersByTime(KEY_DELAY); @@ -1190,7 +1246,7 @@ describe("MatrixRTCSession", () => { const membersToTest = 258; const members: MembershipData[] = []; for (let i = 0; i < membersToTest; i++) { - members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` })); + members.push(Object.assign({}, sessionMembershipTemplate, { device_id: `DEVICE${i}` })); } jest.useFakeTimers(); try { @@ -1204,7 +1260,7 @@ describe("MatrixRTCSession", () => { if (i === 0) { // if first time around then set up the session - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); } else { // otherwise update the state reducing the membership each time in order to trigger key rotation @@ -1229,8 +1285,8 @@ describe("MatrixRTCSession", () => { const realSetTimeout = setTimeout; jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); const keysSentPromise1 = new Promise((resolve) => { sendEventMock.mockImplementation(resolve); @@ -1245,11 +1301,11 @@ describe("MatrixRTCSession", () => { const onMembershipsChanged = jest.fn(); sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); - const member2 = Object.assign({}, membershipTemplate, { + const member2 = Object.assign({}, sessionMembershipTemplate, { device_id: "BBBBBBB", }); - mockRoomState(mockRoom, [membershipTemplate, member2]); + mockRoomState(mockRoom, [sessionMembershipTemplate, member2]); sess.onRTCSessionMemberUpdate(); await new Promise((resolve) => { @@ -1270,8 +1326,8 @@ describe("MatrixRTCSession", () => { sendToDeviceMock.mockImplementation(resolve); }); - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, @@ -1293,8 +1349,8 @@ describe("MatrixRTCSession", () => { describe("receiving", () => { it("collects keys from encryption events", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1318,8 +1374,8 @@ describe("MatrixRTCSession", () => { }); it("collects keys at non-zero indices", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1344,8 +1400,8 @@ describe("MatrixRTCSession", () => { }); it("collects keys by merging", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { @@ -1395,8 +1451,8 @@ describe("MatrixRTCSession", () => { }); it("ignores older keys at same index", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1454,8 +1510,8 @@ describe("MatrixRTCSession", () => { }); it("key timestamps are treated as monotonic", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( makeMockEvent( @@ -1498,8 +1554,8 @@ describe("MatrixRTCSession", () => { }); it("ignores keys event for the local participant", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); mockRoom.emitTimelineEvent( @@ -1521,8 +1577,8 @@ describe("MatrixRTCSession", () => { it("tracks total age statistics for collected keys", async () => { jest.useFakeTimers(); try { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); // defaults to getTs() jest.setSystemTime(1000); @@ -1577,8 +1633,8 @@ describe("MatrixRTCSession", () => { }); describe("read status", () => { it("returns the correct probablyLeft status", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.probablyLeft).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); @@ -1593,8 +1649,8 @@ describe("MatrixRTCSession", () => { }); it("returns membershipStatus once joinRoomSession got called", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + const mockRoom = makeMockRoom([sessionMembershipTemplate]); + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess!.membershipStatus).toBe(undefined); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index a6d862cb0b..acbfbdd074 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -17,13 +17,22 @@ limitations under the License. import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; +import { + makeMockRoom, + type MembershipData, + sessionMembershipTemplate, + mockRoomState, + mockRTCEvent, + rtcMembershipTemplate, +} from "./mocks"; import { logger } from "../../../src/logger"; +import { slotDescriptionToId } from "../../../src/matrixrtc"; describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( "MatrixRTCSessionManager ($eventKind)", ({ eventKind }) => { let client: MatrixClient; + let membershipTemplate: MembershipData; function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void { if (eventKind === "memberState") { @@ -40,6 +49,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( beforeEach(() => { client = new MatrixClient({ baseUrl: "base_url" }); client.matrixRTC.start(); + membershipTemplate = eventKind === "sticky" ? rtcMembershipTemplate : sessionMembershipTemplate; }); afterEach(() => { @@ -91,14 +101,15 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); - it("Fires correctly with custom sessionDescription", () => { + it("Fires correctly with custom slotDescription", () => { const onStarted = jest.fn(); const onEnded = jest.fn(); - // create a session manager with a custom session description - const sessionManager = new MatrixRTCSessionManager(logger, client, { + const slotDescription = { id: "test", application: "m.notCall", - }); + }; + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, slotDescription); // manually start the session manager (its not the default one started by the client) sessionManager.start(); @@ -106,19 +117,49 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); try { - // Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id - const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }]; - const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky"); + // Create a session for applicaation m.other, we ignore this session because it has the wrong application type. + const room1MembershipData: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: "m.call", + }, + }, + ] + : [{ ...membershipTemplate, application: "m.call" }]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky", { + application: "m.call", + id: "", + }); jest.spyOn(client, "getRooms").mockReturnValue([room1]); client.emit(ClientEvent.Room, room1); expect(onStarted).not.toHaveBeenCalled(); onStarted.mockClear(); // Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id - const room2MembershipData: MembershipData[] = [ - { ...membershipTemplate, application: "m.notCall", call_id: "test" }, - ]; - const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky"); + const room2MembershipData: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: slotDescription.application, + }, + slot_id: slotDescriptionToId(slotDescription), + }, + ] + : [ + { + ...membershipTemplate, + application: slotDescription.application, + call_id: slotDescription.id, + }, + ]; + const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky", slotDescription); jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); client.emit(ClientEvent.Room, room2); expect(onStarted).toHaveBeenCalled(); @@ -143,7 +184,18 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( it("Doesn't fire event if unrelated sessions ends", () => { const onEnded = jest.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const membership: MembershipData[] = + eventKind === "sticky" + ? [ + { + ...membershipTemplate, + application: { + ...rtcMembershipTemplate.application, + type: "m.other_app", + }, + }, + ] + : [{ ...membershipTemplate, application: "m.other_app" }]; const room1 = makeMockRoom(membership, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRoom").mockReturnValue(room1); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 8c4f90c002..fc565ec7dd 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -25,15 +25,11 @@ import { type Room, MAX_STICKY_DURATION_MS, } from "../../../src"; -import { - MembershipManagerEvent, - Status, - type Transport, - type SessionMembershipData, - type LivekitFocusSelection, -} from "../../../src/matrixrtc"; -import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; -import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { MembershipManagerEvent, Status, type Transport } from "../../../src/matrixrtc"; +import { makeMockClient, makeMockRoom, sessionMembershipTemplate, mockCallMembership, type MockClient } from "./mocks"; +import { LegacyMembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { type SessionMembershipData } from "../../../src/matrixrtc/membership/legacy.ts"; +import { type RtcMembershipData } from "../../../src/matrixrtc/membership/rtc.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -76,588 +72,673 @@ const callSession = { id: "", application: "m.call" }; describe("MembershipManager", () => { let client: MockClient; let room: Room; - const focusActive: LivekitFocusSelection = { + const focusActive = Object.freeze({ focus_selection: "oldest_membership", type: "livekit", - }; - const focus: Transport = { + }); + const focus: Transport = Object.freeze({ type: "livekit", livekit_service_url: "https://active.url", livekit_alias: "!active:active.url", - }; + }); beforeEach(() => { // Default to fake timers. jest.useFakeTimers(); client = makeMockClient("@alice:example.org", "AAAAAAA"); - room = makeMockRoom([membershipTemplate]); - // Provide a default mock that is like the default "non error" server behaviour. - (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); - (client._unstable_sendStickyEvent as Mock).mockResolvedValue({ event_id: "id" }); - (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); - (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); + room = makeMockRoom([sessionMembershipTemplate]); }); afterEach(() => { jest.useRealTimers(); // There is no need to clean up mocks since we will recreate the client. }); - - describe("isActivated()", () => { - it("defaults to false", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.isActivated()).toEqual(false); + describe("LegacyMembershipManager", () => { + beforeEach(() => { + // Provide a default mock that is like the default "non error" server behaviour. + (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); }); - it("returns true after join()", () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); + describe("isActivated()", () => { + it("defaults to false", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.isActivated()).toEqual(false); + }); + + it("returns true after join()", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + }); }); - }); - describe("join()", () => { - describe("sends a membership event", () => { - it("sends a membership event and schedules delayed leave when joining a call", async () => { - // Spys/Mocks + describe("join()", () => { + describe("sends a membership event", () => { + it("sends a membership event and schedules delayed leave when joining a call", async () => { + // Spys/Mocks - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + const updateDelayedEventHandle = createAsyncHandle( + client._unstable_updateDelayedEvent as Mock, + ); - // Test - const memberManager = new MembershipManager(undefined, room, client, callSession); - memberManager.join([focus], undefined); - // expects - await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - "org.matrix.msc3401.call.member", - { - application: "m.call", - call_id: "", - device_id: "AAAAAAA", - expires: 14400000, - foci_preferred: [focus], - focus_active: focusActive, - scope: "m.room", - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - updateDelayedEventHandle.resolve?.(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( - room.roomId, - { delay: 8000 }, - "org.matrix.msc3401.call.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - }); + // Test + const memberManager = new LegacyMembershipManager(undefined, room, client, callSession); + memberManager.join([focus], undefined); + // expects + await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + "org.matrix.msc3401.call.member", + { + application: "m.call", + call_id: "", + device_id: "AAAAAAA", + expires: 14400000, + foci_preferred: [focus], + focus_active: focusActive, + scope: "m.room", + }, + "_@alice:example.org_AAAAAAA_m.call", + ); + updateDelayedEventHandle.resolve?.(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 8000 }, + "org.matrix.msc3401.call.member", + {}, + "_@alice:example.org_AAAAAAA_m.call", + ); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); - it("reschedules delayed leave event if sending state cancels it", async () => { - const memberManager = new MembershipManager(undefined, room, client, callSession); - const waitForSendState = waitForMockCall(client.sendStateEvent); - const waitForUpdateDelaye = waitForMockCallOnce( - client._unstable_updateDelayedEvent, - Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), - ); - memberManager.join([focus], focusActive); - await waitForSendState; - await waitForUpdateDelaye; - await jest.advanceTimersByTimeAsync(1); - // Once for the initial event and once because of the errcode: "M_NOT_FOUND" - // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); - }); - - describe("does not prefix the state key with _ for rooms that support user-owned state events", () => { - async function testJoin(useOwnedStateEvents: boolean): Promise { - // TODO: this test does quiet a bit. Its more a like a test story summarizing to: - // - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten) - // - run into rate limit for sending delayed event - // - run into rate limit when setting membership state. - if (useOwnedStateEvents) { - room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); - } - const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); - const sentDelayedState = waitForMockCall( - client._unstable_sendDelayedStateEvent, - Promise.resolve({ - delay_id: "id", - }), + it("reschedules delayed leave event if sending state cancels it", async () => { + const memberManager = new LegacyMembershipManager(undefined, room, client, callSession); + const waitForSendState = waitForMockCall(client.sendStateEvent); + const waitForUpdateDelaye = waitForMockCallOnce( + client._unstable_updateDelayedEvent, + Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), ); + memberManager.join([focus], focusActive); + await waitForSendState; + await waitForUpdateDelaye; + await jest.advanceTimersByTimeAsync(1); + // Once for the initial event and once because of the errcode: "M_NOT_FOUND" + // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); - // preparing the delayed disconnect should handle the delay being too long - const sendDelayedStateExceedAttempt = new Promise((resolve) => { - const error = new MatrixError({ - "errcode": "M_UNKNOWN", - "org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED", - "org.matrix.msc4140.max_delay": 7500, + describe("does not prefix the state key with _ for rooms that support user-owned state events", () => { + async function testJoin(useOwnedStateEvents: boolean): Promise { + // TODO: this test does quiet a bit. Its more a like a test story summarizing to: + // - send delay with too long timeout and get server error (test delayedEventTimeout gets overwritten) + // - run into rate limit for sending delayed event + // - run into rate limit when setting membership state. + if (useOwnedStateEvents) { + room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); + } + const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); + const sentDelayedState = waitForMockCall( + client._unstable_sendDelayedStateEvent, + Promise.resolve({ + delay_id: "id", + }), + ); + + // preparing the delayed disconnect should handle the delay being too long + const sendDelayedStateExceedAttempt = new Promise((resolve) => { + const error = new MatrixError({ + "errcode": "M_UNKNOWN", + "org.matrix.msc4140.errcode": "M_MAX_DELAY_EXCEEDED", + "org.matrix.msc4140.max_delay": 7500, + }); + (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); - (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); + + const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`; + // preparing the delayed disconnect should handle ratelimiting + const sendDelayedStateAttempt = new Promise((resolve) => { + const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }); + (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); - }); - const userStateKey = `${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA_m.call`; - // preparing the delayed disconnect should handle ratelimiting - const sendDelayedStateAttempt = new Promise((resolve) => { - const error = new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }); - (client._unstable_sendDelayedStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); + // setting the membership state should handle ratelimiting (also with a retry-after value) + const sendStateEventAttempt = new Promise((resolve) => { + const error = new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ); + (client.sendStateEvent as Mock).mockImplementationOnce(() => { + resolve(); + return Promise.reject(error); + }); }); + const manager = new LegacyMembershipManager( + { + delayedLeaveEventDelayMs: 9000, + }, + room, + client, + callSession, + ); + manager.join([focus]); + + await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches + await sendDelayedStateAttempt; + const callProps = (d: number) => { + return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey]; + }; + expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000)); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500)); + + await jest.advanceTimersByTimeAsync(5000); + + await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches + + await jest.advanceTimersByTimeAsync(1000); + + expect(client.sendStateEvent).toHaveBeenCalledWith( + room!.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + expires: 14400000, + device_id: "AAAAAAA", + foci_preferred: [focus], + focus_active: focusActive, + } satisfies SessionMembershipData, + userStateKey, + ); + await sentDelayedState; + + // should have prepared the heartbeat to keep delaying the leave event while still connected + await updatedDelayedEvent; + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + + // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. + await jest.advanceTimersByTimeAsync(5000); + // should update delayed disconnect + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + } + + it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { + await testJoin(false); }); - // setting the membership state should handle ratelimiting (also with a retry-after value) - const sendStateEventAttempt = new Promise((resolve) => { - const error = new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ); - (client.sendStateEvent as Mock).mockImplementationOnce(() => { - resolve(); - return Promise.reject(error); - }); + it("does not prefix the state key with _ for rooms that support user-owned state events", async () => { + await testJoin(true); }); - const manager = new MembershipManager( - { - delayedLeaveEventDelayMs: 9000, - }, + }); + }); + + describe("delayed leave event", () => { + it("does not try again to schedule a delayed leave event if not supported", () => { + const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus]); + delayedHandle.reject?.( + new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "sendDelayedStateEvent", + ), + ); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); + it("does try to schedule a delayed leave event again if rate limited", async () => { + const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus]); + delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); + await jest.advanceTimersByTimeAsync(5000); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); + it("uses delayedLeaveEventDelayMs from config", () => { + const manager = new LegacyMembershipManager( + { delayedLeaveEventDelayMs: 123456 }, room, client, callSession, ); manager.join([focus]); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 123456 }, + "org.matrix.msc3401.call.member", + {}, + "_@alice:example.org_AAAAAAA_m.call", + ); + }); + }); - await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches - await sendDelayedStateAttempt; - const callProps = (d: number) => { - return [room!.roomId, { delay: d }, "org.matrix.msc3401.call.member", {}, userStateKey]; - }; - expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(1, ...callProps(9000)); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenNthCalledWith(2, ...callProps(7500)); - - await jest.advanceTimersByTimeAsync(5000); - - await sendStateEventAttempt.then(); // needed to resolve after resendIfRateLimited catches + it("rejoins if delayed event is not found (404)", async () => { + const RESTART_DELAY = 15000; + const manager = new LegacyMembershipManager( + { delayedLeaveEventRestartMs: RESTART_DELAY }, + room, + client, - await jest.advanceTimersByTimeAsync(1000); - - expect(client.sendStateEvent).toHaveBeenCalledWith( - room!.roomId, - EventType.GroupCallMemberPrefix, - { - application: "m.call", - scope: "m.room", - call_id: "", - expires: 14400000, - device_id: "AAAAAAA", - foci_preferred: [focus], - focus_active: focusActive, - } satisfies SessionMembershipData, - userStateKey, - ); - await sentDelayedState; + callSession, + ); + // Join with the membership manager + manager.join([focus]); + expect(manager.status).toBe(Status.Connecting); + // Let the scheduler run one iteration so that we can send the join state event + await jest.runOnlyPendingTimersAsync(); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(manager.status).toBe(Status.Connected); + // Now that we are connected, we set up the mocks. + // We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave: + // - We wait until the delayed event gets sent and then mock its response to be "not found." + // - We enforce a race condition between the sync that informs us that our call membership state event was set to "left" + // and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent. + // - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left." + // (onRTCSessionMemberUpdate) + // - Only then do we resolve the sending of the delayed event. + // - We test that the manager acknowledges the leave and sends a new membership state event. + (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + new MatrixError({ errcode: "M_NOT_FOUND" }), + ); - // should have prepared the heartbeat to keep delaying the leave event while still connected - await updatedDelayedEvent; - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); + await jest.advanceTimersByTimeAsync(RESTART_DELAY); + // first simulate the sync, then resolve sending the delayed event. + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + resolve({ delay_id: "id" }); + // Let the scheduler run one iteration so that the new join gets sent + await jest.runOnlyPendingTimersAsync(); + expect(client.sendStateEvent).toHaveBeenCalledTimes(2); + }); - // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. - await jest.advanceTimersByTimeAsync(5000); - // should update delayed disconnect - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); - } + it("uses membershipEventExpiryMs from config", async () => { + const manager = new LegacyMembershipManager( + { membershipEventExpiryMs: 1234567 }, + room, + client, - it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { - await testJoin(false); - }); + callSession, + ); - it("does not prefix the state key with _ for rooms that support user-owned state events", async () => { - await testJoin(true); - }); + manager.join([focus]); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + EventType.GroupCallMemberPrefix, + { + application: "m.call", + scope: "m.room", + call_id: "", + device_id: "AAAAAAA", + expires: 1234567, + foci_preferred: [focus], + focus_active: { + focus_selection: "oldest_membership", + type: "livekit", + }, + }, + "_@alice:example.org_AAAAAAA_m.call", + ); }); - }); - describe("delayed leave event", () => { - it("does not try again to schedule a delayed leave event if not supported", () => { - const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, callSession); + it("does nothing if join called when already joined", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - delayedHandle.reject?.( - new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "sendDelayedStateEvent", - ), - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + manager.join([focus]); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); - it("does try to schedule a delayed leave event again if rate limited", async () => { - const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock); - const manager = new MembershipManager({}, room, client, callSession); + }); + + describe("leave()", () => { + // TODO add rate limit cases. + it("resolves delayed leave event when leave is called", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined)); - await jest.advanceTimersByTimeAsync(5000); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + await jest.advanceTimersByTimeAsync(1); + await manager.leave(); + expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); + expect(client.sendStateEvent).toHaveBeenCalled(); }); - it("uses delayedLeaveEventDelayMs from config", () => { - const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + it("send leave event when leave is called and resolving delayed leave fails", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); manager.join([focus]); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + await jest.advanceTimersByTimeAsync(1); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); + await manager.leave(); + + // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. + expect(client.sendStateEvent).toHaveBeenLastCalledWith( room.roomId, - { delay: 123456 }, "org.matrix.msc3401.call.member", {}, "_@alice:example.org_AAAAAAA_m.call", ); }); + it("does nothing if not joined", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(async () => await manager.leave()).not.toThrow(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); }); - it("rejoins if delayed event is not found (404)", async () => { - const RESTART_DELAY = 15000; - const manager = new MembershipManager( - { delayedLeaveEventRestartMs: RESTART_DELAY }, - room, - client, - - callSession, - ); - // Join with the membership manager - manager.join([focus]); - expect(manager.status).toBe(Status.Connecting); - // Let the scheduler run one iteration so that we can send the join state event - await jest.runOnlyPendingTimersAsync(); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(manager.status).toBe(Status.Connected); - // Now that we are connected, we set up the mocks. - // We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave: - // - We wait until the delayed event gets sent and then mock its response to be "not found." - // - We enforce a race condition between the sync that informs us that our call membership state event was set to "left" - // and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent. - // - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left." - // (onRTCSessionMemberUpdate) - // - Only then do we resolve the sending of the delayed event. - // - We test that the manager acknowledges the leave and sends a new membership state event. - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( - new MatrixError({ errcode: "M_NOT_FOUND" }), - ); - - const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); - await jest.advanceTimersByTimeAsync(RESTART_DELAY); - // first simulate the sync, then resolve sending the delayed event. - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); - resolve({ delay_id: "id" }); - // Let the scheduler run one iteration so that the new join gets sent - await jest.runOnlyPendingTimersAsync(); - expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - }); + describe("onRTCSessionMemberUpdate()", () => { + it("does nothing if not joined", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + await jest.advanceTimersToNextTimerAsync(); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + }); + it("does nothing if own membership still present", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; + // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); + + await manager.onRTCSessionMemberUpdate([ + mockCallMembership(sessionMembershipTemplate, room.roomId), + mockCallMembership( + { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, + room.roomId, + ), + ]); - it("uses membershipEventExpiryMs from config", async () => { - const manager = new MembershipManager( - { membershipEventExpiryMs: 1234567 }, - room, - client, - - callSession, - ); - - manager.join([focus]); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - EventType.GroupCallMemberPrefix, - { - application: "m.call", - scope: "m.room", - call_id: "", - device_id: "AAAAAAA", - expires: 1234567, - foci_preferred: [focus], - focus_active: { - focus_selection: "oldest_membership", - type: "livekit", - }, - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - }); + await jest.advanceTimersByTimeAsync(1); - it("does nothing if join called when already joined", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - manager.join([focus]); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - }); - }); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); + expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + }); + it("recreates membership if it is missing", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - describe("leave()", () => { - // TODO add rate limit cases. - it("resolves delayed leave event when leave is called", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await jest.advanceTimersByTimeAsync(1); - await manager.leave(); - expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - it("send leave event when leave is called and resolving delayed leave fails", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus]); - await jest.advanceTimersByTimeAsync(1); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); - await manager.leave(); - - // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. - expect(client.sendStateEvent).toHaveBeenLastCalledWith( - room.roomId, - "org.matrix.msc3401.call.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - }); - it("does nothing if not joined", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(async () => await manager.leave()).not.toThrow(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - }); - }); + // Our own membership is removed: + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + await jest.advanceTimersByTimeAsync(1); + expect(client.sendStateEvent).toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - describe("onRTCSessionMemberUpdate()", () => { - it("does nothing if not joined", async () => { - const manager = new MembershipManager({}, room, client, callSession); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); - await jest.advanceTimersToNextTimerAsync(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); - }); - it("does nothing if own membership still present", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2]; - // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - await manager.onRTCSessionMemberUpdate([ - mockCallMembership(membershipTemplate, room.roomId), - mockCallMembership( - { ...(myMembership as SessionMembershipData), user_id: client.getUserId()! }, - room.roomId, - ), - ]); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + }); - await jest.advanceTimersByTimeAsync(1); + it("updates the UpdateExpiry entry in the action scheduler", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` + (client.sendStateEvent as Mock).mockClear(); + (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); - }); - it("recreates membership if it is missing", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - // Our own membership is removed: - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); - await jest.advanceTimersByTimeAsync(1); - expect(client.sendStateEvent).toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); - }); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + new MatrixError({ errcode: "M_NOT_FOUND" }), + ); + + const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); + await jest.advanceTimersByTimeAsync(10_000); + await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]); + resolve({ delay_id: "id" }); + await jest.advanceTimersByTimeAsync(10_000); + + expect(client.sendStateEvent).toHaveBeenCalled(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - it("updates the UpdateExpiry entry in the action scheduler", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` - (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); - (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( - new MatrixError({ errcode: "M_NOT_FOUND" }), - ); - - const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent); - await jest.advanceTimersByTimeAsync(10_000); - await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); - resolve({ delay_id: "id" }); - await jest.advanceTimersByTimeAsync(10_000); - - expect(client.sendStateEvent).toHaveBeenCalled(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); - expect(manager.status).toBe(Status.Connected); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(manager.status).toBe(Status.Connected); + }); }); - }); - // TODO: Not sure about this name - describe("background timers", () => { - it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => { - const manager = new MembershipManager( - { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, - room, - client, - { id: "", application: "m.call" }, - ); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - - // The first call is from checking id the server deleted the delayed event - // so it does not need a `advanceTimersByTime` - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); - - for (let i = 2; i <= 12; i++) { - // flush promises before advancing the timers to make sure schedulers are setup - await jest.advanceTimersByTimeAsync(10_000); + // TODO: Not sure about this name + describe("background timers", () => { + it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => { + const manager = new LegacyMembershipManager( + { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, + room, + client, + { id: "", application: "m.call" }, + ); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + // The first call is from checking id the server deleted the delayed event + // so it does not need a `advanceTimersByTime` + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); // TODO: Check that update delayed event is called with the correct HTTP request timeout // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); - } - }); - // because the expires logic was removed for the legacy call manager. - // Delayed events should replace it entirely but before they have wide adoption - // the expiration logic still makes sense. - // TODO: Add git commit when we removed it. - async function testExpires(expire: number, headroom?: number) { - const manager = new MembershipManager( - { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, - room, - client, - - { id: "", application: "m.call" }, - ); - manager.join([focus], focusActive); - await waitForMockCall(client.sendStateEvent); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; - expect(sentMembership.expires).toBe(expire); - for (let i = 2; i <= 12; i++) { - await jest.advanceTimersByTimeAsync(expire); - expect(client.sendStateEvent).toHaveBeenCalledTimes(i); - const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData; - expect(sentMembership.expires).toBe(expire * i); + for (let i = 2; i <= 12; i++) { + // flush promises before advancing the timers to make sure schedulers are setup + await jest.advanceTimersByTimeAsync(10_000); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + // TODO: Check that update delayed event is called with the correct HTTP request timeout + // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + } + }); + + // because the expires logic was removed for the legacy call manager. + // Delayed events should replace it entirely but before they have wide adoption + // the expiration logic still makes sense. + // TODO: Add git commit when we removed it. + async function testExpires(expire: number, headroom?: number) { + const manager = new LegacyMembershipManager( + { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, + room, + client, + + { id: "", application: "m.call" }, + ); + manager.join([focus], focusActive); + await waitForMockCall(client.sendStateEvent); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + const sentMembership = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; + expect(sentMembership.expires).toBe(expire); + for (let i = 2; i <= 12; i++) { + await jest.advanceTimersByTimeAsync(expire); + expect(client.sendStateEvent).toHaveBeenCalledTimes(i); + const sentMembership = (client.sendStateEvent as Mock).mock.lastCall![2] as SessionMembershipData; + expect(sentMembership.expires).toBe(expire * i); + } } - } - it("extends `expires` when call still active", async () => { - await testExpires(10_000); - }); - it("extends `expires` using headroom configuration", async () => { - await testExpires(10_000, 1_000); + it("extends `expires` when call still active", async () => { + await testExpires(10_000); + }); + it("extends `expires` using headroom configuration", async () => { + await testExpires(10_000, 1_000); + }); }); - }); - describe("status updates", () => { - it("starts 'Disconnected'", () => { - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.status).toBe(Status.Disconnected); - }); - it("emits 'Connection' and 'Connected' after join", async () => { - const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const handleStateEvent = createAsyncHandle(client.sendStateEvent); - - const manager = new MembershipManager({}, room, client, callSession); - expect(manager.status).toBe(Status.Disconnected); - const connectEmit = jest.fn(); - manager.on(MembershipManagerEvent.StatusChanged, connectEmit); - manager.join([focus], focusActive); - expect(manager.status).toBe(Status.Connecting); - handleDelayedEvent.resolve(); - await jest.advanceTimersByTimeAsync(1); - expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting); - handleStateEvent.resolve(); - await jest.advanceTimersByTimeAsync(1); - expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); - }); - it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { - const manager = new MembershipManager({}, room, client, callSession); - const connectEmit = jest.fn(); - manager.on(MembershipManagerEvent.StatusChanged, connectEmit); - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); - await manager.leave(); - expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting); - expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected); + describe("status updates", () => { + it("starts 'Disconnected'", () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.status).toBe(Status.Disconnected); + }); + it("emits 'Connection' and 'Connected' after join", async () => { + const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent); + const handleStateEvent = createAsyncHandle(client.sendStateEvent); + + const manager = new LegacyMembershipManager({}, room, client, callSession); + expect(manager.status).toBe(Status.Disconnected); + const connectEmit = jest.fn(); + manager.on(MembershipManagerEvent.StatusChanged, connectEmit); + manager.join([focus], focusActive); + expect(manager.status).toBe(Status.Connecting); + handleDelayedEvent.resolve(); + await jest.advanceTimersByTimeAsync(1); + expect(connectEmit).toHaveBeenCalledWith(Status.Disconnected, Status.Connecting); + handleStateEvent.resolve(); + await jest.advanceTimersByTimeAsync(1); + expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); + }); + it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + const connectEmit = jest.fn(); + manager.on(MembershipManagerEvent.StatusChanged, connectEmit); + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + await manager.leave(); + expect(connectEmit).toHaveBeenCalledWith(Status.Connected, Status.Disconnecting); + expect(connectEmit).toHaveBeenCalledWith(Status.Disconnecting, Status.Disconnected); + }); }); - }); - describe("server error handling", () => { - // Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout - describe("retries sending delayed leave event", () => { - it("sends retry if call membership event is still valid at time of retry", async () => { - const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); + describe("server error handling", () => { + // Types of server error: 429 rate limit with no retry-after header, 429 with retry-after, 50x server error (maybe retry every second), connection/socket timeout + describe("retries sending delayed leave event", () => { + it("sends retry if call membership event is still valid at time of retry", async () => { + const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - handle.reject?.( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), - ); - await jest.advanceTimersByTimeAsync(1000); + handle.reject?.( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + }); + it("abandons retry loop and sends new own membership if not present anymore", async () => { + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the + // RateLimit error. + manager.join([focus], focusActive); + await jest.advanceTimersByTimeAsync(1); + + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); + // Remove our own membership so that there is no reason the send the delayed leave anymore. + // the membership is no longer present on the homeserver + await manager.onRTCSessionMemberUpdate([]); + // Wait for all timers to be setup + await jest.advanceTimersByTimeAsync(1000); + // We should send the first own membership and a new delayed event after the rate limit timeout. + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + }); + it("abandons retry loop if leave() was called before sending state event", async () => { + const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); + + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + handle.reject?.( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + // the user terminated the call locally + await manager.leave(); + + // Wait for all timers to be setup + await jest.advanceTimersByTimeAsync(1000); + + // No new events should have been sent: + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + }); }); - it("abandons retry loop and sends new own membership if not present anymore", async () => { + describe("retries sending update delayed leave event restart", () => { + it("resends the initial check delayed update event", async () => { + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + new MatrixError( + { errcode: "M_LIMIT_EXCEEDED" }, + 429, + undefined, + undefined, + new Headers({ "Retry-After": "1" }), + ), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive); + + // Hit rate limit + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + + // Hit second rate limit. + await jest.advanceTimersByTimeAsync(1000); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + + // Setup resolve + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + await jest.advanceTimersByTimeAsync(1000); + + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + describe("unrecoverable errors", () => { + // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. + it("throws, when reaching maximum number of retries for initial delayed event creation", async () => { + const delayEventSendError = jest.fn(); (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, undefined, undefined, - new Headers({ "Retry-After": "1" }), + new Headers({ "Retry-After": "2" }), ), ); - const manager = new MembershipManager({}, room, client, callSession); - // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the - // RateLimit error. - manager.join([focus], focusActive); - await jest.advanceTimersByTimeAsync(1); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, delayEventSendError); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); - // Remove our own membership so that there is no reason the send the delayed leave anymore. - // the membership is no longer present on the homeserver - await manager.onRTCSessionMemberUpdate([]); - // Wait for all timers to be setup - await jest.advanceTimersByTimeAsync(1000); - // We should send the first own membership and a new delayed event after the rate limit timeout. - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + for (let i = 0; i < 10; i++) { + await jest.advanceTimersByTimeAsync(2000); + } + expect(delayEventSendError).toHaveBeenCalled(); }); - it("abandons retry loop if leave() was called before sending state event", async () => { - const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent); - - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive); - handle.reject?.( + // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. + it("throws, when reaching maximum number of retries", async () => { + const delayEventRestartError = jest.fn(); + (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -666,225 +747,152 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, delayEventRestartError); + for (let i = 0; i < 10; i++) { + await jest.advanceTimersByTimeAsync(1000); + } + expect(delayEventRestartError).toHaveBeenCalled(); + }); + it("falls back to using pure state events when some error occurs while sending delayed events", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, unrecoverableError); + await waitForMockCall(client.sendStateEvent); + expect(unrecoverableError).not.toHaveBeenCalledWith(); + expect(client.sendStateEvent).toHaveBeenCalled(); + }); + it("retries before failing in case its a network error", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 501)); + const manager = new LegacyMembershipManager( + { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, + room, + client, + callSession, + ); + manager.join([focus], focusActive, unrecoverableError); + for (let retries = 0; retries < 7; retries++) { + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1); + await jest.advanceTimersByTimeAsync(1000); + } + expect(unrecoverableError).toHaveBeenCalled(); + expect(unrecoverableError.mock.lastCall![0].message).toMatch( + "The MembershipManager shut down because of the end condition", + ); + expect(client.sendStateEvent).not.toHaveBeenCalled(); + }); + it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => { + const unrecoverableError = jest.fn(); + (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( + new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), + ); + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - // the user terminated the call locally - await manager.leave(); - - // Wait for all timers to be setup - await jest.advanceTimersByTimeAsync(1000); - // No new events should have been sent: - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); + expect(unrecoverableError).not.toHaveBeenCalled(); + expect(client.sendStateEvent).toHaveBeenCalled(); }); }); - describe("retries sending update delayed leave event restart", () => { - it("resends the initial check delayed update event", async () => { - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), + describe("probablyLeft", () => { + it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => { + const manager = new LegacyMembershipManager( + { delayedLeaveEventDelayMs: 10000 }, + room, + client, + + callSession, ); - const manager = new MembershipManager({}, room, client, callSession); + const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); + const probablyLeftEmit = jest.fn(); + manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit); manager.join([focus], focusActive); + try { + // Let the scheduler run one iteration so that we can send the join state event + await waitForMockCall(client._unstable_updateDelayedEvent); + + // We never resolve the delayed event so that we can test the probablyLeft event. + // This simulates the case where the server does not respond to the delayed event. + client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); + expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + expect(manager.status).toBe(Status.Connected); + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + // We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000. + // We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding. + // The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test. + await jest.advanceTimersByTimeAsync(5000); + // No emission after 5s + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - // Hit rate limit - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - - // Hit second rate limit. - await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); - - // Setup resolve - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); - await jest.advanceTimersByTimeAsync(1000); - - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(4999); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); + + // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. + (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); + + // Emit after 10s + await jest.advanceTimersByTimeAsync(1); + expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); + expect(probablyLeftEmit).toHaveBeenCalledWith(true); + + // Mock a sync which does not include our own membership + await manager.onRTCSessionMemberUpdate([]); + // Wait for the current ongoing delayed event sending to finish + await jest.advanceTimersByTimeAsync(1); + // We should send a new state event and an associated delayed leave event. + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + expect(client.sendStateEvent).toHaveBeenCalledTimes(2); + // At the same time we expect the probablyLeft event to be emitted with false so we are back operational. + expect(probablyLeftEmit).toHaveBeenCalledWith(false); + } finally { + rejectStuckPromise(); + } }); }); - }); - describe("unrecoverable errors", () => { - // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. - it("throws, when reaching maximum number of retries for initial delayed event creation", async () => { - const delayEventSendError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "2" }), - ), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, delayEventSendError); - - for (let i = 0; i < 10; i++) { - await jest.advanceTimersByTimeAsync(2000); - } - expect(delayEventSendError).toHaveBeenCalled(); - }); - // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. - it("throws, when reaching maximum number of retries", async () => { - const delayEventRestartError = jest.fn(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( - new MatrixError( - { errcode: "M_LIMIT_EXCEEDED" }, - 429, - undefined, - undefined, - new Headers({ "Retry-After": "1" }), - ), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, delayEventRestartError); - - for (let i = 0; i < 10; i++) { - await jest.advanceTimersByTimeAsync(1000); - } - expect(delayEventRestartError).toHaveBeenCalled(); - }); - it("falls back to using pure state events when some error occurs while sending delayed events", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 601)); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, unrecoverableError); - await waitForMockCall(client.sendStateEvent); - expect(unrecoverableError).not.toHaveBeenCalledWith(); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - it("retries before failing in case its a network error", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue(new HTTPError("unknown", 501)); - const manager = new MembershipManager( - { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, - room, - client, - callSession, - ); - manager.join([focus], focusActive, unrecoverableError); - for (let retries = 0; retries < 7; retries++) { - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(retries + 1); - await jest.advanceTimersByTimeAsync(1000); - } - expect(unrecoverableError).toHaveBeenCalled(); - expect(unrecoverableError.mock.lastCall![0].message).toMatch( - "The MembershipManager shut down because of the end condition", - ); - expect(client.sendStateEvent).not.toHaveBeenCalled(); - }); - it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => { - const unrecoverableError = jest.fn(); - (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( - new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), - ); - const manager = new MembershipManager({}, room, client, callSession); - manager.join([focus], focusActive, unrecoverableError); - await jest.advanceTimersByTimeAsync(1); - - expect(unrecoverableError).not.toHaveBeenCalled(); - expect(client.sendStateEvent).toHaveBeenCalled(); - }); - }); - describe("probablyLeft", () => { - it("emits probablyLeft when the membership manager could not hear back from the server for the duration of the delayed event", async () => { - const manager = new MembershipManager( - { delayedLeaveEventDelayMs: 10000 }, - room, - client, - - callSession, - ); - const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); - const probablyLeftEmit = jest.fn(); - manager.on(MembershipManagerEvent.ProbablyLeft, probablyLeftEmit); - manager.join([focus], focusActive); - try { - // Let the scheduler run one iteration so that we can send the join state event - await waitForMockCall(client._unstable_updateDelayedEvent); - - // We never resolve the delayed event so that we can test the probablyLeft event. - // This simulates the case where the server does not respond to the delayed event. - client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); - expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - expect(manager.status).toBe(Status.Connected); - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - // We expect the probablyLeft event to be emitted after the `delayedLeaveEventDelayMs` = 10000. - // We also track the calls to updated the delayed event that all will never resolve to simulate the server not responding. - // The numbers are a bit arbitrary since we use the local timeout that does not perfectly match the 5s check interval in this test. - await jest.advanceTimersByTimeAsync(5000); - // No emission after 5s - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); - await jest.advanceTimersByTimeAsync(4999); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); - expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - - // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); - - // Emit after 10s - await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); - expect(probablyLeftEmit).toHaveBeenCalledWith(true); + describe("updateCallIntent()", () => { + it("should fail if the user has not joined the call", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + // After joining we want our own focus to be the one we select. + try { + await manager.updateCallIntent("video"); + throw Error("Should have thrown"); + } catch {} + }); - // Mock a sync which does not include our own membership - await manager.onRTCSessionMemberUpdate([]); - // Wait for the current ongoing delayed event sending to finish - await jest.advanceTimersByTimeAsync(1); - // We should send a new state event and an associated delayed leave event. - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2); + it("can adjust the intent", async () => { + const manager = new LegacyMembershipManager({}, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + const membership = mockCallMembership( + { ...sessionMembershipTemplate, user_id: client.getUserId()! }, + room.roomId, + ); + await manager.onRTCSessionMemberUpdate([membership]); + await manager.updateCallIntent("video"); expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - // At the same time we expect the probablyLeft event to be emitted with false so we are back operational. - expect(probablyLeftEmit).toHaveBeenCalledWith(false); - } finally { - rejectStuckPromise(); - } - }); - }); + const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; + expect(eventContent["created_ts"]).toEqual(membership.createdTs()); + expect(eventContent["m.call.intent"]).toEqual("video"); + }); - describe("updateCallIntent()", () => { - it("should fail if the user has not joined the call", async () => { - const manager = new MembershipManager({}, room, client, callSession); - // After joining we want our own focus to be the one we select. - try { + it("does nothing if the intent doesn't change", async () => { + const manager = new LegacyMembershipManager({ callIntent: "video" }, room, client, callSession); + manager.join([]); + expect(manager.isActivated()).toEqual(true); + const membership = mockCallMembership( + { ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, + room.roomId, + ); + await manager.onRTCSessionMemberUpdate([membership]); await manager.updateCallIntent("video"); - throw Error("Should have thrown"); - } catch {} - }); - - it("can adjust the intent", async () => { - const manager = new MembershipManager({}, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); - await manager.onRTCSessionMemberUpdate([membership]); - await manager.updateCallIntent("video"); - expect(client.sendStateEvent).toHaveBeenCalledTimes(2); - const eventContent = (client.sendStateEvent as Mock).mock.calls[0][2] as SessionMembershipData; - expect(eventContent["created_ts"]).toEqual(membership.createdTs()); - expect(eventContent["m.call.intent"]).toEqual("video"); - }); - - it("does nothing if the intent doesn't change", async () => { - const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession); - manager.join([]); - expect(manager.isActivated()).toEqual(true); - const membership = mockCallMembership( - { ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" }, - room.roomId, - ); - await manager.onRTCSessionMemberUpdate([membership]); - await manager.updateCallIntent("video"); - expect(client.sendStateEvent).toHaveBeenCalledTimes(0); + expect(client.sendStateEvent).toHaveBeenCalledTimes(0); + }); }); }); @@ -915,15 +923,15 @@ describe("MembershipManager", () => { { application: { type: "m.call" }, member: { - user_id: "@alice:example.org", + claimed_user_id: "@alice:example.org", id: "_@alice:example.org_AAAAAAA_m.call", - device_id: "AAAAAAA", + claimed_device_id: "AAAAAAA", }, slot_id: "m.call#", rtc_transports: [focus], versions: [], msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", - }, + } satisfies RtcMembershipData, ); updateDelayedEventHandle.resolve?.(); @@ -948,9 +956,9 @@ 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); + const membershipManager = new LegacyMembershipManager(undefined, room, client, callSession); const spy = jest.spyOn(console, "error"); // Double join diff --git a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts index 1614ec1e87..525f79d84a 100644 --- a/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts +++ b/spec/unit/matrixrtc/RTCEncryptionManager.spec.ts @@ -20,7 +20,7 @@ import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManage import { type CallMembership, type Statistics } from "../../../src/matrixrtc"; import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts"; -import { membershipTemplate, mockCallMembership } from "./mocks.ts"; +import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts"; import { decodeBase64, TypedEventEmitter } from "../../../src"; import { logger } from "../../../src/logger.ts"; import { getParticipantId } from "../../../src/matrixrtc/utils.ts"; @@ -782,7 +782,7 @@ describe("RTCEncryptionManager", () => { function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership { return mockCallMembership( - { ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, + { ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts }, "!room:id", ); } diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index f08cced850..59f323663c 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks"; +import { makeMockEvent, makeMockRoom, sessionMembershipTemplate, makeKey } from "./mocks"; import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport"; import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; import { EventType, MatrixClient, RoomEvent } from "../../../src"; @@ -48,7 +48,7 @@ describe("RoomKeyTransport", () => { roomEventEncryptionKeysReceivedTotalAge: 0, }, }; - room = makeMockRoom([membershipTemplate]); + room = makeMockRoom([sessionMembershipTemplate]); client = new MatrixClient({ baseUrl: "base_url" }); client.matrixRTC.start(); transport = new RoomKeyTransport(room, client, statistics, { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 24982afe25..6c9513e37a 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -18,12 +18,23 @@ import { EventEmitter } from "stream"; import { type Mocked } from "jest-mock"; import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; -import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; +import { CallMembership } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; +import { + DefaultCallApplicationDescription, + type RtcSlotEventContent, + type SlotDescription, + slotDescriptionToId, +} from "../../../src/matrixrtc"; +import { mkMatrixEvent } from "../../../src/testing"; +import type { SessionMembershipData } from "../../../src/matrixrtc/membership/legacy"; +import type { RtcMembershipData } from "../../../src/matrixrtc/membership/rtc"; -export type MembershipData = (SessionMembershipData | {}) & { user_id: string }; +export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string }; -export const membershipTemplate: SessionMembershipData & { user_id: string } = { +export const testStickyDurationMs = 10000; + +export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = { application: "m.call", call_id: "", user_id: "@mock:user.example", @@ -44,6 +55,34 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = { ], }; +export const rtcMembershipTemplate: RtcMembershipData & { user_id: string; __test_sticky_expiry?: number } = { + slot_id: "m.call#", + application: { + "type": "m.call", + "m.call.id": "", + }, + user_id: "@mock:user.example", + member: { + claimed_user_id: "@mock:user.example", + claimed_device_id: "AAAAAAA", + id: "ea2MaingeeMo", + }, + sticky_key: "ea2MaingeeMo", + rtc_transports: [ + { + livekit_alias: "!alias:something.org", + livekit_service_url: "https://livekit-jwt.something.io", + type: "livekit", + }, + { + livekit_alias: "!alias:something.org", + livekit_service_url: "https://livekit-jwt.something.dev", + type: "livekit", + }, + ], + versions: [], +}; + export type MockClient = Pick< MatrixClient, | "getUserId" @@ -76,10 +115,16 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { export function makeMockRoom( membershipData: MembershipData[], useStickyEvents = false, + slotDescription = DefaultCallApplicationDescription, + addRTCSlot = useStickyEvents, ): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); + const roomState = makeMockRoomState( + useStickyEvents ? [] : membershipData, + roomId, + addRTCSlot ? slotDescription : undefined, + ); const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, @@ -91,7 +136,16 @@ export function makeMockRoom( _unstable_getStickyEvents: jest .fn() .mockImplementation(() => - useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], + useStickyEvents + ? membershipData.map((m) => + mockRTCEvent( + m, + roomId, + (m as typeof rtcMembershipTemplate).__test_sticky_expiry ?? testStickyDurationMs, + ts, + ), + ) + : [], ) as any, }); return Object.assign(room, { @@ -100,39 +154,71 @@ export function makeMockRoom( }) as unknown as Mocked void }>; } -function makeMockRoomState(membershipData: MembershipData[], roomId: string) { +function makeMockRoomState(membershipData: MembershipData[], roomId: string, slotDescription?: SlotDescription) { const events = membershipData.map((m) => mockRTCEvent(m, roomId)); const keysAndEvents = events.map((e) => { const data = e.getContent() as SessionMembershipData; return [`_${e.sender?.userId}_${data.device_id}`]; }); + let slotEvent: MatrixEvent | undefined; + + if (slotDescription) { + // Add a slot + const stateKey = slotDescriptionToId(slotDescription); + slotEvent = mkMatrixEvent({ + stateKey: stateKey, + roomId, + sender: "@anyadmin:example.org", + type: EventType.RTCSlot, + content: { + application: { + type: slotDescription.application, + }, + slot_id: slotDescriptionToId(slotDescription), + } satisfies RtcSlotEventContent, + }); + } return { on: jest.fn(), off: jest.fn(), - getStateEvents: (_: string, stateKey: string) => { + getStateEvents: (type: string, stateKey: string) => { + if (slotEvent && type === EventType.RTCSlot && stateKey === slotEvent.getStateKey()) return slotEvent; + if (type !== EventType.GroupCallMemberPrefix) return null; if (stateKey !== undefined) return keysAndEvents.find(([k]) => k === stateKey)?.[1]; return events; }, - events: - events.length === 0 - ? new Map() - : new Map([ + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey), + get: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey)?.[1], + values: () => events, + }, + ], + ...(slotEvent + ? [ [ - EventType.GroupCallMemberPrefix, + EventType.RTCSlot, { size: () => true, - has: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey), - get: (stateKey: string) => keysAndEvents.find(([k]) => k === stateKey)?.[1], - values: () => events, + has: (stateKey: string) => slotEvent.getStateKey() === stateKey, + get: (stateKey: string) => (slotEvent.getStateKey() === stateKey ? slotEvent : undefined), + values: () => [slotEvent], }, ], - ]), + ] + : []), + ] as any), }; } -export function mockRoomState(room: Room, membershipData: MembershipData[]): void { - room.getLiveTimeline().getState = jest.fn().mockReturnValue(makeMockRoomState(membershipData, room.roomId)); +export function mockRoomState(room: Room, membershipData: MembershipData[], slotDescription?: SlotDescription): void { + room.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState(membershipData, room.roomId, slotDescription)); } export function makeMockEvent( @@ -170,7 +256,7 @@ export function mockRTCEvent( timestamp, !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", ), - unstableStickyExpiresAt: stickyDuration, + unstableStickyExpiresAt: stickyDuration ? Date.now() + stickyDuration : undefined, } as unknown as MatrixEvent; } diff --git a/src/@types/event.ts b/src/@types/event.ts index 96780da84e..7b2fc889d1 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -56,13 +56,15 @@ import { type IRTCDeclineContent, type EncryptionKeysEventContent, type ICallNotifyContent, + type RtcSlotEventContent, } from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; -import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts"; import { type LocalNotificationSettings } from "./local_notifications.ts"; import { type IPushRules } from "./PushRules.ts"; import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts"; import { type POLICIES_ACCOUNT_EVENT_TYPE } from "../models/invites-ignorer-types.ts"; +import { type RtcMembershipData } from "../matrixrtc/membership/rtc.ts"; +import { type SessionMembershipData } from "../matrixrtc/membership/legacy.ts"; export enum EventType { // Room state events @@ -148,6 +150,10 @@ export enum EventType { // Group call events GroupCallPrefix = "org.matrix.msc3401.call", + /** + * Legacy call membership. + * @see RTCMembership + */ GroupCallMemberPrefix = "org.matrix.msc3401.call.member", // MatrixRTC events @@ -155,6 +161,7 @@ export enum EventType { CallNotify = "org.matrix.msc4075.call.notify", RTCNotification = "org.matrix.msc4075.rtc.notification", RTCDecline = "org.matrix.msc4310.rtc.decline", + RTCSlot = "org.matrix.msc4143.rtc.slot", } export enum RelationType { @@ -339,6 +346,7 @@ export interface TimelineEvents { [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; [EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty. + [EventType.RTCSlot]: RtcSlotEventContent; } /** diff --git a/src/matrixrtc/CallApplication.ts b/src/matrixrtc/CallApplication.ts new file mode 100644 index 0000000000..301e2a4d0c --- /dev/null +++ b/src/matrixrtc/CallApplication.ts @@ -0,0 +1,26 @@ +import { type RtcSlotEventContent, type SlotDescription } from "./types.ts"; + +export const DefaultCallApplicationDescription: SlotDescription = { + id: "", + application: "m.call", +}; + +/** + * Matrix RTC Slot event for the "m.call" application type. + */ +export interface CallSlotEventContent extends RtcSlotEventContent<"m.call"> { + application: { + "type": "m.call"; + "m.call.id"?: string; + }; + slot_id: `${string}#${string}`; +} +/** + * Default slot for a room using "m.call". + */ +export const DefaultCallApplicationSlot: CallSlotEventContent = { + application: { + type: "m.call", + }, + slot_id: "m.call#", +}; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ba142ab063..a207283c00 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,14 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { type LivekitFocusSelection } from "./LivekitTransport.ts"; -import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; -import type { RTCCallIntent, Transport } from "./types.ts"; +import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts"; import { type IContent, type MatrixEvent } from "../models/event.ts"; -import { type RelationType } from "../@types/event.ts"; import { logger } from "../logger.ts"; +import { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; +import { checkSessionsMembershipData, type SessionMembershipData } from "./membership/legacy.ts"; +import { checkRtcMembershipData, type RtcMembershipData } from "./membership/rtc.ts"; +import { MatrixRTCMembershipParseError } from "./membership/common.ts"; +import { EventType } from "../@types/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -30,213 +31,23 @@ import { 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; 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; -} - -const checkRtcMembershipData = ( - data: IContent, - errors: string[], - referenceUserId: string, -): data is RtcMembershipData => { - const prefix = " - "; - - // required fields - if (typeof data.slot_id !== "string") { - errors.push(prefix + "slot_id must be string"); - } else { - if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"'); - } - if (typeof data.member !== "object" || data.member === null) { - errors.push(prefix + "member must be an object"); - } else { - if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); - else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); - // This is not what the spec enforces but there currently are no rules what power levels are required to - // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there - // is a proper definition when this is allowed. - else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender"); - if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string"); - if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); - } - if (typeof data.application !== "object" || data.application === null) { - errors.push(prefix + "application must be an object"); - } else { - if (typeof data.application.type !== "string") { - errors.push(prefix + "application.type must be a string"); - } else { - if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"'); - } - } - if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) { - errors.push(prefix + "rtc_transports must be an array"); - } else { - // validate that each transport has at least a string 'type' - for (const t of data.rtc_transports) { - if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") { - errors.push(prefix + "rtc_transports entries must be objects with a string type"); - break; - } - } - } - if (data.versions === undefined || !Array.isArray(data.versions)) { - errors.push(prefix + "versions must be an array"); - } else if (!data.versions.every((v) => typeof v === "string")) { - errors.push(prefix + "versions must be an array of strings"); - } - - // optional fields - if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) { - errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined"); - } - if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { - errors.push(prefix + "sticky_key must be a string"); - } - if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") { - errors.push(prefix + "msc4354_sticky_key must be a string"); - } - if ( - data.sticky_key !== undefined && - data.msc4354_sticky_key !== undefined && - data.sticky_key !== data.msc4354_sticky_key - ) { - errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined"); - } - if (data["m.relates_to"] !== undefined) { - const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; - if (typeof rel !== "object" || rel === null) { - errors.push(prefix + "m.relates_to must be an object if provided"); - } else { - if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string"); - if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference"); - } - } - - return errors.length === 0; -}; - /** - * MSC4143 (MatrixRTC) session membership data. - * Represents the `session` in the memberships section of an m.call.member event as it is on the wire. - **/ -export type SessionMembershipData = { - /** - * The RTC application defines the type of the RTC session. - */ - "application": string; - - /** - * The id of this session. - * A session can never span over multiple rooms so this id is to distinguish between - * multiple session in one room. A room wide session that is not associated with a user, - * and therefore immune to creation race conflicts, uses the `call_id: ""`. - */ - "call_id": string; - - /** - * The Matrix device ID of this session. A single user can have multiple sessions on different devices. - */ - "device_id": string; - - /** - * The focus selection system this user/membership is using. - */ - "focus_active": LivekitFocusSelection; - - /** - * A list of possible foci this user knows about. One of them might be used based on the focus_active - * selection system. - */ - "foci_preferred": Transport[]; - - /** - * Optional field that contains the creation of the session. If it is undefined the creation - * is the `origin_server_ts` of the event itself. For updates to the event this property tracks - * the `origin_server_ts` of the initial join event. - * - If it is undefined it can be interpreted as a "Join". - * - If it is defined it can be interpreted as an "Update" - */ - "created_ts"?: number; - - // Application specific data - - /** - * If the `application` = `"m.call"` this defines if it is a room or user owned call. - * There can always be one room scoped call but multiple user owned calls (breakout sessions) - */ - "scope"?: CallScope; - - /** - * Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid. - * This should be set to multiple hours. The only reason it exist is to deal with failed delayed events. - * (for example caused by a homeserver crashes) - **/ - "expires"?: number; - + * Describes the source event type that provided the membership data. + */ +enum MembershipKind { /** - * The intent of the call from the perspective of this user. This may be an audio call, video call or - * something else. + * The modern MSC4143 format event. */ - "m.call.intent"?: RTCCallIntent; + RTC = "rtc", /** - * The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device. + * The legacy call event type. */ - "msc4354_sticky_key"?: string; -}; - -const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => { - const prefix = " - "; - if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string"); - if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string"); - if (typeof data.application !== "string") errors.push(prefix + "application must be a string"); - if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string"); - if (data.focus_active === undefined) { - errors.push(prefix + "focus_active has an invalid type"); - } - if ( - data.foci_preferred !== undefined && - !( - Array.isArray(data.foci_preferred) && - data.foci_preferred.every( - (f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string", - ) - ) - ) { - errors.push(prefix + "foci_preferred must be an array of transport objects"); - } - // optional parameters - if (data.created_ts !== undefined && typeof data.created_ts !== "number") { - errors.push(prefix + "created_ts must be number"); - } - - // application specific data (we first need to check if they exist) - if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string"); - - if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { - errors.push(prefix + "m.call.intent must be a string"); - } - - return errors.length === 0; -}; + Session = "session", +} -type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; +type MembershipData = + | { kind: MembershipKind.RTC; data: RtcMembershipData } + | { kind: MembershipKind.Session; data: SessionMembershipData }; // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a?: CallMembership, b?: CallMembership): boolean { @@ -256,24 +67,27 @@ export class CallMembership { ) { const eventId = matrixEvent.getId(); const sender = matrixEvent.getSender(); + const evType = matrixEvent.getType(); if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); if (sender === undefined) throw new Error("parentEvent is missing sender field"); - const sessionErrors: string[] = []; - const rtcErrors: string[] = []; - if (checkSessionsMembershipData(data, sessionErrors)) { - this.membershipData = { kind: "session", data }; - } else if (checkRtcMembershipData(data, rtcErrors, sender)) { - this.membershipData = { kind: "rtc", data }; - } else { - const details = - sessionErrors.length < rtcErrors.length - ? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n` - : `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`; - const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'"); - throw Error(`unknown CallMembership data.\n` + details + json); + try { + // Event types are strictly checked here. + if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) { + this.membershipData = { kind: MembershipKind.RTC, data }; + } else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) { + this.membershipData = { kind: MembershipKind.Session, data }; + } else { + throw Error(`'${evType} is not a known call membership type`); + } + } catch (ex) { + if (ex instanceof MatrixRTCMembershipParseError) { + logger.debug("CallMembership.MatrixRTCMembershipParseError provided data", data); + } + throw ex; } + this.matrixEventData = { eventId, sender }; } @@ -281,12 +95,13 @@ export class CallMembership { public get sender(): string { return this.userId; } + public get userId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": - return data.member.user_id; - case "session": + case MembershipKind.RTC: + return data.member.claimed_user_id; + case MembershipKind.Session: default: return this.matrixEventData.sender; } @@ -303,9 +118,9 @@ export class CallMembership { public get slotId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.slot_id; - case "session": + case MembershipKind.Session: default: return slotDescriptionToId({ application: this.application, id: data.call_id }); } @@ -314,66 +129,62 @@ export class CallMembership { public get deviceId(): string { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": - return data.member.device_id; - case "session": + case MembershipKind.RTC: + return data.member.claimed_device_id; + 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; - } - 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; } + logger.warn("RTC membership has invalid m.call.intent"); + return undefined; } /** * Parsed `slot_id` (format `{application}#{id}`) into its components (application and id). */ public get slotDescription(): SlotDescription { + // TODO: Should this use content.application? return slotIdToDescription(this.slotId); } + /** + * The application `type`. + * @deprecated Use @see applicationData + */ public get application(): string { - const { kind, data } = this.membershipData; - switch (kind) { - case "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: + // XXX: This is a hack around 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; } @@ -385,9 +196,9 @@ export class CallMembership { // synapse ignores sending state events if they have the same content. const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.member.id; - case "session": + case MembershipKind.Session: default: return (this.createdTs() ?? "").toString(); } @@ -396,10 +207,10 @@ export class CallMembership { public createdTs(): number { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: // TODO we need to read the referenced (relation) event if available to get the real created_ts return this.matrixEvent.getTs(); - case "session": + case MembershipKind.Session: default: return data.created_ts ?? this.matrixEvent.getTs(); } @@ -412,9 +223,9 @@ export class CallMembership { public getAbsoluteExpiry(): number | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": - return undefined; - case "session": + case MembershipKind.RTC: + return this.matrixEvent.unstableStickyExpiresAt; + case MembershipKind.Session: default: // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); @@ -423,19 +234,14 @@ export class CallMembership { /** * @returns The number of milliseconds until the membership expires or undefined if applicable + * @deprecated Not used by RTC events. */ public getMsUntilExpiry(): number | undefined { - const { kind } = this.membershipData; - switch (kind) { - case "rtc": - return undefined; - case "session": - default: - // Assume that local clock is sufficiently in sync with other clocks in the distributed system. - // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. - // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 - return this.getAbsoluteExpiry()! - Date.now(); - } + const absExpiry = this.getAbsoluteExpiry(); + // Assume that local clock is sufficiently in sync with other clocks in the distributed system. + // We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate. + // The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2 + return absExpiry ? absExpiry - Date.now() : undefined; } /** @@ -444,9 +250,11 @@ export class CallMembership { public isExpired(): boolean { const { kind } = this.membershipData; switch (kind) { - case "rtc": - return false; - case "session": + case MembershipKind.RTC: + return this.matrixEvent.unstableStickyExpiresAt + ? Date.now() > this.matrixEvent.unstableStickyExpiresAt + : false; + case MembershipKind.Session: default: return this.getMsUntilExpiry()! <= 0; } @@ -472,30 +280,19 @@ 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": - 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 MembershipKind.Session: + if (data.focus_active.focus_selection === "oldest_membership") { + // For legacy events we only support "oldest_membership" + if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; + if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); } + break; } return undefined; } - /** - * The focus_active filed of the session membership (m.call.member). - * @deprecated focus_active is not used and will be removed in future versions. - */ - public getFocusActive(): LivekitFocusSelection | undefined { - const { kind, data } = this.membershipData; - if (kind === "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). @@ -503,9 +300,9 @@ export class CallMembership { public get transports(): Transport[] { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": + case MembershipKind.RTC: return data.rtc_transports; - case "session": + case MembershipKind.Session: default: return data.foci_preferred; } diff --git a/src/matrixrtc/LivekitTransport.ts b/src/matrixrtc/LivekitTransport.ts index eda11f554e..d9b1478864 100644 --- a/src/matrixrtc/LivekitTransport.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -30,17 +30,3 @@ export interface LivekitTransport extends LivekitTransportConfig { export const isLivekitTransport = (object: any): object is LivekitTransport => isLivekitTransportConfig(object) && "livekit_alias" in object; - -/** - * @deprecated, this is just needed for the old focus active / focus fields of a call membership. - * Not needed for new implementations. - */ -export interface LivekitFocusSelection extends Transport { - type: "livekit"; - focus_selection: "oldest_membership" | "multi_sfu"; -} -/** - * @deprecated see LivekitFocusSelection - */ -export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => - object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 63bc8adf68..4740dac19c 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Logger, logger as rootLogger } from "../logger.ts"; +import { logger, type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { EventTimeline } from "../models/event-timeline.ts"; +import { Direction, EventTimeline } from "../models/event-timeline.ts"; import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; @@ -24,17 +24,19 @@ import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; -import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; +import { LegacyMembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; -import type { - Statistics, - RTCNotificationType, - Status, - IRTCNotificationContent, - ICallNotifyContent, - RTCCallIntent, - Transport, +import { + type Statistics, + type RTCNotificationType, + type Status, + type IRTCNotificationContent, + type ICallNotifyContent, + type RTCCallIntent, + type Transport, + type RtcSlotEventContent, + type SlotDescription, } from "./types.ts"; import { MembershipManagerEvent, @@ -46,6 +48,9 @@ import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { type MatrixEvent } from "../models/event.ts"; import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; +import { DefaultCallApplicationSlot } from "./CallApplication.ts"; +import { slotDescriptionToId } from "./utils.ts"; +import { type RtcMembershipData } from "./membership/rtc.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; /** @@ -97,21 +102,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. @@ -294,44 +284,57 @@ export class MatrixRTCSession extends TypedEventEmitter< * * It can be undefined since the callId is only known once the first membership joins. * The callId is the property that, per definition, groups memberships into one call. + * + * This may also be undefined if MSC4143 slots are in use, as calls do not always have an ID if they + * have a slot. * @deprecated use `slotId` instead. */ public get callId(): string | undefined { return this.slotDescription?.id; } - /** - * The slotId of the call. - * `{application}#{appSpecificId}` - * It can be undefined since the slotId is only known once the first membership joins. - * The slotId is the property that, per definition, groups memberships into one call. - */ - public get slotId(): string | undefined { - return slotDescriptionToId(this.slotDescription); - } /** - * Returns all the call memberships for a room that match the provided `sessionDescription`, - * oldest first. - * - * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. + * Get a slot for a given room and description + * @param room The room which the slot is scoped to. + * @param slotDescription The description of the slot. The type, ID, and parameters must exactly match. + * @returns The contents of the slot event, or null if no matching slot found. */ - public static callMembershipsForRoom( - room: Pick, - ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForSlot(room, { - id: "", - application: "m.call", - }); - } + public static getRtcSlot( + room: Pick, + slotDescription: SlotDescription, + ): RtcSlotEventContent | null { + const slotId = slotDescriptionToId(slotDescription); + const slot = room.getLiveTimeline().getState(Direction.Forward)?.getStateEvents(EventType.RTCSlot, slotId); + if (!slot) { + logger.debug(`No slot found for ${room.roomId}`); + return null; + } + const slotContent = slot.getContent>(); + if (!slotContent.application || typeof slotContent.application !== "object") { + logger.debug(`Invalid app for ${room.roomId}`); + // Invalid slot content. + return null; + } + if ( + "type" in slotContent.application === false || + slotContent.application.type !== slotDescription.application + ) { + logger.debug(`Mismatched app for ${room.roomId}`); + // Mismached or missing application type. + return null; + } - /** - * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. - */ - public static sessionMembershipsForRoom( - room: Pick, - sessionDescription: SlotDescription, - ): CallMembership[] { - return this.sessionMembershipsForSlot(room, sessionDescription); + if ( + "slot_id" in slotContent === false || + typeof slotContent.slot_id !== "string" || + !slotContent.slot_id.startsWith(slotContent.application.type + "#") + ) { + logger.debug(`Mismatched app for ${room.roomId}`, slotContent); + // Mismached or missing application type. + return null; + } + + return slotContent as RtcSlotEventContent; } /** @@ -350,14 +353,27 @@ export class MatrixRTCSession extends TypedEventEmitter< listenForMemberStateEvents: true, }, ): CallMembership[] { - const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); + const slotId = slotDescriptionToId(slotDescription); + const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId} ${slotId}]`); let callMemberEvents = [] as MatrixEvent[]; - if (listenForStickyEvents) { - // prefill with sticky events - callMemberEvents = [...room._unstable_getStickyEvents()].filter( - (e) => e.getType() === EventType.RTCMembership, - ); - } + // Check that the room has the valid slot. + if (listenForStickyEvents && this.getRtcSlot(room, slotDescription)) { + // Has a slot and the application parameters match, fetch sticky members. + callMemberEvents = [...room._unstable_getStickyEvents()].filter((e) => { + if (e.getType() !== EventType.RTCMembership) { + return false; + } + const content = e.getContent(); + // Ensure the slot ID of the membership matches the state + if (content.slot_id !== slotId) { + return false; + } + if (content.application.type !== slotDescription.application) { + return false; + } + return true; + }); + } // otherwise, the slot wasn't valid and we can skip these members if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { @@ -404,10 +420,9 @@ export class MatrixRTCSession extends TypedEventEmitter< } try { const membership = new CallMembership(memberEvent, membershipData); - if (!deepCompare(membership.slotDescription, slotDescription)) { logger.info( - `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`, + `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)} !== ${JSON.stringify(slotDescription)}`, ); continue; } @@ -453,21 +468,10 @@ export class MatrixRTCSession extends TypedEventEmitter< room: Room, opts?: SessionMembershipsForRoomOpts, ): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForSlot( - room, - { id: "", application: "m.call" }, - opts, - ); - return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); + const sessionDescription = { id: "", application: DefaultCallApplicationSlot.application.type }; + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, sessionDescription, opts); + return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } - - /** - * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. - */ - public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { - return this.sessionForSlot(client, room, slotDescription); - } - /** * Return the MatrixRTC session for the room. * This returned session can be used to find out if there are active sessions @@ -535,7 +539,9 @@ export class MatrixRTCSession extends TypedEventEmitter< public readonly slotDescription: SlotDescription, ) { super(); - this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); + this.logger = rootLogger.getChild( + `[MatrixRTCSession ${roomSubset.roomId} ${slotDescriptionToId(slotDescription)}]`, + ); const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); @@ -602,7 +608,13 @@ export class MatrixRTCSession extends TypedEventEmitter< this.slotDescription, this.logger, ) - : new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); + : new LegacyMembershipManager( + joinConfig, + this.roomSubset, + this.client, + this.slotDescription, + this.logger, + ); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, @@ -701,13 +713,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]; } @@ -814,6 +819,7 @@ export class MatrixRTCSession extends TypedEventEmitter< "lifetime": 30_000, // 30 seconds }; if (callIntent) { + // TODO: Should be in a different field?1 content["m.call.intent"] = callIntent; } const response = await this.client.sendEvent(this.roomSubset.roomId, EventType.RTCNotification, content); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index a103b39db9..f34fece166 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; +import { DefaultCallApplicationDescription } from "./CallApplication.ts"; +import type { SlotDescription } from "./types.ts"; export enum MatrixRTCSessionManagerEvents { // A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously @@ -56,7 +58,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { this.roomSessions.set(room.roomId, session); } @@ -104,7 +106,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter extends TypedEventEmitter implements IMembershipManager { @@ -386,7 +379,7 @@ export class MembershipManager protected memberId: string; protected rtcTransport?: Transport; /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ - private fociPreferred?: Transport[]; + protected fociPreferred?: Transport[]; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -477,15 +470,7 @@ export class MembershipManager } } - // an abstraction to switch between sending state or a sticky event - protected clientSendDelayedDisconnectMembership: () => Promise = () => - this.client._unstable_sendDelayedStateEvent( - this.room.roomId, - { delay: this.delayedLeaveEventDelayMs }, - EventType.GroupCallMemberPrefix, - {}, - this.memberId, - ); + protected abstract clientSendDelayedDisconnectMembership: () => Promise; // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { @@ -673,16 +658,9 @@ export class MembershipManager }); } - protected clientSendMembership: ( - myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, - ) => Promise = (myMembership) => { - return this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - myMembership as EmptyObject | SessionMembershipData, - this.memberId, - ); - }; + protected abstract clientSendMembership: ( + myMembership: MembershipData | EmptyObject, + ) => Promise; private async sendJoinEvent(): Promise { return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) @@ -775,30 +753,7 @@ export class MembershipManager /** * Constructs our own membership */ - protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { - const ownMembership = this.ownMembership; - - const focusObjects = - this.rtcTransport === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { - focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, - foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], - }; - return { - "application": this.slotDescription.application, - "call_id": this.slotDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "m.call.intent": this.callIntent, - ...focusObjects, - ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), - }; - } + protected abstract makeMyMembership(expires: number): MembershipData; // Error checks and handlers @@ -1024,11 +979,62 @@ export class MembershipManager } } +/** + * Handles sending membership for MSC3401 RTC events. + */ +export class LegacyMembershipManager extends MembershipManager { + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ); + + protected makeMyMembership(expires: number): SessionMembershipData { + const ownMembership = this.ownMembership; + + const focusObjects = + this.rtcTransport === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { + focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, + foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], + }; + return { + "application": this.slotDescription.application, + "call_id": this.slotDescription.id ?? "", + "scope": "m.room", + "device_id": this.deviceId, + expires, + "m.call.intent": this.callIntent, + ...focusObjects, + ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), + }; + } + + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership, + this.memberId, + ); + }; +} + /** * Implementation of the Membership manager that uses sticky events * rather than state events. + * + * This exclusively sends RTCMembershipData */ -export class StickyEventMembershipManager extends MembershipManager { +export class StickyEventMembershipManager extends MembershipManager { public constructor( joinConfig: (SessionConfig & MembershipConfig) | undefined, room: Pick, @@ -1040,6 +1046,8 @@ export class StickyEventMembershipManager extends MembershipManager { super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger); } + protected readonly eventType = EventType.RTCMembership; + protected clientSendDelayedDisconnectMembership: () => Promise = () => this.clientWithSticky._unstable_sendStickyDelayedEvent( this.room.roomId, @@ -1050,9 +1058,9 @@ export class StickyEventMembershipManager extends MembershipManager { { msc4354_sticky_key: this.memberId }, ); - protected clientSendMembership: ( - myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, - ) => Promise = (myMembership) => { + protected clientSendMembership: (myMembership: RtcMembershipData | EmptyObject) => Promise = ( + myMembership, + ) => { return this.clientWithSticky._unstable_sendStickyEvent( this.room.roomId, MEMBERSHIP_STICKY_DURATION_MS, @@ -1070,7 +1078,7 @@ export class StickyEventMembershipManager extends MembershipManager { return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); } - protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + protected makeMyMembership(_expires: number): RtcMembershipData { const ownMembership = this.ownMembership; const relationObject = ownMembership?.eventId @@ -1083,7 +1091,7 @@ export class StickyEventMembershipManager extends MembershipManager { }, slot_id: slotDescriptionToId(this.slotDescription), rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], - member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + member: { claimed_device_id: this.deviceId, claimed_user_id: this.client.getUserId()!, id: this.memberId }, versions: [], ...relationObject, }; diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index 9f52bec6f2..2c87667893 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -18,6 +18,8 @@ export * from "./CallMembership.ts"; export * from "./LivekitTransport.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; +export * from "./CallApplication.ts"; +export { slotDescriptionToId, slotIdToDescription } from "./utils.ts"; export type * from "./types.ts"; export { Status } from "./types.ts"; export { MembershipManagerEvent } from "./IMembershipManager.ts"; diff --git a/src/matrixrtc/membership/common.ts b/src/matrixrtc/membership/common.ts new file mode 100644 index 0000000000..81bcd9522f --- /dev/null +++ b/src/matrixrtc/membership/common.ts @@ -0,0 +1,11 @@ +/** + * Thrown when an event does not look valid for use with MatrixRTC. + */ +export class MatrixRTCMembershipParseError extends Error { + public constructor( + public readonly type: string, + public readonly errors: string[], + ) { + super(`Does not match ${type}:\n${errors.join("\n")}`); + } +} diff --git a/src/matrixrtc/membership/legacy.ts b/src/matrixrtc/membership/legacy.ts new file mode 100644 index 0000000000..4f57a1d2da --- /dev/null +++ b/src/matrixrtc/membership/legacy.ts @@ -0,0 +1,121 @@ +import { type IContent } from "../../matrix.ts"; +import { type RTCCallIntent, type Transport } from "../types.ts"; +import { MatrixRTCMembershipParseError } from "./common.ts"; + +/** + * **Legacy** (MatrixRTC) session membership data. + * This represents the *OLD* form of MSC4143. + * Represents the `session` in the memberships section of an m.call.member event as it is on the wire. + **/ +export type SessionMembershipData = { + /** + * The RTC application defines the type of the RTC session. + */ + "application": string; + + /** + * The id of this session. + * A session can never span over multiple rooms so this id is to distinguish between + * multiple session in one room. A room wide session that is not associated with a user, + * and therefore immune to creation race conflicts, uses the `call_id: ""`. + */ + "call_id": string; + + /** + * The Matrix device ID of this session. A single user can have multiple sessions on different devices. + */ + "device_id": string; + + /** + * The focus selection system this user/membership is using. + * NOTE: This is still included for legacy reasons, but not consumed by the SDK. + */ + "focus_active": { + type: string; + focus_selection: "oldest_membership" | string; + }; + + /** + * A list of possible foci this user knows about. One of them might be used based on the focus_active + * 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; +}; + +/** + * 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("bar", errors); + } + + return true; +}; diff --git a/src/matrixrtc/membership/rtc.ts b/src/matrixrtc/membership/rtc.ts new file mode 100644 index 0000000000..a23c23ed3f --- /dev/null +++ b/src/matrixrtc/membership/rtc.ts @@ -0,0 +1,122 @@ +import { MXID_PATTERN } from "../../models/room-member.ts"; +import { IContent } from "../../models/event.ts"; +import { RelationType } from "../../types.ts"; +import { type RtcSlotEventContent, type Transport } from "../types.ts"; +import { MatrixRTCMembershipParseError } from "./common.ts"; + +/** + * Represents the current form of MSC4143. + */ +export interface RtcMembershipData { + "slot_id": string; + "member": { + claimed_user_id: string; + claimed_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 = " - "; + + // 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.claimed_user_id !== "string") { + errors.push(prefix + "member.claimed_user_id must be string"); + } else if (!MXID_PATTERN.test(data.member.claimed_user_id)) { + errors.push(prefix + "member.claimed_user_id must be a valid mxid"); + } + // This is not what the spec enforces but there currently are no rules what power levels are required to + // send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there + // is a proper definition when this is allowed. + else if (data.member.claimed_user_id !== sender) { + errors.push(prefix + "member.claimed_user_id must match the sender"); + } + if (typeof data.member.claimed_device_id !== "string") { + errors.push(prefix + "member.claimed_device_id must be string"); + } + if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string"); + } + if (typeof data.application !== "object" || data.application === null) { + 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("foo", errors); + } + + return true; +}; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index fe4b47b102..1466448b86 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -200,3 +200,20 @@ export interface Transport { type: string; [key: string]: unknown; } + +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 { + id?: string; + application: string; +} diff --git a/src/matrixrtc/utils.ts b/src/matrixrtc/utils.ts index 0d44c5e711..f5ad7194c4 100644 --- a/src/matrixrtc/utils.ts +++ b/src/matrixrtc/utils.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { InboundEncryptionSession, ParticipantId } from "./types.ts"; +import type { InboundEncryptionSession, ParticipantId, SlotDescription } from "./types.ts"; /** * Detects when a key for a given index is outdated. @@ -49,3 +49,12 @@ export class OutdatedKeyFilter { export function getParticipantId(userId: string, deviceId: string): ParticipantId { return `${userId}:${deviceId}`; } + +export function slotIdToDescription(slotId: string): SlotDescription { + const [application, id] = slotId.split("#"); + return { application, id }; +} + +export function slotDescriptionToId(slotDescription: SlotDescription): string { + return `${slotDescription.application}#${slotDescription.id}`; +}