diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index cfa98ebd2bf..95c7140b131 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -19,6 +19,7 @@ import { CallMembership, type SessionMembershipData, DEFAULT_EXPIRE_DURATION, + type RtcMembershipData, } from "../../../src/matrixrtc/CallMembership"; import { membershipTemplate } from "./mocks"; @@ -26,6 +27,7 @@ function makeMockEvent(originTs = 0): MatrixEvent { return { getTs: jest.fn().mockReturnValue(originTs), getSender: jest.fn().mockReturnValue("@alice:example.org"), + getId: jest.fn().mockReturnValue("$eventid"), } as unknown as MatrixEvent; } @@ -40,12 +42,13 @@ describe("CallMembership", () => { }); const membershipTemplate: SessionMembershipData = { - call_id: "", - scope: "m.room", - application: "m.call", - device_id: "AAAAAAA", - focus_active: { type: "livekit" }, - foci_preferred: [{ type: "livekit" }], + "call_id": "", + "scope": "m.room", + "application": "m.call", + "device_id": "AAAAAAA", + "focus_active": { type: "livekit", focus_selection: "oldest_membership" }, + "foci_preferred": [{ type: "livekit" }], + "m.call.intent": "voice", }; it("rejects membership with no device_id", () => { @@ -94,11 +97,271 @@ describe("CallMembership", () => { it("returns preferred foci", () => { const fakeEvent = makeMockEvent(); const mockFocus = { type: "this_is_a_mock_focus" }; - const membership = new CallMembership( - fakeEvent, - Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }), - ); - expect(membership.getPreferredFoci()).toEqual([mockFocus]); + const membership = new CallMembership(fakeEvent, { ...membershipTemplate, foci_preferred: [mockFocus] }); + expect(membership.transports).toEqual([mockFocus]); + }); + + describe("getTransport", () => { + const mockFocus = { type: "this_is_a_mock_focus" }; + const oldestMembership = new CallMembership(makeMockEvent(), membershipTemplate); + it("gets the correct active transport with oldest_membership", () => { + const membership = new CallMembership(makeMockEvent(), { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, + }); + + // if we are the oldest member we use our focus. + expect(membership.getTransport(membership)).toStrictEqual(mockFocus); + + // If there is an older member we use its focus. + expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); + }); + + it("gets the correct active transport with multi_sfu", () => { + const membership = new CallMembership(makeMockEvent(), { + ...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); + + // If there is an older member we still use our own focus in multi sfu. + expect(membership.getTransport(oldestMembership)).toBe(mockFocus); + }); + it("does not provide focus if the selection method is unknown", () => { + const membership = new CallMembership(makeMockEvent(), { + ...membershipTemplate, + foci_preferred: [mockFocus], + focus_active: { type: "livekit", focus_selection: "unknown" }, + }); + + // if we are the oldest member we use our focus. + expect(membership.getTransport(membership)).toBeUndefined(); + }); + }); + describe("correct values from computed fields", () => { + const membership = new CallMembership(makeMockEvent(), membershipTemplate); + it("returns correct sender", () => { + expect(membership.sender).toBe("@alice:example.org"); + }); + it("returns correct eventId", () => { + expect(membership.eventId).toBe("$eventid"); + }); + it("returns correct slot_id", () => { + expect(membership.slotId).toBe("m.call#"); + expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" }); + }); + it("returns correct deviceId", () => { + expect(membership.deviceId).toBe("AAAAAAA"); + }); + it("returns correct call intent", () => { + expect(membership.callIntent).toBe("voice"); + }); + it("returns correct application", () => { + expect(membership.application).toStrictEqual("m.call"); + }); + it("returns correct applicationData", () => { + expect(membership.applicationData).toStrictEqual({ "type": "m.call", "m.call.intent": "voice" }); + }); + it("returns correct scope", () => { + expect(membership.scope).toBe("m.room"); + }); + it("returns correct membershipID", () => { + expect(membership.membershipID).toBe("0"); + }); + it("returns correct unused fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); + expect(membership.isExpired()).toBe(true); + }); + }); + }); + + describe("RtcMembershipData", () => { + 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" }, + rtc_transports: [{ type: "livekit" }], + versions: [], + msc4354_sticky_key: "abc123", + }; + + it("rejects membership with no slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + }).toThrow(); + }); + it("rejects membership with invalid slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" }); + }).toThrow(); + }); + it("accepts membership with valid slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" }); + }).not.toThrow(); + }); + + it("rejects membership with no application", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, application: undefined }); + }).toThrow(); + }); + + it("rejects membership with incorrect application", () => { + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + application: { wrong_type_key: "unknown" }, + }); + }).toThrow(); + }); + + it("rejects membership with no member", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, member: undefined }); + }).toThrow(); + }); + + it("rejects membership with incorrect member", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, member: { i: "test" } }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id_wrong: "test" }, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id_wrong: "test", user_id_wrong: "test" }, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@@test" }, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, + }); + }).toThrow(); + }); + it("rejects membership with incorrect sticky_key", () => { + expect(() => { + new CallMembership(makeMockEvent(), membershipTemplate); + }).not.toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + sticky_key: 1, + msc4354_sticky_key: undefined, + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + sticky_key: "1", + msc4354_sticky_key: undefined, + }); + }).not.toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, msc4354_sticky_key: undefined }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + msc4354_sticky_key: 1, + sticky_key: "valid", + }); + }).toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + msc4354_sticky_key: "valid", + sticky_key: "valid", + }); + }).not.toThrow(); + expect(() => { + new CallMembership(makeMockEvent(), { + ...membershipTemplate, + msc4354_sticky_key: "valid_but_different", + sticky_key: "valid", + }); + }).toThrow(); + }); + + it("considers memberships unexpired if local age low enough", () => { + // TODO link prev event + }); + + it("considers memberships expired if local age large enough", () => { + // TODO link prev event + }); + + describe("getTransport", () => { + it("gets the correct active transport with oldest_membership", () => { + const oldestMembership = new CallMembership(makeMockEvent(), { + ...membershipTemplate, + rtc_transports: [{ type: "oldest_transport" }], + }); + const membership = new CallMembership(makeMockEvent(), membershipTemplate); + + // if we are the oldest member we use our focus. + expect(membership.getTransport(membership)).toStrictEqual({ type: "livekit" }); + + // If there is an older member we use our own focus focus. (RtcMembershipData always uses multi sfu) + expect(membership.getTransport(oldestMembership)).toStrictEqual({ type: "livekit" }); + }); + }); + describe("correct values from computed fields", () => { + const membership = new CallMembership(makeMockEvent(), membershipTemplate); + it("returns correct sender", () => { + expect(membership.sender).toBe("@alice:example.org"); + }); + it("returns correct eventId", () => { + expect(membership.eventId).toBe("$eventid"); + }); + it("returns correct slot_id", () => { + expect(membership.slotId).toBe("m.call#"); + expect(membership.slotDescription).toStrictEqual({ id: "", application: "m.call" }); + }); + it("returns correct deviceId", () => { + expect(membership.deviceId).toBe("AAAAAAA"); + }); + it("returns correct call intent", () => { + expect(membership.callIntent).toBe("voice"); + }); + it("returns correct application", () => { + expect(membership.application).toStrictEqual("m.call"); + }); + it("returns correct applicationData", () => { + expect(membership.applicationData).toStrictEqual({ + "type": "m.call", + "m.call.id": "", + "m.call.intent": "voice", + }); + }); + it("returns correct scope", () => { + expect(membership.scope).toBe(undefined); + }); + it("returns correct membershipID", () => { + expect(membership.membershipID).toBe("xyzHASHxyz"); + }); + it("returns correct unused fields", () => { + expect(membership.getAbsoluteExpiry()).toBe(undefined); + expect(membership.getMsUntilExpiry()).toBe(undefined); + expect(membership.isExpired()).toBe(false); + }); }); }); diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts similarity index 53% rename from spec/unit/matrixrtc/LivekitFocus.spec.ts rename to spec/unit/matrixrtc/LivekitTransport.spec.ts index 728d6a68de6..04f04a1357e 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -14,47 +14,51 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; +import { + isLivekitTransport, + isLivekitFocusSelection, + isLivekitTransportConfig, +} from "../../../src/matrixrtc/LivekitTransport"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { expect( - isLivekitFocus({ + isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", livekit_alias: "test", }), ).toBeTruthy(); - expect(isLivekitFocus({ type: "livekit" })).toBeFalsy(); + expect(isLivekitTransport({ type: "livekit" })).toBeFalsy(); expect( - isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }), + isLivekitTransport({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }), ).toBeFalsy(); expect( - isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }), + isLivekitTransport({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }), ).toBeFalsy(); expect( - isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), + isLivekitTransport({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }), ).toBeFalsy(); }); it("isLivekitFocusActive", () => { expect( - isLivekitFocusActive({ + isLivekitFocusSelection({ type: "livekit", focus_selection: "oldest_membership", }), ).toBeTruthy(); - expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); + expect(isLivekitFocusSelection({ type: "livekit" })).toBeFalsy(); + expect(isLivekitFocusSelection({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy(); }); it("isLivekitFocusConfig", () => { expect( - isLivekitFocusConfig({ + isLivekitTransportConfig({ type: "livekit", livekit_service_url: "http://test.com", }), ).toBeTruthy(); - expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy(); - expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); - expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "livekit" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy(); + expect(isLivekitTransportConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy(); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 70cbe927df0..3e394119a29 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -53,12 +53,12 @@ describe("MatrixRTCSession", () => { sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + 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].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.sessionDescription.id).toEqual(""); + expect(sess?.slotDescription.id).toEqual(""); }); it("ignores memberships where application is not m.call", () => { @@ -268,7 +268,6 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.getActiveFocus()).toBe(firstPreferredFocus); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -288,7 +287,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.getActiveFocus()).toBe(undefined); + expect(sess.memberships.length).toBe(0); }); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index c22ab18390c..1e0e0b5e798 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -27,12 +27,11 @@ import { import { MembershipManagerEvent, Status, - type Focus, - type LivekitFocusActive, + type Transport, type SessionMembershipData, + type LivekitFocusSelection, } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; -import { logger } from "../../../src/logger.ts"; import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; /** @@ -76,11 +75,11 @@ const callSession = { id: "", application: "m.call" }; describe("MembershipManager", () => { let client: MockClient; let room: Room; - const focusActive: LivekitFocusActive = { + const focusActive: LivekitFocusSelection = { focus_selection: "oldest_membership", type: "livekit", }; - const focus: Focus = { + const focus: Transport = { type: "livekit", livekit_service_url: "https://active.url", livekit_alias: "!active:active.url", @@ -104,12 +103,12 @@ describe("MembershipManager", () => { describe("isActivated()", () => { it("defaults to false", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(manager.isActivated()).toEqual(false); }); it("returns true after join()", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); }); @@ -123,8 +122,8 @@ describe("MembershipManager", () => { const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); // Test - const memberManager = new MembershipManager(undefined, room, client, () => undefined, callSession); - memberManager.join([focus], focusActive); + 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( @@ -152,8 +151,45 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); }); + it("sends a rtc membership event when using `useRtcMemberFormat`", async () => { + // Spys/Mocks + + const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + + // Test + const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession); + memberManager.join([], focus); + // expects + await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); + expect(client.sendStateEvent).toHaveBeenCalledWith( + room.roomId, + "org.matrix.msc4143.rtc.member", + { + application: { type: "m.call" }, + member: { + user_id: "@alice:example.org", + id: "_@alice:example.org_AAAAAAA_m.call", + device_id: "AAAAAAA", + }, + slot_id: "m.call#", + rtc_transports: [focus], + versions: [], + }, + "_@alice:example.org_AAAAAAA_m.call", + ); + updateDelayedEventHandle.resolve?.(); + expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( + room.roomId, + { delay: 8000 }, + "org.matrix.msc4143.rtc.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, () => undefined, callSession); + const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); const waitForUpdateDelaye = waitForMockCallOnce( client._unstable_updateDelayedEvent, @@ -228,10 +264,9 @@ describe("MembershipManager", () => { }, room, client, - () => undefined, callSession, ); - manager.join([focus], focusActive); + manager.join([focus]); await sendDelayedStateExceedAttempt.then(); // needed to resolve after the send attempt catches await sendDelayedStateAttempt; @@ -286,8 +321,8 @@ describe("MembershipManager", () => { 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, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); delayedHandle.reject?.( new UnsupportedDelayedEventsEndpointError( "Server does not support the delayed events API", @@ -298,21 +333,15 @@ describe("MembershipManager", () => { }); 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, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, 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 MembershipManager( - { delayedLeaveEventDelayMs: 123456 }, - room, - client, - () => undefined, - callSession, - ); - manager.join([focus], focusActive); + const manager = new MembershipManager({ delayedLeaveEventDelayMs: 123456 }, room, client, callSession); + manager.join([focus]); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 123456 }, @@ -329,11 +358,11 @@ describe("MembershipManager", () => { { delayedLeaveEventRestartMs: RESTART_DELAY }, room, client, - () => undefined, + callSession, ); // Join with the membership manager - manager.join([focus], focusActive); + 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(); @@ -367,11 +396,11 @@ describe("MembershipManager", () => { { membershipEventExpiryMs: 1234567 }, room, client, - () => undefined, + callSession, ); - manager.join([focus], focusActive); + manager.join([focus]); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledWith( room.roomId, @@ -393,11 +422,11 @@ describe("MembershipManager", () => { }); it("does nothing if join called when already joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); await waitForMockCall(client.sendStateEvent); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); - manager.join([focus], focusActive); + manager.join([focus]); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); }); @@ -405,16 +434,16 @@ describe("MembershipManager", () => { describe("leave()", () => { // TODO add rate limit cases. it("resolves delayed leave event when leave is called", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], focusActive); + 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, () => undefined, callSession); - manager.join([focus], focusActive); + const manager = new MembershipManager({}, room, client, callSession); + manager.join([focus]); await jest.advanceTimersByTimeAsync(1); (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); await manager.leave(); @@ -428,60 +457,16 @@ describe("MembershipManager", () => { ); }); it("does nothing if not joined", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + 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(); }); }); - describe("getsActiveFocus", () => { - it("gets the correct active focus with oldest_membership", () => { - const getOldestMembership = jest.fn(); - const manager = new MembershipManager({}, room, client, getOldestMembership, callSession); - // Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession) - expect(manager.getActiveFocus()).toBe(undefined); - manager.join([focus], focusActive); - // After joining we want our own focus to be the one we select. - getOldestMembership.mockReturnValue( - mockCallMembership( - { - ...membershipTemplate, - foci_preferred: [ - { - livekit_alias: "!active:active.url", - livekit_service_url: "https://active.url", - type: "livekit", - }, - ], - user_id: client.getUserId()!, - device_id: client.getDeviceId()!, - created_ts: 1000, - }, - room.roomId, - ), - ); - expect(manager.getActiveFocus()).toStrictEqual(focus); - getOldestMembership.mockReturnValue( - mockCallMembership( - Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }), - room.roomId, - ), - ); - // If there is an older member we use its focus. - expect(manager.getActiveFocus()).toBe(membershipTemplate.foci_preferred[0]); - }); - - it("does not provide focus if the selection method is unknown", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], Object.assign(focusActive, { type: "unknown_type" })); - expect(manager.getActiveFocus()).toBe(undefined); - }); - }); - describe("onRTCSessionMemberUpdate()", () => { it("does nothing if not joined", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]); await jest.advanceTimersToNextTimerAsync(); expect(client.sendStateEvent).not.toHaveBeenCalled(); @@ -489,7 +474,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + 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]; @@ -513,7 +498,7 @@ describe("MembershipManager", () => { expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + 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` @@ -531,7 +516,7 @@ describe("MembershipManager", () => { }); it("updates the UpdateExpiry entry in the action scheduler", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + 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` @@ -564,7 +549,6 @@ describe("MembershipManager", () => { { delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 }, room, client, - () => undefined, { id: "", application: "m.call" }, ); manager.join([focus], focusActive); @@ -596,7 +580,7 @@ describe("MembershipManager", () => { { membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom }, room, client, - () => undefined, + { id: "", application: "m.call" }, ); manager.join([focus], focusActive); @@ -621,14 +605,14 @@ describe("MembershipManager", () => { describe("status updates", () => { it("starts 'Disconnected'", () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + 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, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); expect(manager.status).toBe(Status.Disconnected); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); @@ -642,7 +626,7 @@ describe("MembershipManager", () => { expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected); }); it("emits 'Disconnecting' and 'Disconnected' after leave", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); const connectEmit = jest.fn(); manager.on(MembershipManagerEvent.StatusChanged, connectEmit); manager.join([focus], focusActive); @@ -658,7 +642,7 @@ describe("MembershipManager", () => { 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, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); @@ -685,7 +669,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); // Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the // RateLimit error. manager.join([focus], focusActive); @@ -705,7 +689,7 @@ describe("MembershipManager", () => { 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, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); handle.reject?.( new MatrixError( @@ -740,7 +724,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive); // Hit rate limit @@ -773,7 +757,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "2" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, delayEventSendError); for (let i = 0; i < 10; i++) { @@ -793,7 +777,7 @@ describe("MembershipManager", () => { new Headers({ "Retry-After": "1" }), ), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, delayEventRestartError); for (let i = 0; i < 10; i++) { @@ -804,7 +788,7 @@ describe("MembershipManager", () => { 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, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, unrecoverableError); await waitForMockCall(client.sendStateEvent); expect(unrecoverableError).not.toHaveBeenCalledWith(); @@ -817,7 +801,6 @@ describe("MembershipManager", () => { { networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 }, room, client, - () => undefined, callSession, ); manager.join([focus], focusActive, unrecoverableError); @@ -836,7 +819,7 @@ describe("MembershipManager", () => { (client._unstable_sendDelayedStateEvent as Mock).mockRejectedValue( new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"), ); - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([focus], focusActive, unrecoverableError); await jest.advanceTimersByTimeAsync(1); @@ -850,7 +833,7 @@ describe("MembershipManager", () => { { delayedLeaveEventDelayMs: 10000 }, room, client, - () => undefined, + callSession, ); const { promise: stuckPromise, reject: rejectStuckPromise } = Promise.withResolvers(); @@ -904,7 +887,7 @@ describe("MembershipManager", () => { describe("updateCallIntent()", () => { it("should fail if the user has not joined the call", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); // After joining we want our own focus to be the one we select. try { await manager.updateCallIntent("video"); @@ -913,7 +896,7 @@ describe("MembershipManager", () => { }); it("can adjust the intent", async () => { - const manager = new MembershipManager({}, room, client, () => undefined, callSession); + const manager = new MembershipManager({}, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId); @@ -926,7 +909,7 @@ describe("MembershipManager", () => { }); it("does nothing if the intent doesn't change", async () => { - const manager = new MembershipManager({ callIntent: "video" }, room, client, () => undefined, callSession); + const manager = new MembershipManager({ callIntent: "video" }, room, client, callSession); manager.join([]); expect(manager.isActivated()).toEqual(true); const membership = mockCallMembership( @@ -944,7 +927,7 @@ it("Should prefix log with MembershipManager used", () => { const client = makeMockClient("@alice:example.org", "AAAAAAA"); const room = makeMockRoom([membershipTemplate]); - const membershipManager = new MembershipManager(undefined, room, client, () => undefined, callSession, logger); + const membershipManager = new MembershipManager(undefined, room, client, callSession); const spy = jest.spyOn(console, "error"); // Double join diff --git a/src/@types/event.ts b/src/@types/event.ts index 6e4d0ddff19..1364d9ca75f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -58,7 +58,7 @@ import { type ICallNotifyContent, } from "../matrixrtc/types.ts"; import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts"; -import { type SessionMembershipData } from "../matrixrtc/CallMembership.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"; @@ -151,6 +151,7 @@ export enum EventType { GroupCallMemberPrefix = "org.matrix.msc3401.call.member", // MatrixRTC events + RTCMembership = "org.matrix.msc4143.rtc.member", CallNotify = "org.matrix.msc4075.call.notify", RTCNotification = "org.matrix.msc4075.rtc.notification", RTCDecline = "org.matrix.msc4310.rtc.decline", @@ -369,7 +370,7 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; - + [EventType.RTCMembership]: RtcMembershipData | EmptyObject; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 00e42baa17a..59ff3778e7a 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type MatrixEvent } from "../matrix.ts"; +import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { type Focus } from "./focus.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; -import { type SessionDescription } from "./MatrixRTCSession.ts"; -import { type RTCCallIntent } from "./types.ts"; +import { type LivekitFocusSelection } from "./LivekitTransport.ts"; +import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; +import type { RTCCallIntent, Transport } from "./types.ts"; +import { type IContent, type MatrixEvent } from "../models/event.ts"; +import { type RelationType } from "../@types/event.ts"; +import { logger } from "../logger.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -29,6 +31,106 @@ import { type RTCCallIntent } from "./types.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. @@ -56,13 +158,13 @@ export type SessionMembershipData = { /** * The focus selection system this user/membership is using. */ - "focus_active": Focus; + "focus_active": LivekitFocusSelection; /** - * A list of possible foci this uses knows about. One of them might be used based on the focus_active + * A list of possible foci this user knows about. One of them might be used based on the focus_active * selection system. */ - "foci_preferred": Focus[]; + "foci_preferred": Transport[]; /** * Optional field that contains the creation of the session. If it is undefined the creation @@ -77,7 +179,7 @@ export type SessionMembershipData = { /** * If the `application` = `"m.call"` this defines if it is a room or user owned call. - * There can always be one room scroped call but multiple user owned calls (breakout sessions) + * There can always be one room scoped call but multiple user owned calls (breakout sessions) */ "scope"?: CallScope; @@ -95,16 +197,26 @@ export type SessionMembershipData = { "m.call.intent"?: RTCCallIntent; }; -const checkSessionsMembershipData = ( - data: Partial>, - errors: string[], -): data is SessionMembershipData => { - const prefix = "Malformed session membership event: "; +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 (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); + 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"); @@ -120,109 +232,278 @@ const checkSessionsMembershipData = ( return errors.length === 0; }; +type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; +// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { - public static equal(a: CallMembership, b: CallMembership): boolean { - return deepCompare(a.membershipData, b.membershipData); + public static equal(a?: CallMembership, b?: CallMembership): boolean { + return deepCompare(a?.membershipData, b?.membershipData); } - private membershipData: SessionMembershipData; + private membershipData: MembershipData; + + /** The parsed data from the Matrix event. + * To access checked eventId and sender from the matrixEvent. + * Class construction will fail if these values cannot get obtained. */ + private readonly matrixEventData: { eventId: string; sender: string }; public constructor( - private parentEvent: MatrixEvent, - data: any, + /** The Matrix event that this membership is based on */ + private readonly matrixEvent: MatrixEvent, + data: IContent, ) { + const eventId = matrixEvent.getId(); + const sender = matrixEvent.getSender(); + + if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); + if (sender === undefined) throw new Error("parentEvent is missing sender field"); + const sessionErrors: string[] = []; - if (!checkSessionsMembershipData(data, sessionErrors)) { - throw Error( - `unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`, - ); + const rtcErrors: string[] = []; + if (checkSessionsMembershipData(data, sessionErrors)) { + this.membershipData = { kind: "session", data }; + } else if (checkRtcMembershipData(data, rtcErrors, sender)) { + this.membershipData = { kind: "rtc", data }; } else { - this.membershipData = data; + 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); } + this.matrixEventData = { eventId, sender }; } - public get sender(): string | undefined { - return this.parentEvent.getSender(); + /** @deprecated use userId instead */ + 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": + default: + return this.matrixEventData.sender; + } } - public get eventId(): string | undefined { - return this.parentEvent.getId(); + public get eventId(): string { + return this.matrixEventData.eventId; } /** - * @deprecated Use sessionDescription.id instead. + * The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`). + * This is computed in case SessionMembershipData is used. */ - public get callId(): string { - return this.membershipData.call_id; + public get slotId(): string { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.slot_id; + case "session": + default: + return slotDescriptionToId({ application: this.application, id: data.call_id }); + } } public get deviceId(): string { - return this.membershipData.device_id; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.device_id; + case "session": + default: + return data.device_id; + } } public get callIntent(): RTCCallIntent | undefined { - return this.membershipData["m.call.intent"]; + 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"]; + } } - public get sessionDescription(): SessionDescription { - return { - application: this.membershipData.application, - id: this.membershipData.call_id, - }; + /** + * Parsed `slot_id` (format `{application}#{id}`) into its components (application and id). + */ + public get slotDescription(): SlotDescription { + return slotIdToDescription(this.slotId); } - public get application(): string | undefined { - return this.membershipData.application; + public get application(): string { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application.type; + case "session": + default: + return data.application; + } + } + public get applicationData(): { type: string; [key: string]: unknown } { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application; + case "session": + default: + return { "type": data.application, "m.call.intent": data["m.call.intent"] }; + } } + /** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/ public get scope(): CallScope | undefined { - return this.membershipData.scope; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + default: + return data.scope; + } } public get membershipID(): string { // the createdTs behaves equivalent to the membershipID. - // we only need the field for the legacy member envents where we needed to update them + // we only need the field for the legacy member events where we needed to update them // synapse ignores sending state events if they have the same content. - return this.createdTs().toString(); + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.id; + case "session": + default: + return (this.createdTs() ?? "").toString(); + } } public createdTs(): number { - return this.membershipData.created_ts ?? this.parentEvent.getTs(); + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + // TODO we need to read the referenced (relation) event if available to get the real created_ts + return this.matrixEvent.getTs(); + case "session": + default: + return data.created_ts ?? this.matrixEvent.getTs(); + } } /** * Gets the absolute expiry timestamp of the membership. * @returns The absolute expiry time of the membership as a unix timestamp in milliseconds or undefined if not applicable */ - public getAbsoluteExpiry(): number { - // TODO: calculate this from the MatrixRTCSession join configuration directly - return this.createdTs() + (this.membershipData.expires ?? DEFAULT_EXPIRE_DURATION); + public getAbsoluteExpiry(): number | undefined { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + default: + // TODO: calculate this from the MatrixRTCSession join configuration directly + return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); + } } /** * @returns The number of milliseconds until the membership expires or undefined if applicable */ - public getMsUntilExpiry(): number { - // 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(); + 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(); + } } /** * @returns true if the membership has expired, otherwise false */ public isExpired(): boolean { - return this.getMsUntilExpiry() <= 0; + const { kind } = this.membershipData; + switch (kind) { + case "rtc": + return false; + case "session": + default: + return this.getMsUntilExpiry()! <= 0; + } } - public getPreferredFoci(): Focus[] { - return this.membershipData.foci_preferred; + /** + * ## RTC Membership + * Gets the primary transport to use for this RTC membership (m.rtc.member). + * This will return the primary transport that is used by this call membership to publish their media. + * Directly relates to the `rtc_transports` field. + * + * ## Legacy session membership + * In case of a legacy session membership (m.call.member) this will return the selected transport where + * media is published. How this selection happens depends on the `focus_active` field of the session membership. + * If the `focus_selection` is `oldest_membership` this will return the transport of the oldest membership + * in the room (based on the `created_ts` field of the session membership). + * If the `focus_selection` is `multi_sfu` it will return the first transport of the `foci_preferred` list. + * (`multi_sfu` is equivalent to how `m.rtc.member` `rtc_transports` work). + * @param oldestMembership For backwards compatibility with session membership (legacy). Unused in case of RTC membership. + * Always required to make the consumer not care if it deals with RTC or session memberships. + * @returns The transport this membership uses to publish media or undefined if no transport is available. + */ + public getTransport(oldestMembership: CallMembership): Transport | undefined { + const { kind, data } = this.membershipData; + switch (kind) { + case "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; + } + } + return undefined; } - public getFocusSelection(): string | undefined { - const focusActive = this.membershipData.focus_active; - if (isLivekitFocusActive(focusActive)) { - return focusActive.focus_selection; + /** + * 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). + */ + public get transports(): Transport[] { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.rtc_transports; + case "session": + default: + return data.foci_preferred; } } } diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index 320654543d9..7826fa9d16e 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -15,8 +15,7 @@ limitations under the License. */ import type { CallMembership } from "./CallMembership.ts"; -import type { Focus } from "./focus.ts"; -import type { RTCCallIntent, Status } from "./types.ts"; +import type { RTCCallIntent, Status, Transport } from "./types.ts"; import { type TypedEventEmitter } from "../models/typed-event-emitter.ts"; export enum MembershipManagerEvent { @@ -80,10 +79,13 @@ export interface IMembershipManager /** * Start sending all necessary events to make this user participate in the RTC session. * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. - * @param fociActive the active focus to use in the joined RTC membership event. + * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously. + * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the + * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` + * transport selection will be used instead. * @throws can throw if it exceeds a configured maximum retry. */ - join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void; + join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void; /** * Send all necessary events to make this user leave the RTC session. * @param timeout the maximum duration in ms until the promise is forced to resolve. @@ -95,11 +97,6 @@ export interface IMembershipManager * Call this if the MatrixRTC session members have changed. */ onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; - /** - * The used active focus in the currently joined session. - * @returns the used active focus in the currently joined session or undefined if not joined. - */ - getActiveFocus(): Focus | undefined; /** * Update the intent of a membership on the call (e.g. user is now providing a video feed) diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitFocus.ts deleted file mode 100644 index 66d8a0a50be..00000000000 --- a/src/matrixrtc/LivekitFocus.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2023 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { type Focus } from "./focus.ts"; - -export interface LivekitFocusConfig extends Focus { - type: "livekit"; - livekit_service_url: string; -} - -export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig => - object.type === "livekit" && "livekit_service_url" in object; - -export interface LivekitFocus extends LivekitFocusConfig { - livekit_alias: string; -} - -export const isLivekitFocus = (object: any): object is LivekitFocus => - isLivekitFocusConfig(object) && "livekit_alias" in object; - -export interface LivekitFocusActive extends Focus { - type: "livekit"; - focus_selection: "oldest_membership"; -} -export const isLivekitFocusActive = (object: any): object is LivekitFocusActive => - object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/LivekitTransport.ts b/src/matrixrtc/LivekitTransport.ts new file mode 100644 index 00000000000..eda11f554e5 --- /dev/null +++ b/src/matrixrtc/LivekitTransport.ts @@ -0,0 +1,46 @@ +/* +Copyright 2025 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { type Transport } from "./types.ts"; + +export interface LivekitTransportConfig extends Transport { + type: "livekit"; + livekit_service_url: string; +} + +export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig => + object.type === "livekit" && "livekit_service_url" in object; + +export interface LivekitTransport extends LivekitTransportConfig { + livekit_alias: string; +} + +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 020f768558d..9a61a7238b4 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,17 +24,17 @@ 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 { type Focus } from "./focus.ts"; import { MembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; -import { - type Statistics, - type RTCNotificationType, - type Status, - type IRTCNotificationContent, - type ICallNotifyContent, - type RTCCallIntent, +import type { + Statistics, + RTCNotificationType, + Status, + IRTCNotificationContent, + ICallNotifyContent, + RTCCallIntent, + Transport, } from "./types.ts"; import { RoomKeyTransport } from "./RoomKeyTransport.ts"; import { @@ -103,10 +103,17 @@ export interface SessionConfig { /** * The session description is used to identify a session. Used in the state event. */ -export interface SessionDescription { +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. @@ -185,6 +192,7 @@ export interface MembershipConfig { * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; + useRtcMemberFormat?: boolean; } export interface EncryptionConfig { @@ -240,8 +248,6 @@ export class MatrixRTCSession extends TypedEventEmitter< > { private membershipManager?: IMembershipManager; private encryptionManager?: IEncryptionManager; - // The session Id of the call, this is the call_id of the call Member event. - private _callId: string | undefined; private joinConfig?: SessionConfig; private logger: Logger; @@ -279,33 +285,53 @@ 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. + * @deprecated use `slotId` instead. */ public get callId(): string | undefined { - return this._callId; + 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.sessionMembershipsForRoom` instead. + * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ public static callMembershipsForRoom( room: Pick, ): CallMembership[] { - return MatrixRTCSession.sessionMembershipsForRoom(room, { + return MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call", }); } + /** + * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. + */ + public static sessionMembershipsForRoom( + room: Pick, + sessionDescription: SlotDescription, + ): CallMembership[] { + return this.sessionMembershipsForSlot(room, sessionDescription); + } + /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. */ - public static sessionMembershipsForRoom( + public static sessionMembershipsForSlot( room: Pick, - sessionDescription: SessionDescription, + slotDescription: SlotDescription, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -335,12 +361,16 @@ export class MatrixRTCSession extends TypedEventEmitter< if (membershipContents.length === 0) continue; for (const membershipData of membershipContents) { + if (!("application" in membershipData)) { + // This is a left membership event, ignore it here to not log warnings. + continue; + } try { const membership = new CallMembership(memberEvent, membershipData); - if (!deepCompare(membership.sessionDescription, sessionDescription)) { + if (!deepCompare(membership.slotDescription, slotDescription)) { logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.sessionDescription)}`, + `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`, ); continue; } @@ -379,26 +409,29 @@ export class MatrixRTCSession extends TypedEventEmitter< * This method is an alias for `MatrixRTCSession.sessionForRoom` with * sessionDescription `{ id: "", application: "m.call" }`. * - * @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead. + * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" }); + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, { id: "", application: "m.call" }); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } + /** + * @deprecated Use `MatrixRTCSession.sessionForSlot` instead. + */ + public static sessionForRoom(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + return this.sessionForSlot(client, room, slotDescription); + } + /** * Return the MatrixRTC session for the room. * This returned session can be used to find out if there are active sessions - * for the requested room and `sessionDescription`. + * for the requested room and `slotDescription`. */ - public static sessionForRoom( - client: MatrixClient, - room: Room, - sessionDescription: SessionDescription, - ): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + public static sessionForSlot(client: MatrixClient, room: Room, slotDescription: SlotDescription): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForSlot(room, slotDescription); - return new MatrixRTCSession(client, room, callMemberships, sessionDescription); + return new MatrixRTCSession(client, room, callMemberships, slotDescription); } /** @@ -444,14 +477,14 @@ export class MatrixRTCSession extends TypedEventEmitter< >, public memberships: CallMembership[], /** - * The session description is used to define the exact session this object is tracking. - * A session is distinct from another session if one of those properties differ: `roomSubset.roomId`, `sessionDescription.application`, `sessionDescription.id`. + * The slot description is a virtual address where participants are allowed to meet. + * This session will only manage memberships that match this slot description. + * Sessions are distinct if any of those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`. */ - public readonly sessionDescription: SessionDescription, + public readonly slotDescription: SlotDescription, ) { super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); - this._callId = memberships[0]?.sessionDescription.id; 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); @@ -490,14 +523,18 @@ export class MatrixRTCSession extends TypedEventEmitter< * This will not subscribe to updates: remember to call subscribe() separately if * desired. * This method will return immediately and the session will be joined in the background. - * - * @param fociActive - The object representing the active focus. (This depends on the focus type.) - * @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to. - * For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session, - * or optionally other room members homeserver well known. + * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. + * If multiSfuFocus is set, this is only needed if this client wants to publish to multiple transports simultaneously. + * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the + * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` + * transport selection will be used instead. * @param joinConfig - Additional configuration for the joined session. */ - public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void { + public joinRoomSession( + fociPreferred: Transport[], + multiSfuFocus?: Transport, + joinConfig?: JoinSessionConfig, + ): void { if (this.isJoined()) { this.logger.info(`Already joined to session in room ${this.roomSubset.roomId}: ignoring join call`); return; @@ -508,8 +545,7 @@ export class MatrixRTCSession extends TypedEventEmitter< joinConfig, this.roomSubset, this.client, - () => this.getOldestMembership(), - this.sessionDescription, + this.slotDescription, this.logger, ); @@ -571,7 +607,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.pendingNotificationToSend = this.joinConfig?.notificationType; // Join! - this.membershipManager!.join(fociPreferred, fociActive, (e) => { + this.membershipManager!.join(fociPreferred, multiSfuFocus, (e) => { this.logger.error("MembershipManager encountered an unrecoverable error: ", e); this.emit(MatrixRTCSessionEvent.MembershipManagerError, e); this.emit(MatrixRTCSessionEvent.JoinStateChanged, this.isJoined()); @@ -606,16 +642,23 @@ export class MatrixRTCSession extends TypedEventEmitter< return await leavePromise; } - /** - * Get the active focus from the current CallMemberState event - * @returns The focus that is currently in use to connect to this session. This is undefined - * if the client is not connected to this session. + * This returns the focus in use by the oldest membership. + * Do not use since this might be just the focus for the oldest membership. others might use a different focus. + * @deprecated use `member.getTransport(session.getOldestMembership())` instead for the specific member you want to get the focus for. */ - public getActiveFocus(): Focus | undefined { - return this.membershipManager?.getActiveFocus(); + public getFocusInUse(): Transport | undefined { + const oldestMembership = this.getOldestMembership(); + 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]; } @@ -646,20 +689,6 @@ export class MatrixRTCSession extends TypedEventEmitter< await this.membershipManager?.updateCallIntent(callIntent); } - /** - * This method is used when the user is not yet connected to the Session but wants to know what focus - * the users in the session are using to make a decision how it wants/should connect. - * - * See also `getActiveFocus` - * @returns The focus which should be used when joining this session. - */ - public getFocusInUse(): Focus | undefined { - const oldestMembership = this.getOldestMembership(); - if (oldestMembership?.getFocusSelection() === "oldest_membership") { - return oldestMembership.getPreferredFoci()[0]; - } - } - /** * Re-emit an EncryptionKeyChanged event for each tracked encryption key. This can be used to export * the keys. @@ -777,9 +806,7 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription); - - this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id; + this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); const changed = oldMemberships.length != this.memberships.length || diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index cc25105d977..792132d273e 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -20,7 +20,7 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; -import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts"; +import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; export enum MatrixRTCSessionManagerEvents { @@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0) { this.roomSessions.set(room.roomId, session); } @@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter void): void { + public join(fociPreferred: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void { if (this.scheduler.running) { this.logger.error("MembershipManager is already running. Ignoring join request."); return; } this.fociPreferred = fociPreferred; - this.focusActive = focusActive; + this.rtcTransport = multiSfuFocus; this.leavePromiseResolvers = undefined; this.activated = true; this.oldStatus = this.status; @@ -266,25 +276,6 @@ export class MembershipManager return Promise.resolve(); } - public getActiveFocus(): Focus | undefined { - if (this.focusActive) { - // A livekit active focus - if (isLivekitFocusActive(this.focusActive)) { - if (this.focusActive.focus_selection === "oldest_membership") { - const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; - } - } else { - this.logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU."); - } - } else { - // We do not understand the membership format (could be legacy). We default to oldestMembership - // Once there are other methods this is a hard error! - const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; - } - } - public async updateCallIntent(callIntent: RTCCallIntent): Promise { if (!this.activated || !this.ownMembership) { throw Error("You cannot update your intent before joining the call"); @@ -302,7 +293,6 @@ export class MembershipManager * @param joinConfig * @param room * @param client - * @param getOldestMembership */ public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, @@ -315,8 +305,7 @@ export class MembershipManager | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" >, - private getOldestMembership: () => CallMembership | undefined, - public readonly sessionDescription: SessionDescription, + public readonly slotDescription: SlotDescription, parentLogger?: Logger, ) { super(); @@ -325,7 +314,9 @@ export class MembershipManager if (userId === null) throw Error("Missing userId in client"); if (deviceId === null) throw Error("Missing deviceId in client"); this.deviceId = deviceId; - this.stateKey = this.makeMembershipStateKey(userId, deviceId); + // this needs to become a uuid so that consecutive join/leaves result in a key rotation. + // we keep it as a string for now for backwards compatibility. + this.memberId = this.makeMembershipStateKey(userId, deviceId); this.state = MembershipManager.defaultState; this.callIntent = joinConfig?.callIntent; this.scheduler = new ActionScheduler((type): Promise => { @@ -371,9 +362,10 @@ export class MembershipManager } // Membership Event static parameters: private deviceId: string; - private stateKey: string; - private fociPreferred?: Focus[]; - private focusActive?: Focus; + private memberId: string; + /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ + private fociPreferred?: Transport[]; + private rtcTransport?: Transport; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -406,6 +398,9 @@ export class MembershipManager private get delayedLeaveEventRestartLocalTimeoutMs(): number { return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } + private get useRtcMemberFormat(): boolean { + return this.joinConfig?.useRtcMemberFormat ?? false; + } // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -472,9 +467,9 @@ export class MembershipManager { delay: this.delayedLeaveEventDelayMs, }, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, {}, // leave event - this.stateKey, + this.memberId, ) .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; @@ -659,9 +654,9 @@ export class MembershipManager return await this.client .sendStateEvent( this.room.roomId, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs), - this.stateKey, + this.memberId, ) .then(() => { this.setAndEmitProbablyLeft(false); @@ -705,9 +700,9 @@ export class MembershipManager return await this.client .sendStateEvent( this.room.roomId, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.stateKey, + this.memberId, ) .then(() => { // Success, we reset retries and schedule update. @@ -731,7 +726,12 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey) + .sendStateEvent( + this.room.roomId, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -746,7 +746,7 @@ export class MembershipManager // HELPERS private makeMembershipStateKey(localUserId: string, localDeviceId: string): string { - const stateKey = `${localUserId}_${localDeviceId}_${this.sessionDescription.application}${this.sessionDescription.id}`; + const stateKey = `${localUserId}_${localDeviceId}_${this.slotDescription.application}${this.slotDescription.id}`; if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) { return stateKey; } else { @@ -757,20 +757,45 @@ export class MembershipManager /** * Constructs our own membership */ - private makeMyMembership(expires: number): SessionMembershipData { - const hasPreviousEvent = !!this.ownMembership; - return { - // TODO: use the new format for m.rtc.member events where call_id becomes session.id - "application": this.sessionDescription.application, - "call_id": this.sessionDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "focus_active": { type: "livekit", focus_selection: "oldest_membership" }, - "foci_preferred": this.fociPreferred ?? [], - "m.call.intent": this.callIntent, - ...(hasPreviousEvent ? { created_ts: this.ownMembership?.createdTs() } : undefined), - }; + private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + const ownMembership = this.ownMembership; + if (this.useRtcMemberFormat) { + const relationObject = ownMembership?.eventId + ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } + : {}; + return { + application: { + type: this.slotDescription.application, + ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), + }, + slot_id: slotDescriptionToId(this.slotDescription), + rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], + member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + versions: [], + ...relationObject, + }; + } else { + 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), + }; + } } // Error checks and handlers diff --git a/src/matrixrtc/focus.ts b/src/matrixrtc/focus.ts deleted file mode 100644 index cf9836dd450..00000000000 --- a/src/matrixrtc/focus.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* -Copyright 2023 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Information about a MatrixRTC conference focus. The only attribute that - * the js-sdk (currently) knows about is the type: applications can extend - * this class for different types of focus. - */ -export interface Focus { - type: string; - [key: string]: unknown; -} diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index 40ab6919f5b..9f52bec6f27 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -15,8 +15,7 @@ limitations under the License. */ export * from "./CallMembership.ts"; -export type * from "./focus.ts"; -export * from "./LivekitFocus.ts"; +export * from "./LivekitTransport.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; export type * from "./types.ts"; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index b344a22d8b4..08c32a20628 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -156,3 +156,11 @@ export type Statistics = { export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean => m.sender === userId && m.deviceId === deviceId; + +/** + * A RTC transport is a JSON object that describes how to connect to a RTC member. + */ +export interface Transport { + type: string; + [key: string]: unknown; +} diff --git a/src/models/room-member.ts b/src/models/room-member.ts index 6cf702e8aa3..afe72d4ef51 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter