From 41a2f477d5977c1d7d9b359839f941166caccb4e Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 19 Nov 2024 09:55:12 -0500 Subject: [PATCH 01/31] WIP --- spec/unit/matrixrtc/LivekitFocus.spec.ts | 8 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 4 +- src/matrixrtc/CallMembership.ts | 12 +-- src/matrixrtc/IMembershipManager.ts | 5 +- src/matrixrtc/LivekitFocus.ts | 4 +- src/matrixrtc/MatrixRTCSession.ts | 18 +--- src/matrixrtc/MembershipManager.ts | 98 +++++--------------- 7 files changed, 40 insertions(+), 109 deletions(-) diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitFocus.spec.ts index 728d6a68de6..2653511638b 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitFocus.spec.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; +import { isLivekitFocus, isLivekitFocusSelection, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { @@ -38,13 +38,13 @@ describe("LivekitFocus", () => { }); 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( diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 7a33c3017c2..e47189b6033 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -247,7 +247,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.getActiveFocus()).toBe(firstPreferredFocus); + expect(sess.resolveActiveFocus()).toBe(firstPreferredFocus); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -267,7 +267,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.getActiveFocus()).toBe(undefined); + expect(sess.resolveActiveFocus()).toBe(undefined); }); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index b3f50f02867..8bd894045c6 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -17,7 +17,6 @@ limitations under the License. import { type MatrixEvent } from "../matrix.ts"; import { deepCompare } from "../utils.ts"; import { type Focus } from "./focus.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { type SessionDescription } from "./MatrixRTCSession.ts"; /** @@ -61,7 +60,7 @@ export type SessionMembershipData = { * A list of possible foci this uses knows about. One of them might be used based on the focus_active * selection system. */ - foci_preferred: Focus[]; + foci_preferred?: Focus[]; /** * Optional field that contains the creation of the session. If it is undefined the creation @@ -195,13 +194,10 @@ export class CallMembership { } public getPreferredFoci(): Focus[] { - return this.membershipData.foci_preferred; + return this.membershipData.foci_preferred ?? []; } - public getFocusSelection(): string | undefined { - const focusActive = this.membershipData.focus_active; - if (isLivekitFocusActive(focusActive)) { - return focusActive.focus_selection; - } + public getFocusActive(): Focus { + return this.membershipData.focus_active; } } diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index b1702ef8e33..b99c9a78638 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -96,8 +96,7 @@ export interface IMembershipManager */ 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. + * Determines the active focus used by the given session member, or undefined if not joined. */ - getActiveFocus(): Focus | undefined; + resolveActiveFocus(member: CallMembership): Focus | undefined; } diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitFocus.ts index 66d8a0a50be..a799a0b7b0b 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitFocus.ts @@ -31,9 +31,9 @@ export interface LivekitFocus extends LivekitFocusConfig { export const isLivekitFocus = (object: any): object is LivekitFocus => isLivekitFocusConfig(object) && "livekit_alias" in object; -export interface LivekitFocusActive extends Focus { +export interface LivekitFocusSelection extends Focus { type: "livekit"; focus_selection: "oldest_membership"; } -export const isLivekitFocusActive = (object: any): object is LivekitFocusActive => +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 07f08c75cb7..b68ed65e7ba 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -606,28 +606,14 @@ export class MatrixRTCSession extends TypedEventEmitter< * @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. */ - public getActiveFocus(): Focus | undefined { - return this.membershipManager?.getActiveFocus(); + public resolveActiveFocus(member: CallMembership): Focus | undefined { + return this.membershipManager?.resolveActiveFocus(member); } public getOldestMembership(): CallMembership | undefined { return this.memberships[0]; } - /** - * 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. diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index cdee3ee83a5..f582feaff6c 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -17,65 +17,18 @@ import { AbortError } from "p-retry"; import { EventType } from "../@types/event.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts"; -import { type MatrixClient } from "../client.ts"; -import { UnsupportedDelayedEventsEndpointError } from "../errors.ts"; +import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; -import { type Logger, logger as rootLogger } from "../logger.ts"; -import { type Room } from "../models/room.ts"; -import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; -import { type Focus } from "./focus.ts"; +import { Room } from "../models/room.ts"; +import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from "./CallMembership.ts"; +import { Focus } from "./focus.ts"; +import { isLivekitFocusSelection } from "./LivekitFocus.ts"; +import { MembershipConfig, SessionDescription } from "./MatrixRTCSession.ts"; +import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "src/matrix.ts"; +import { IMembershipManager, MembershipManagerEvent, MembershipManagerEventHandlerMap } from "./IMembershipManager.ts"; +import { Logger, logger as rootLogger } from "src/logger.ts"; +import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; -import { isLivekitFocusActive } from "./LivekitFocus.ts"; -import { type SessionDescription, type MembershipConfig } from "./MatrixRTCSession.ts"; -import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts"; -import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; -import { - MembershipManagerEvent, - type IMembershipManager, - type MembershipManagerEventHandlerMap, -} from "./IMembershipManager.ts"; - -/* MembershipActionTypes: - -On Join: ───────────────┐ ┌───────────────(1)───────────┐ - ▼ ▼ │ - ┌────────────────┐ │ - │SendDelayedEvent│ ──────(2)───┐ │ - └────────────────┘ │ │ - │(3) │ │ - ▼ │ │ - ┌─────────────┐ │ │ - ┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │ - │ └─────────────┘ │ │ │ - │ ┌─────┐ ┌──────┐ │ │ │ - ▼ ▼ │ │ ▼ ▼ ▼ │ -┌────────────┐ │ │ ┌───────────────────┐ │ -│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │ -└────────────┘ │ │ └───────────────────┘ │ - │ │ │ │ │ │ - └─────┘ └──────┘ └───────┘ - -On Leave: ───────── STOP ALL ABOVE - ▼ - ┌────────────────────────────────┐ - │ SendScheduledDelayedLeaveEvent │ - └────────────────────────────────┘ - │(5) - ▼ - ┌──────────────┐ - │SendLeaveEvent│ - └──────────────┘ -(1) [Not found error] results in resending the delayed event -(2) [hasMemberEvent = true] Sending the delayed event if we - already have a call member event results jumping to the - RestartDelayedEvent loop directly -(3) [hasMemberEvent = false] if there is not call member event - sending it is the next step -(4) Both (UpdateExpiry and RestartDelayedEvent) actions are - scheduled when successfully sending the state event -(5) Only if delayed event sending failed (fallback) -(s) Successful restart/resend -*/ /** * The different types of actions the MembershipManager can take. @@ -262,22 +215,15 @@ 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! + public resolveActiveFocus(member: CallMembership): Focus | undefined { + const data = member.getFocusActive(); + if (isLivekitFocusSelection(data) && data.focus_selection === "oldest_membership") { const oldestMembership = this.getOldestMembership(); - return oldestMembership?.getPreferredFoci()[0]; + if (member === oldestMembership) return member.getPreferredFoci()[0]; + if (oldestMembership !== undefined) return this.resolveActiveFocus(oldestMembership); + } else { + // This is a fully resolved focus config + return data; } } @@ -748,8 +694,12 @@ export class MembershipManager scope: "m.room", device_id: this.deviceId, expires, - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - foci_preferred: this.fociPreferred ?? [], + ...(this.focusActive === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { focus_active: this.focusActive }), }; } From 209eecd813f8522ee9e3ec8332ca88944f9962c6 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 27 Aug 2025 14:00:51 +0200 Subject: [PATCH 02/31] temp Signed-off-by: Timo K --- src/matrixrtc/MembershipManager.ts | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index f582feaff6c..19271c40958 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -30,6 +30,48 @@ import { Logger, logger as rootLogger } from "src/logger.ts"; import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; +/* MembershipActionTypes: + +On Join: ───────────────┐ ┌───────────────(1)───────────┐ + ▼ ▼ │ + ┌────────────────┐ │ + │SendDelayedEvent│ ──────(2)───┐ │ + └────────────────┘ │ │ + │(3) │ │ + ▼ │ │ + ┌─────────────┐ │ │ + ┌──────(4)───│SendJoinEvent│────(4)─────┐ │ │ + │ └─────────────┘ │ │ │ + │ ┌─────┐ ┌──────┐ │ │ │ + ▼ ▼ │ │ ▼ ▼ ▼ │ +┌────────────┐ │ │ ┌───────────────────┐ │ +│UpdateExpiry│ (s) (s)|RestartDelayedEvent│ │ +└────────────┘ │ │ └───────────────────┘ │ + │ │ │ │ │ │ + └─────┘ └──────┘ └───────┘ + +On Leave: ───────── STOP ALL ABOVE + ▼ + ┌────────────────────────────────┐ + │ SendScheduledDelayedLeaveEvent │ + └────────────────────────────────┘ + │(5) + ▼ + ┌──────────────┐ + │SendLeaveEvent│ + └──────────────┘ +(1) [Not found error] results in resending the delayed event +(2) [hasMemberEvent = true] Sending the delayed event if we + already have a call member event results jumping to the + RestartDelayedEvent loop directly +(3) [hasMemberEvent = false] if there is not call member event + sending it is the next step +(4) Both (UpdateExpiry and RestartDelayedEvent) actions are + scheduled when successfully sending the state event +(5) Only if delayed event sending failed (fallback) +(s) Successful restart/resend +*/ + /** * The different types of actions the MembershipManager can take. * @internal From 6156d4ce10f1f83235180513965199cb0feb207e Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 27 Aug 2025 14:41:07 +0200 Subject: [PATCH 03/31] Fix imports --- src/matrixrtc/MembershipManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 19271c40958..0f6111600e3 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -24,9 +24,9 @@ import { CallMembership, DEFAULT_EXPIRE_DURATION, SessionMembershipData } from " import { Focus } from "./focus.ts"; import { isLivekitFocusSelection } from "./LivekitFocus.ts"; import { MembershipConfig, SessionDescription } from "./MatrixRTCSession.ts"; -import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "src/matrix.ts"; +import { TypedEventEmitter, UnsupportedDelayedEventsEndpointError } from "../matrix.ts"; import { IMembershipManager, MembershipManagerEvent, MembershipManagerEventHandlerMap } from "./IMembershipManager.ts"; -import { Logger, logger as rootLogger } from "src/logger.ts"; +import { Logger, logger as rootLogger } from "../logger.ts"; import { ActionScheduler, ActionUpdate } from "./MembershipManagerActionScheduler.ts"; import { isMyMembership, Status } from "./types.ts"; From b61e39a81458fb02d76d384e9c4bbef30fcd516a Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 27 Aug 2025 15:30:36 +0200 Subject: [PATCH 04/31] Fix checkSessionsMembershipData thinking foci_preferred is required --- src/matrixrtc/CallMembership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 8bd894045c6..b34644525a5 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -93,7 +93,7 @@ const checkSessionsMembershipData = (data: any, errors: string[]): data is Sessi 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.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array"); // optional parameters if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number"); From 29879e83842cc6428fd24b95bf3edffb066c116f Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:14:31 +0200 Subject: [PATCH 05/31] incorporate CallMembership changes - rename Focus -> Transport - add RtcMembershipData (next to `sessionMembershipData`) - make `new CallMembership` initializable with both - move oldest member calculation into CallMembership Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 140 ++++++++- spec/unit/matrixrtc/LivekitFocus.spec.ts | 24 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 10 +- spec/unit/matrixrtc/MembershipManager.spec.ts | 189 ++++++------ src/@types/event.ts | 8 +- src/matrixrtc/CallMembership.ts | 277 +++++++++++++++--- src/matrixrtc/IMembershipManager.ts | 12 +- src/matrixrtc/LivekitFocus.ts | 23 +- src/matrixrtc/MatrixRTCSession.ts | 114 ++++--- src/matrixrtc/MatrixRTCSessionManager.ts | 4 +- src/matrixrtc/MembershipManager.ts | 120 ++++---- src/matrixrtc/focus.ts | 25 -- src/matrixrtc/index.ts | 1 - src/matrixrtc/types.ts | 8 + src/models/room-member.ts | 2 +- 15 files changed, 650 insertions(+), 307 deletions(-) delete mode 100644 src/matrixrtc/focus.ts diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index cfa98ebd2bf..528f46cace3 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"; @@ -44,7 +45,7 @@ describe("CallMembership", () => { scope: "m.room", application: "m.call", device_id: "AAAAAAA", - focus_active: { type: "livekit" }, + focus_active: { type: "livekit", focus_selection: "oldest_membership" }, foci_preferred: [{ type: "livekit" }], }; @@ -94,11 +95,138 @@ 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("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: "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); + }); + }); + }); + + describe("RtcMembershipData", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const membershipTemplate: RtcMembershipData = { + slot_id: "m.call#1", + application: { type: "m.call" }, + member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, + rtc_transports: [{ type: "livekit" }], + versions: [], + }; + + it("rejects membership with no slot_id", () => { + expect(() => { + new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined }); + }).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:user.id" }, + }); + }).not.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" }); + }); }); }); diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitFocus.spec.ts index 2653511638b..7da0aebb575 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitFocus.spec.ts @@ -14,26 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { isLivekitFocus, isLivekitFocusSelection, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus"; +import { + isLivekitTransport, + isLivekitFocusSelection, + isLivekitTransportConfig, +} from "../../../src/matrixrtc/LivekitFocus"; 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", () => { @@ -48,13 +52,13 @@ describe("LivekitFocus", () => { }); 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 71d46548355..390a201fe46 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,9 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.resolveActiveFocus()).toBe(firstPreferredFocus); + expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe( + firstPreferredFocus, + ); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -288,7 +290,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.resolveActiveFocus()).toBe(undefined); + expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe(undefined); }); }); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index c22ab18390c..01eb5856bfc 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.msc3401.call.member", + { + application: { type: "m.call", id: "" }, + 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.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, () => 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..7ec82777240 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"; @@ -368,7 +368,11 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; - [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; + [EventType.GroupCallMemberPrefix]: + | IGroupCallRoomMemberState + | SessionMembershipData + | RtcMembershipData + | EmptyObject; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index cb20c567ed4..5da96294f23 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -14,11 +14,13 @@ 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 { type SessionDescription } from "./MatrixRTCSession.ts"; -import { type RTCCallIntent } from "./types.ts"; +import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; +import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; +import { type RTCCallIntent, type Transport } from "./types.ts"; +import { type RelationType } from "src/types.ts"; +import { type MatrixEvent } from "../models/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -28,6 +30,91 @@ 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]: any; + }; + "rtc_transports": Transport[]; + "versions": string[]; + "created_ts"?: number; + "sticky_key"?: string; + /** + * 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; +} + +const checkRtcMembershipData = ( + data: Partial>, + errors: string[], +): data is RtcMembershipData => { + const prefix = "Malformed rtc membership event: "; + + // required fields + if (typeof data.slot_id !== "string") errors.push(prefix + "slot_id must be string"); + if (typeof data.member !== "object" || data.member === null) { + errors.push(prefix + "member must be an object"); + } else { + if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string"); + else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid"); + if (typeof data.member.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"); + } + 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" || 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.created_ts !== undefined && typeof data.created_ts !== "number") { + errors.push(prefix + "created_ts must be number"); + } + if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + errors.push(prefix + "sticky_key must be a string"); + } + if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { + errors.push(prefix + "m.call.intent must be a string"); + } + 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. @@ -55,13 +142,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 @@ -76,7 +163,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; @@ -103,8 +190,12 @@ const checkSessionsMembershipData = ( 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.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) - {errors.push(prefix + "foci_preferred must be an array");} + if (data.focus_active !== undefined && !isLivekitFocusSelection(data.focus_active)) { + errors.push(prefix + "focus_active has an invalid type"); + } + if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) { + errors.push(prefix + "foci_preferred must be an array"); + } // optional parameters if (data.created_ts !== undefined && typeof data.created_ts !== "number") { errors.push(prefix + "created_ts must be number"); @@ -120,28 +211,43 @@ const checkSessionsMembershipData = ( return errors.length === 0; }; +type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData }; export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { + if (a === undefined || b === undefined) return a === b; return deepCompare(a.membershipData, b.membershipData); } - private membershipData: SessionMembershipData; + + private membershipData: MembershipData; public constructor( private parentEvent: MatrixEvent, data: any, ) { const sessionErrors: string[] = []; - if (!checkSessionsMembershipData(data, sessionErrors)) { + const rtcErrors: string[] = []; + if (checkSessionsMembershipData(data, sessionErrors)) { + this.membershipData = { kind: "session", data }; + } else if (checkRtcMembershipData(data, rtcErrors)) { + this.membershipData = { kind: "rtc", data }; + } else { throw Error( - `unknown CallMembership data. Does not match MSC4143 call.member (${sessionErrors.join(" & ")}) events this could be a legacy membership event: (${data})`, + `unknown CallMembership data.` + + `Does not match MSC4143 call.member (${sessionErrors.join(" & ")})\n` + + `Does not match MSC4143 rtc.member (${rtcErrors.join(" & ")})\n` + + `events this could be a legacy membership event: (${data})`, ); - } else { - this.membershipData = data; } } public get sender(): string | undefined { - return this.parentEvent.getSender(); + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.member.user_id; + case "session": + return this.parentEvent.getSender(); + } } public get eventId(): string | undefined { @@ -149,77 +255,156 @@ export class CallMembership { } /** - * @deprecated Use sessionDescription.id instead. + * The slot id to find all member building one session `slot_id` (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": + 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": + return data.device_id; + } } public get callIntent(): RTCCallIntent | undefined { - return this.membershipData["m.call.intent"]; + return this.membershipData.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": + return data.application; + } } public get scope(): CallScope | undefined { - return this.membershipData.scope; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return undefined; + case "session": + 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": + 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.parentEvent.getTs(); + case "session": + return data.created_ts ?? this.parentEvent.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": + // 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": + // 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": + return this.getMsUntilExpiry()! <= 0; + } } - public getPreferredFoci(): Focus[] { - return this.membershipData.foci_preferred ?? []; + /** + * + * @param oldestMembership For backwards compatibility with session membership (legacy). + * @returns + */ + 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 "oldest_membership": + if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0]; + if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership); + break; + case "multi_sfu": + return data.foci_preferred[0]; + } + } } - - public getFocusActive(): Focus { - return this.membershipData.focus_active; + public get transports(): Transport[] { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.rtc_transports; + case "session": + return data.foci_preferred; + } } } diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index fb0b3d3b5b8..cf6963fdc18 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,11 @@ 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. + * @param multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the + * membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu. * @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,10 +95,6 @@ export interface IMembershipManager * Call this if the MatrixRTC session members have changed. */ onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; - /** - * Determines the active focus used by the given session member, or undefined if not joined. - */ - resolveActiveFocus(member: CallMembership): 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 index a799a0b7b0b..6c17ffc6a6d 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitFocus.ts @@ -14,26 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { type Focus } from "./focus.ts"; +import { type Transport } from "./types.ts"; -export interface LivekitFocusConfig extends Focus { +export interface LivekitTransportConfig extends Transport { type: "livekit"; livekit_service_url: string; } -export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig => +export const isLivekitTransportConfig = (object: any): object is LivekitTransportConfig => object.type === "livekit" && "livekit_service_url" in object; -export interface LivekitFocus extends LivekitFocusConfig { +export interface LivekitTransport extends LivekitTransportConfig { livekit_alias: string; } -export const isLivekitFocus = (object: any): object is LivekitFocus => - isLivekitFocusConfig(object) && "livekit_alias" in object; +export const isLivekitTransport = (object: any): object is LivekitTransport => + isLivekitTransportConfig(object) && "livekit_alias" in object; -export interface LivekitFocusSelection extends Focus { +/** + * 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"; + 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 5ff40819d60..4fcc449f56a 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 { @@ -241,7 +249,7 @@ 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 _slotId: string | undefined; private joinConfig?: SessionConfig; private logger: Logger; @@ -279,33 +287,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 this._slotId; } /** * 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); @@ -338,9 +366,9 @@ export class MatrixRTCSession extends TypedEventEmitter< 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 session: ${JSON.stringify(membership.slotDescription)}`, ); continue; } @@ -379,26 +407,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); } /** @@ -445,13 +476,13 @@ 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`. + * A session is distinct from another session if one of those properties differ: `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; + this._slotId = memberships[0]?.slotId; 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); @@ -497,7 +528,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * or optionally other room members homeserver well known. * @param joinConfig - Additional configuration for the joined session. */ - public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void { + public joinRoomSession(fociPreferred: Transport[], fociActive?: 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 +539,7 @@ export class MatrixRTCSession extends TypedEventEmitter< joinConfig, this.roomSubset, this.client, - () => this.getOldestMembership(), - this.sessionDescription, + this.slotDescription, this.logger, ); @@ -608,12 +638,18 @@ export class MatrixRTCSession extends TypedEventEmitter< } /** - * Get the active focus from the current CallMemberState event + * Get the focus in use from a specific specified member. + * @param member The member for which to get the active focus. If undefined, the own membership is used. * @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. - */ - public resolveActiveFocus(member: CallMembership): Focus | undefined { - return this.membershipManager?.resolveActiveFocus(member); + * @deprecated use `member.getTransport(session.getOldestMembership())` instead if you want to get the active transport for a specific member. + */ + public resolveActiveFocus(member?: CallMembership): Transport | undefined { + const oldestMembership = this.getOldestMembership(); + if (!oldestMembership) return undefined; + const m = member === undefined ? this.membershipManager?.ownMembership : member; + if (!m) return undefined; + return m.getTransport(oldestMembership); } public getOldestMembership(): CallMembership | undefined { @@ -763,9 +799,9 @@ export class MatrixRTCSession extends TypedEventEmitter< */ private recalculateSessionMembers = (): void => { const oldMemberships = this.memberships; - this.memberships = MatrixRTCSession.sessionMembershipsForRoom(this.room, this.sessionDescription); + this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); - this._callId = this._callId ?? this.memberships[0]?.sessionDescription.id; + this._slotId = this._slotId ?? this.memberships[0]?.slotId; const changed = oldMemberships.length != this.memberships.length || diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index cc25105d977..f2f49cc9136 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 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,18 +274,6 @@ export class MembershipManager return Promise.resolve(); } - public resolveActiveFocus(member: CallMembership): Focus | undefined { - const data = member.getFocusActive(); - if (isLivekitFocusSelection(data) && data.focus_selection === "oldest_membership") { - const oldestMembership = this.getOldestMembership(); - if (member === oldestMembership) return member.getPreferredFoci()[0]; - if (oldestMembership !== undefined) return this.resolveActiveFocus(oldestMembership); - } else { - // This is a fully resolved focus config - return data; - } - } - public async updateCallIntent(callIntent: RTCCallIntent): Promise { if (!this.activated || !this.ownMembership) { throw Error("You cannot update your intent before joining the call"); @@ -295,7 +291,6 @@ export class MembershipManager * @param joinConfig * @param room * @param client - * @param getOldestMembership */ public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, @@ -308,8 +303,7 @@ export class MembershipManager | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" >, - private getOldestMembership: () => CallMembership | undefined, - public readonly sessionDescription: SessionDescription, + public readonly slotDescription: SlotDescription, parentLogger?: Logger, ) { super(); @@ -318,7 +312,7 @@ 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.memberId = this.makeMembershipStateKey(userId, deviceId); this.state = MembershipManager.defaultState; this.callIntent = joinConfig?.callIntent; this.scheduler = new ActionScheduler((type): Promise => { @@ -364,9 +358,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; @@ -399,6 +394,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) { @@ -467,7 +465,7 @@ export class MembershipManager }, EventType.GroupCallMemberPrefix, {}, // leave event - this.stateKey, + this.memberId, ) .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; @@ -654,7 +652,7 @@ export class MembershipManager this.room.roomId, EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs), - this.stateKey, + this.memberId, ) .then(() => { this.setAndEmitProbablyLeft(false); @@ -700,7 +698,7 @@ export class MembershipManager this.room.roomId, EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.stateKey, + this.memberId, ) .then(() => { // Success, we reset retries and schedule update. @@ -724,7 +722,7 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey) + .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.memberId) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -739,7 +737,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 { @@ -750,24 +748,42 @@ 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, - "m.call.intent": this.callIntent, - ...(this.focusActive === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { focus_active: this.focusActive }), - ...(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, id: this.slotDescription.id }, + 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..e383b3f1043 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -15,7 +15,6 @@ limitations under the License. */ export * from "./CallMembership.ts"; -export type * from "./focus.ts"; export * from "./LivekitFocus.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.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..a2711d38ebe 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter Date: Tue, 30 Sep 2025 14:25:15 +0200 Subject: [PATCH 06/31] use correct event type Signed-off-by: Timo K --- src/@types/event.ts | 9 +++------ src/matrixrtc/MembershipManager.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 7ec82777240..1364d9ca75f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.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", @@ -368,12 +369,8 @@ export interface StateEvents { // MSC3401 [EventType.GroupCallPrefix]: IGroupCallRoomState; - [EventType.GroupCallMemberPrefix]: - | IGroupCallRoomMemberState - | SessionMembershipData - | RtcMembershipData - | EmptyObject; - + [EventType.GroupCallMemberPrefix]: IGroupCallRoomMemberState | SessionMembershipData | EmptyObject; + [EventType.RTCMembership]: RtcMembershipData | EmptyObject; // MSC3089 [UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent; diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 617ae91931f..35183753b58 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -463,7 +463,7 @@ export class MembershipManager { delay: this.delayedLeaveEventDelayMs, }, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, {}, // leave event this.memberId, ) @@ -650,7 +650,7 @@ export class MembershipManager return await this.client .sendStateEvent( this.room.roomId, - EventType.GroupCallMemberPrefix, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, this.makeMyMembership(this.membershipEventExpiryMs), this.memberId, ) @@ -696,7 +696,7 @@ 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.memberId, ) @@ -722,7 +722,12 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.memberId) + .sendStateEvent( + this.room.roomId, + this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; From bb7c23d6e5cef8163afc678c413a323936fc25f8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:34:06 +0200 Subject: [PATCH 07/31] fix sonar cube conerns Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 5da96294f23..3b0071955e9 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -220,6 +220,7 @@ export class CallMembership { private membershipData: MembershipData; + private parentEventData: { eventId: string; sender: string }; public constructor( private parentEvent: MatrixEvent, data: any, @@ -238,20 +239,27 @@ export class CallMembership { `events this could be a legacy membership event: (${data})`, ); } + + const eventId = parentEvent.getId(); + const sender = parentEvent.getSender(); + + if (eventId === undefined) throw new Error("parentEvent is missing eventId field"); + if (sender === undefined) throw new Error("parentEvent is missing sender field"); + this.parentEventData = { eventId, sender }; } - public get sender(): string | undefined { + public get sender(): string { const { kind, data } = this.membershipData; switch (kind) { case "rtc": return data.member.user_id; case "session": - return this.parentEvent.getSender(); + return this.parentEventData.sender; } } - public get eventId(): string | undefined { - return this.parentEvent.getId(); + public get eventId(): string { + return this.parentEventData.eventId; } /** @@ -306,6 +314,8 @@ export class CallMembership { return undefined; case "session": return data.scope; + default: + return undefined; } } From 8a5a8cd0cf6311b282baa2382adcefe321dad108 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 14:53:31 +0200 Subject: [PATCH 08/31] callMembership tests Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 102 ++++++++++++++++++--- src/matrixrtc/CallMembership.ts | 10 ++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 528f46cace3..2d21b038bca 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -27,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; } @@ -41,12 +42,13 @@ describe("CallMembership", () => { }); const membershipTemplate: SessionMembershipData = { - call_id: "", - scope: "m.room", - application: "m.call", - device_id: "AAAAAAA", - focus_active: { type: "livekit", focus_selection: "oldest_membership" }, - 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", () => { @@ -98,6 +100,7 @@ describe("CallMembership", () => { 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); @@ -129,6 +132,42 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(mockFocus); }); }); + 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(14400000); + expect(membership.getMsUntilExpiry()).toBe(14400000 - Date.now()); + expect(membership.isExpired()).toBe(true); + }); + }); }); describe("RtcMembershipData", () => { @@ -141,11 +180,12 @@ describe("CallMembership", () => { }); const membershipTemplate: RtcMembershipData = { - slot_id: "m.call#1", - application: { type: "m.call" }, - member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzHASHxyz" }, - rtc_transports: [{ type: "livekit" }], - versions: [], + "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" }], + "m.call.intent": "voice", + "versions": [], }; it("rejects membership with no slot_id", () => { @@ -228,6 +268,46 @@ describe("CallMembership", () => { 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); + }); + }); }); describe("expiry calculation", () => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 3b0071955e9..6e1fac59557 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -306,7 +306,17 @@ export class CallMembership { return data.application; } } + public get applicationData(): { type: string } & Record { + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application; + case "session": + 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 { const { kind, data } = this.membershipData; switch (kind) { From 25f4d6f8fbe0507b48e2ef0e80f6acd91006994a Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 15:00:43 +0200 Subject: [PATCH 09/31] make test correct Signed-off-by: Timo K --- spec/unit/matrixrtc/MembershipManager.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 01eb5856bfc..e63a01ecb79 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -163,7 +163,7 @@ describe("MembershipManager", () => { await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); expect(client.sendStateEvent).toHaveBeenCalledWith( room.roomId, - "org.matrix.msc3401.call.member", + "org.matrix.msc4143.rtc.member", { application: { type: "m.call", id: "" }, member: { @@ -181,7 +181,7 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 8000 }, - "org.matrix.msc3401.call.member", + "org.matrix.msc4143.rtc.member", {}, "_@alice:example.org_AAAAAAA_m.call", ); From 84a3d56f90bddee2a29ac46611e686faeaa8d74d Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 15:58:25 +0200 Subject: [PATCH 10/31] make sonar cube happy (it does not know about the type constraints...) Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 6e1fac59557..756fd425ff8 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -253,7 +253,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.user_id; - case "session": + default: // "session": return this.parentEventData.sender; } } @@ -271,7 +271,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.slot_id; - case "session": + default: // "session": return slotDescriptionToId({ application: this.application, id: data.call_id }); } } @@ -281,7 +281,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.device_id; - case "session": + default: // "session": return data.device_id; } } @@ -302,7 +302,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.application.type; - case "session": + default: // "session": return data.application; } } @@ -311,7 +311,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.application; - case "session": + default: // "session": return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -322,10 +322,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": return data.scope; - default: - return undefined; } } @@ -337,7 +335,7 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.id; - case "session": + default: // "session": return (this.createdTs() ?? "").toString(); } } @@ -348,7 +346,7 @@ export class CallMembership { case "rtc": // TODO we need to read the referenced (relation) event if available to get the real created_ts return this.parentEvent.getTs(); - case "session": + default: // "session": return data.created_ts ?? this.parentEvent.getTs(); } } @@ -362,7 +360,7 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); } @@ -376,7 +374,7 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - case "session": + default: // "session": // 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 @@ -392,7 +390,7 @@ export class CallMembership { switch (kind) { case "rtc": return false; - case "session": + default: // "session": return this.getMsUntilExpiry()! <= 0; } } @@ -409,21 +407,22 @@ export class CallMembership { 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 "multi_sfu": - return data.foci_preferred[0]; } } + return undefined; } public get transports(): Transport[] { const { kind, data } = this.membershipData; switch (kind) { case "rtc": return data.rtc_transports; - case "session": + default: // "session": return data.foci_preferred; } } From 5bc970cb6c7494a1ba545ae7e16aa68bd17f7dbd Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 16:38:08 +0200 Subject: [PATCH 11/31] remove created_ts from RtcMembership Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 756fd425ff8..b9830f98827 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -46,7 +46,6 @@ export interface RtcMembershipData { }; "rtc_transports": Transport[]; "versions": string[]; - "created_ts"?: number; "sticky_key"?: string; /** * The intent of the call from the perspective of this user. This may be an audio call, video call or @@ -94,9 +93,6 @@ const checkRtcMembershipData = ( } // optional fields - if (data.created_ts !== undefined && typeof data.created_ts !== "number") { - errors.push(prefix + "created_ts must be number"); - } if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { errors.push(prefix + "sticky_key must be a string"); } From d94d02d19b9f17c724b5919b185fea3413dbf7a2 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 30 Sep 2025 16:46:32 +0200 Subject: [PATCH 12/31] fix imports Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index b9830f98827..4908e7e5209 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -18,9 +18,9 @@ import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; -import { type RTCCallIntent, type Transport } from "./types.ts"; -import { type RelationType } from "src/types.ts"; +import type { RTCCallIntent, Transport } from "./types.ts"; import { type MatrixEvent } from "../models/event.ts"; +import { type RelationType } from "../@types/event.ts"; /** * The default duration in milliseconds that a membership is considered valid for. From 74b793c77e1aed587b842469c83b451492aedbae Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:14:27 +0200 Subject: [PATCH 13/31] Update src/matrixrtc/IMembershipManager.ts Co-authored-by: Robin --- src/matrixrtc/IMembershipManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index cf6963fdc18..8a000a578e0 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -80,7 +80,8 @@ 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 multiSfuFocus the active focus to use in the joined RTC membership event. Setting this implies the - * membership manager will use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu. + * 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: Transport[], multiSfuFocus?: Transport, onError?: (error: unknown) => void): void; From e829a7b81b8894390f8d7fffb8f970ec8135ad47 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 1 Oct 2025 10:29:20 +0200 Subject: [PATCH 14/31] rename LivekitFocus.ts -> LivekitTransport.ts Signed-off-by: Timo K --- .../{LivekitFocus.spec.ts => LivekitTransport.spec.ts} | 2 +- src/matrixrtc/CallMembership.ts | 2 +- src/matrixrtc/{LivekitFocus.ts => LivekitTransport.ts} | 4 ++-- src/matrixrtc/index.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename spec/unit/matrixrtc/{LivekitFocus.spec.ts => LivekitTransport.spec.ts} (98%) rename src/matrixrtc/{LivekitFocus.ts => LivekitTransport.ts} (91%) diff --git a/spec/unit/matrixrtc/LivekitFocus.spec.ts b/spec/unit/matrixrtc/LivekitTransport.spec.ts similarity index 98% rename from spec/unit/matrixrtc/LivekitFocus.spec.ts rename to spec/unit/matrixrtc/LivekitTransport.spec.ts index 7da0aebb575..04f04a1357e 100644 --- a/spec/unit/matrixrtc/LivekitFocus.spec.ts +++ b/spec/unit/matrixrtc/LivekitTransport.spec.ts @@ -18,7 +18,7 @@ import { isLivekitTransport, isLivekitFocusSelection, isLivekitTransportConfig, -} from "../../../src/matrixrtc/LivekitFocus"; +} from "../../../src/matrixrtc/LivekitTransport"; describe("LivekitFocus", () => { it("isLivekitFocus", () => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 4908e7e5209..04f0f27702c 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -16,7 +16,7 @@ limitations under the License. import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitFocus.ts"; +import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitTransport.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; import type { RTCCallIntent, Transport } from "./types.ts"; import { type MatrixEvent } from "../models/event.ts"; diff --git a/src/matrixrtc/LivekitFocus.ts b/src/matrixrtc/LivekitTransport.ts similarity index 91% rename from src/matrixrtc/LivekitFocus.ts rename to src/matrixrtc/LivekitTransport.ts index 6c17ffc6a6d..61b2d49d061 100644 --- a/src/matrixrtc/LivekitFocus.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -32,7 +32,7 @@ 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. + * @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 { @@ -40,7 +40,7 @@ export interface LivekitFocusSelection extends Transport { focus_selection: "oldest_membership" | "multi_sfu"; } /** - * deprecated see LivekitFocusSelection + * @deprecated see LivekitFocusSelection */ export const isLivekitFocusSelection = (object: any): object is LivekitFocusSelection => object.type === "livekit" && "focus_selection" in object; diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index e383b3f1043..9f52bec6f27 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -15,7 +15,7 @@ limitations under the License. */ export * from "./CallMembership.ts"; -export * from "./LivekitFocus.ts"; +export * from "./LivekitTransport.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; export type * from "./types.ts"; From f70cb140f8f6910282f3fbbcf4f39aa98e672130 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 1 Oct 2025 10:36:44 +0200 Subject: [PATCH 15/31] add details to `getTransport` Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 04f0f27702c..8cd82ac9472 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -208,6 +208,7 @@ const checkSessionsMembershipData = ( }; 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 { if (a === undefined || b === undefined) return a === b; @@ -392,10 +393,23 @@ export class CallMembership { } /** + * ## RTC Membership + * Gets the 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. * - * @param oldestMembership For backwards compatibility with session membership (legacy). - * @returns + * ## 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. */ + // TODO: make this return all transports used to publish media once this is supported. public getTransport(oldestMembership: CallMembership): Transport | undefined { const { kind, data } = this.membershipData; switch (kind) { From 11f610d7c70a03bc0f2a2aa26cdfee8f1e266b3f Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 14:44:57 +0200 Subject: [PATCH 16/31] review Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 8 --- src/matrixrtc/CallMembership.ts | 71 ++++++++++++++-------- src/matrixrtc/LivekitTransport.ts | 2 +- src/matrixrtc/MatrixRTCSession.ts | 4 ++ src/models/room-member.ts | 2 +- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 2d21b038bca..712151f99d0 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -171,14 +171,6 @@ describe("CallMembership", () => { }); describe("RtcMembershipData", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - const membershipTemplate: RtcMembershipData = { "slot_id": "m.call#", "application": { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" }, diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 8cd82ac9472..ce15159ecfa 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -46,6 +46,7 @@ export interface RtcMembershipData { }; "rtc_transports": Transport[]; "versions": string[]; + "msc4354_sticky_key"?: string; "sticky_key"?: string; /** * The intent of the call from the perspective of this user. This may be an audio call, video call or @@ -93,7 +94,8 @@ const checkRtcMembershipData = ( } // optional fields - if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") { + const stickyKey = data.sticky_key ?? data.msc4354_sticky_key; + if (stickyKey !== undefined && typeof stickyKey !== "string") { errors.push(prefix + "sticky_key must be a string"); } if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { @@ -210,16 +212,20 @@ const checkSessionsMembershipData = ( 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 { + public static equal(a?: CallMembership, b?: CallMembership): boolean { if (a === undefined || b === undefined) return a === b; return deepCompare(a.membershipData, b.membershipData); } private membershipData: MembershipData; - private parentEventData: { eventId: string; sender: string }; + /** 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 matrixEventData: { eventId: string; sender: string }; public constructor( - private parentEvent: MatrixEvent, + /** The Matrix event that this membership is based on */ + private matrixEvent: MatrixEvent, data: any, ) { const sessionErrors: string[] = []; @@ -237,12 +243,12 @@ export class CallMembership { ); } - const eventId = parentEvent.getId(); - const sender = parentEvent.getSender(); + 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"); - this.parentEventData = { eventId, sender }; + this.matrixEventData = { eventId, sender }; } public get sender(): string { @@ -250,13 +256,14 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.user_id; - default: // "session": - return this.parentEventData.sender; + case "session": + default: + return this.matrixEventData.sender; } } public get eventId(): string { - return this.parentEventData.eventId; + return this.matrixEventData.eventId; } /** @@ -268,7 +275,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.slot_id; - default: // "session": + case "session": + default: return slotDescriptionToId({ application: this.application, id: data.call_id }); } } @@ -278,7 +286,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.device_id; - default: // "session": + case "session": + default: return data.device_id; } } @@ -299,7 +308,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.application.type; - default: // "session": + case "session": + default: return data.application; } } @@ -308,7 +318,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.application; - default: // "session": + case "session": + default: return { "type": data.application, "m.call.intent": data["m.call.intent"] }; } } @@ -319,7 +330,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + case "session": + default: return data.scope; } } @@ -332,7 +344,8 @@ export class CallMembership { switch (kind) { case "rtc": return data.member.id; - default: // "session": + case "session": + default: return (this.createdTs() ?? "").toString(); } } @@ -342,9 +355,10 @@ export class CallMembership { switch (kind) { case "rtc": // TODO we need to read the referenced (relation) event if available to get the real created_ts - return this.parentEvent.getTs(); - default: // "session": - return data.created_ts ?? this.parentEvent.getTs(); + return this.matrixEvent.getTs(); + case "session": + default: + return data.created_ts ?? this.matrixEvent.getTs(); } } @@ -357,7 +371,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + case "session": + default: // TODO: calculate this from the MatrixRTCSession join configuration directly return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION); } @@ -371,7 +386,8 @@ export class CallMembership { switch (kind) { case "rtc": return undefined; - default: // "session": + 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 @@ -387,14 +403,15 @@ export class CallMembership { switch (kind) { case "rtc": return false; - default: // "session": + case "session": + default: return this.getMsUntilExpiry()! <= 0; } } /** * ## RTC Membership - * Gets the transport to use for this RTC membership (m.rtc.member). + * 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. * @@ -409,7 +426,6 @@ export class CallMembership { * 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. */ - // TODO: make this return all transports used to publish media once this is supported. public getTransport(oldestMembership: CallMembership): Transport | undefined { const { kind, data } = this.membershipData; switch (kind) { @@ -427,12 +443,17 @@ export class CallMembership { } 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; - default: // "session": + case "session": + default: return data.foci_preferred; } } diff --git a/src/matrixrtc/LivekitTransport.ts b/src/matrixrtc/LivekitTransport.ts index 61b2d49d061..eda11f554e5 100644 --- a/src/matrixrtc/LivekitTransport.ts +++ b/src/matrixrtc/LivekitTransport.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +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. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 4fcc449f56a..76b693b20ab 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -363,6 +363,10 @@ 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); diff --git a/src/models/room-member.ts b/src/models/room-member.ts index a2711d38ebe..afe72d4ef51 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -388,7 +388,7 @@ export class RoomMember extends TypedEventEmitter Date: Tue, 7 Oct 2025 15:00:27 +0200 Subject: [PATCH 17/31] use DEFAULT_EXPIRE_DURATION in tests Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 712151f99d0..0a25ba22e5b 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -163,8 +163,8 @@ describe("CallMembership", () => { expect(membership.membershipID).toBe("0"); }); it("returns correct unused fields", () => { - expect(membership.getAbsoluteExpiry()).toBe(14400000); - expect(membership.getMsUntilExpiry()).toBe(14400000 - Date.now()); + expect(membership.getAbsoluteExpiry()).toBe(DEFAULT_EXPIRE_DURATION); + expect(membership.getMsUntilExpiry()).toBe(DEFAULT_EXPIRE_DURATION - Date.now()); expect(membership.isExpired()).toBe(true); }); }); From 4643844597f8bd0196714ecc1c7fafd3f3f6669d Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 7 Oct 2025 15:02:54 +0200 Subject: [PATCH 18/31] fix test `does not provide focus if the selection method is unknown` Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 0a25ba22e5b..6105fc963fe 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -118,7 +118,7 @@ describe("CallMembership", () => { expect(membership.getTransport(oldestMembership)).toBe(membershipTemplate.foci_preferred[0]); }); - it("does not provide focus if the selection method is unknown", () => { + it("gets the correct active transport with multi_sfu", () => { const membership = new CallMembership(makeMockEvent(), { ...membershipTemplate, foci_preferred: [mockFocus], @@ -131,6 +131,16 @@ describe("CallMembership", () => { // 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); From 10574952aea47027c9f0cfa24127a722f6bd6da8 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:24:29 +0200 Subject: [PATCH 19/31] Update src/matrixrtc/CallMembership.ts Co-authored-by: Robin --- src/matrixrtc/CallMembership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ce15159ecfa..7bf7e181ff3 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -42,7 +42,7 @@ export interface RtcMembershipData { "application": { type: string; // other application specific keys - [key: string]: any; + [key: string]: unknown; }; "rtc_transports": Transport[]; "versions": string[]; From 7b0dbbf73474c6a9f036473fff37444d1af04376 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 12:07:13 +0200 Subject: [PATCH 20/31] Move `m.call.intent` into the `application` section for rtc member events. Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 11 +++++------ src/matrixrtc/CallMembership.ts | 19 +++++++++---------- src/matrixrtc/MembershipManager.ts | 6 +++++- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 6105fc963fe..4c5f52e10ab 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -182,12 +182,11 @@ describe("CallMembership", () => { 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" }], - "m.call.intent": "voice", - "versions": [], + 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: [], }; it("rejects membership with no slot_id", () => { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 7bf7e181ff3..67f20ea0706 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -48,11 +48,6 @@ export interface RtcMembershipData { "versions": string[]; "msc4354_sticky_key"?: string; "sticky_key"?: string; - /** - * 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; } const checkRtcMembershipData = ( @@ -98,9 +93,6 @@ const checkRtcMembershipData = ( if (stickyKey !== undefined && typeof stickyKey !== "string") { errors.push(prefix + "sticky_key must be a string"); } - if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") { - errors.push(prefix + "m.call.intent must be a string"); - } if (data["m.relates_to"] !== undefined) { const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"]; if (typeof rel !== "object" || rel === null) { @@ -293,7 +285,14 @@ export class CallMembership { } public get callIntent(): RTCCallIntent | undefined { - return this.membershipData.data["m.call.intent"]; + const { kind, data } = this.membershipData; + switch (kind) { + case "rtc": + return data.application["m.call.intent"]; + case "session": + default: + return data["m.call.intent"]; + } } /** @@ -313,7 +312,7 @@ export class CallMembership { return data.application; } } - public get applicationData(): { type: string } & Record { + public get applicationData(): { type: string; [key: string]: unknown } { const { kind, data } = this.membershipData; switch (kind) { case "rtc": diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 35183753b58..c3a8864a65f 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -760,7 +760,11 @@ export class MembershipManager ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } : {}; return { - application: { type: this.slotDescription.application, id: this.slotDescription.id }, + application: { + type: this.slotDescription.application, + id: this.slotDescription.id, + ...(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 }, From cf140072f8ffce6af86e4a9c6fb3dab2e9e14079 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 12:50:38 +0200 Subject: [PATCH 21/31] review on rtc object validation code. Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 54 ++++++++++++++++++++ src/matrixrtc/CallMembership.ts | 57 +++++++++++++++------- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 4c5f52e10ab..46b3550e4fd 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -187,6 +187,7 @@ describe("CallMembership", () => { 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", () => { @@ -194,6 +195,16 @@ describe("CallMembership", () => { 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(() => { @@ -245,6 +256,49 @@ describe("CallMembership", () => { }); }).not.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 diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 67f20ea0706..aca91faea4f 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -54,10 +54,14 @@ const checkRtcMembershipData = ( data: Partial>, errors: string[], ): data is RtcMembershipData => { - const prefix = "Malformed rtc membership event: "; + const prefix = " - "; // required fields - if (typeof data.slot_id !== "string") errors.push(prefix + "slot_id must be string"); + 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 { @@ -69,14 +73,18 @@ const checkRtcMembershipData = ( 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"); + 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" || typeof (t as any).type !== "string") { + 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; } @@ -89,10 +97,22 @@ const checkRtcMembershipData = ( } // optional fields - const stickyKey = data.sticky_key ?? data.msc4354_sticky_key; - if (stickyKey !== undefined && typeof stickyKey !== "string") { + 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) { @@ -175,7 +195,7 @@ const checkSessionsMembershipData = ( data: Partial>, errors: string[], ): data is SessionMembershipData => { - const prefix = "Malformed session membership event: "; + 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"); @@ -183,8 +203,12 @@ const checkSessionsMembershipData = ( if (data.focus_active !== undefined && !isLivekitFocusSelection(data.focus_active)) { errors.push(prefix + "focus_active has an invalid type"); } - if (data.foci_preferred !== undefined && !Array.isArray(data.foci_preferred)) { - errors.push(prefix + "foci_preferred must be an array"); + 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") { @@ -205,8 +229,7 @@ type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "sessio // TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file. export class CallMembership { public static equal(a?: CallMembership, b?: CallMembership): boolean { - if (a === undefined || b === undefined) return a === b; - return deepCompare(a.membershipData, b.membershipData); + return deepCompare(a?.membershipData, b?.membershipData); } private membershipData: MembershipData; @@ -227,12 +250,12 @@ export class CallMembership { } else if (checkRtcMembershipData(data, rtcErrors)) { this.membershipData = { kind: "rtc", data }; } else { - throw Error( - `unknown CallMembership data.` + - `Does not match MSC4143 call.member (${sessionErrors.join(" & ")})\n` + - `Does not match MSC4143 rtc.member (${rtcErrors.join(" & ")})\n` + - `events this could be a legacy membership event: (${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); } const eventId = matrixEvent.getId(); From 23b60c45faf256650a92b98b146f28d16e8c37c1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 12:58:11 +0200 Subject: [PATCH 22/31] user id check Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index aca91faea4f..4b95996e616 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -53,6 +53,7 @@ export interface RtcMembershipData { const checkRtcMembershipData = ( data: Partial>, errors: string[], + referenceUserId: string, ): data is RtcMembershipData => { const prefix = " - "; @@ -67,6 +68,10 @@ const checkRtcMembershipData = ( } 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"); } @@ -266,7 +271,11 @@ export class CallMembership { this.matrixEventData = { eventId, sender }; } + /** @deprecated use userId instead */ public get sender(): string { + return this.userId; + } + public get userId(): string { const { kind, data } = this.membershipData; switch (kind) { case "rtc": From 62b5b507ba480afff536c6dcbef28177d595c3e9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 16:29:53 +0200 Subject: [PATCH 23/31] review: Refactor RTC membership handling and improve error handling Signed-off-by: Timo K --- spec/unit/matrixrtc/CallMembership.spec.ts | 4 +-- spec/unit/matrixrtc/MembershipManager.spec.ts | 2 +- src/matrixrtc/CallMembership.ts | 31 +++++++++---------- src/matrixrtc/MatrixRTCSession.ts | 9 ++---- src/matrixrtc/MatrixRTCSessionManager.ts | 6 ++-- src/matrixrtc/MembershipManager.ts | 3 +- 6 files changed, 24 insertions(+), 31 deletions(-) diff --git a/spec/unit/matrixrtc/CallMembership.spec.ts b/spec/unit/matrixrtc/CallMembership.spec.ts index 46b3550e4fd..95c7140b131 100644 --- a/spec/unit/matrixrtc/CallMembership.spec.ts +++ b/spec/unit/matrixrtc/CallMembership.spec.ts @@ -252,9 +252,9 @@ describe("CallMembership", () => { expect(() => { new CallMembership(makeMockEvent(), { ...membershipTemplate, - member: { id: "test", device_id: "test", user_id: "@test:user.id" }, + member: { id: "test", device_id: "test", user_id: "@test-wrong-user:user.id" }, }); - }).not.toThrow(); + }).toThrow(); }); it("rejects membership with incorrect sticky_key", () => { expect(() => { diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index e63a01ecb79..1e0e0b5e798 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -165,7 +165,7 @@ describe("MembershipManager", () => { room.roomId, "org.matrix.msc4143.rtc.member", { - application: { type: "m.call", id: "" }, + application: { type: "m.call" }, member: { user_id: "@alice:example.org", id: "_@alice:example.org_AAAAAAA_m.call", diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 4b95996e616..442eba8d438 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -19,7 +19,7 @@ import { deepCompare } from "../utils.ts"; import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitTransport.ts"; import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts"; import type { RTCCallIntent, Transport } from "./types.ts"; -import { type MatrixEvent } from "../models/event.ts"; +import { type IContent, type MatrixEvent } from "../models/event.ts"; import { type RelationType } from "../@types/event.ts"; /** @@ -51,7 +51,7 @@ export interface RtcMembershipData { } const checkRtcMembershipData = ( - data: Partial>, + data: IContent, errors: string[], referenceUserId: string, ): data is RtcMembershipData => { @@ -196,10 +196,7 @@ export type SessionMembershipData = { "m.call.intent"?: RTCCallIntent; }; -const checkSessionsMembershipData = ( - data: Partial>, - errors: string[], -): data is SessionMembershipData => { +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"); @@ -242,17 +239,23 @@ export class CallMembership { /** 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 matrixEventData: { eventId: string; sender: string }; + private readonly matrixEventData: { eventId: string; sender: string }; public constructor( /** The Matrix event that this membership is based on */ - private matrixEvent: MatrixEvent, - data: any, + 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[] = []; const rtcErrors: string[] = []; if (checkSessionsMembershipData(data, sessionErrors)) { this.membershipData = { kind: "session", data }; - } else if (checkRtcMembershipData(data, rtcErrors)) { + } else if (checkRtcMembershipData(data, rtcErrors, sender)) { this.membershipData = { kind: "rtc", data }; } else { const details = @@ -262,12 +265,6 @@ export class CallMembership { const json = "\nevent:\n" + JSON.stringify(data).replaceAll('"', "'"); throw Error(`unknown CallMembership data.\n` + details + json); } - - 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"); this.matrixEventData = { eventId, sender }; } @@ -291,7 +288,7 @@ export class CallMembership { } /** - * The slot id to find all member building one session `slot_id` (format `{application}#{id}`). + * The ID of the MatrixRTC slot that this membership belongs to (format `{application}#{id}`). * This is computed in case SessionMembershipData is used. */ public get slotId(): string { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 76b693b20ab..bcac9174db1 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -248,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 _slotId: string | undefined; private joinConfig?: SessionConfig; private logger: Logger; @@ -299,7 +297,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * The slotId is the property that, per definition, groups memberships into one call. */ public get slotId(): string | undefined { - return this._slotId; + return slotDescriptionToId(this.slotDescription); } /** @@ -372,7 +370,7 @@ export class MatrixRTCSession extends TypedEventEmitter< if (!deepCompare(membership.slotDescription, slotDescription)) { logger.info( - `Ignoring membership of user ${membership.sender} for a different session: ${JSON.stringify(membership.slotDescription)}`, + `Ignoring membership of user ${membership.sender} for a different slot: ${JSON.stringify(membership.slotDescription)}`, ); continue; } @@ -486,7 +484,6 @@ export class MatrixRTCSession extends TypedEventEmitter< ) { super(); this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`); - this._slotId = memberships[0]?.slotId; 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); @@ -805,8 +802,6 @@ export class MatrixRTCSession extends TypedEventEmitter< const oldMemberships = this.memberships; this.memberships = MatrixRTCSession.sessionMembershipsForSlot(this.room, this.slotDescription); - this._slotId = this._slotId ?? this.memberships[0]?.slotId; - const changed = oldMemberships.length != this.memberships.length || oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i])); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index f2f49cc9136..792132d273e 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -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 Date: Wed, 8 Oct 2025 18:55:36 +0200 Subject: [PATCH 24/31] docstring updates Signed-off-by: Timo K --- src/matrixrtc/IMembershipManager.ts | 1 + src/matrixrtc/MatrixRTCSession.ts | 23 ++++++++++++++--------- src/matrixrtc/MembershipManager.ts | 6 ++++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts index 8a000a578e0..7826fa9d16e 100644 --- a/src/matrixrtc/IMembershipManager.ts +++ b/src/matrixrtc/IMembershipManager.ts @@ -79,6 +79,7 @@ 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. + * 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. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index bcac9174db1..9cb64e4644e 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -477,8 +477,9 @@ 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`, `slotDescription.application`, `slotDescription.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 slotDescription: SlotDescription, ) { @@ -522,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: Transport[], fociActive?: Transport, 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; @@ -602,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()); diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index aea7c0b0a1c..45f0a5ce9fc 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -176,9 +176,11 @@ export class MembershipManager /** * Puts the MembershipManager in a state where it tries to be joined. * It will send delayed events and membership events - * @param fociPreferred + * @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 use multi sfu. Use `undefined` to not use `oldest_membership` selection based sfu. + * membership manager will operate in a multi-SFU connection mode. If `undefined`, an `oldest_membership` + * transport selection will be used instead. * @param onError This will be called once the membership manager encounters an unrecoverable error. * This should bubble up the the frontend to communicate that the call does not work in the current environment. */ From 093c561dc206e825ad5f2167e4ff84c8d4b7e182 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 19:20:11 +0200 Subject: [PATCH 25/31] add back deprecated `getFocusInUse` & `getActiveFocus` Signed-off-by: Timo K --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 5 +---- src/matrixrtc/CallMembership.ts | 21 ++++++++++++++++-- src/matrixrtc/MatrixRTCSession.ts | 23 ++++++++++---------- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 390a201fe46..3e394119a29 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -268,9 +268,6 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "oldest_membership", }); - expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe( - firstPreferredFocus, - ); jest.useRealTimers(); }); it("does not provide focus if the selection method is unknown", () => { @@ -290,7 +287,7 @@ describe("MatrixRTCSession", () => { type: "livekit", focus_selection: "unknown", }); - expect(sess.resolveActiveFocus(sess.memberships.find((m) => m.deviceId === "old"))).toBe(undefined); + expect(sess.memberships.length).toBe(0); }); }); diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 442eba8d438..3eccc983ae9 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -21,6 +21,7 @@ import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from " 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 "src/logger.ts"; /** * The default duration in milliseconds that a membership is considered valid for. @@ -316,8 +317,14 @@ export class CallMembership { public get callIntent(): RTCCallIntent | undefined { const { kind, data } = this.membershipData; switch (kind) { - case "rtc": - return data.application["m.call.intent"]; + case "rtc": { + const intent = data.application["m.call.intent"]; + if (intent !== undefined && typeof intent !== "string") { + logger.warn("RTC membership has invalid m.call.intent"); + return undefined; + } + return intent; + } case "session": default: return data["m.call.intent"]; @@ -471,6 +478,16 @@ export class CallMembership { } 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). diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 9cb64e4644e..9a61a7238b4 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -642,22 +642,23 @@ export class MatrixRTCSession extends TypedEventEmitter< return await leavePromise; } - /** - * Get the focus in use from a specific specified member. - * @param member The member for which to get the active focus. If undefined, the own membership is used. - * @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. - * @deprecated use `member.getTransport(session.getOldestMembership())` instead if you want to get the active transport for a specific member. + * 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 resolveActiveFocus(member?: CallMembership): Transport | undefined { + public getFocusInUse(): Transport | undefined { const oldestMembership = this.getOldestMembership(); - if (!oldestMembership) return undefined; - const m = member === undefined ? this.membershipManager?.ownMembership : member; - if (!m) return undefined; - return m.getTransport(oldestMembership); + 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]; } From a850496a32504b845aaaf0f716de375e139e64e0 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 19:25:27 +0200 Subject: [PATCH 26/31] ci Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 3eccc983ae9..c11159d024e 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -21,7 +21,7 @@ import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from " 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 "src/logger.ts"; +import { logger } from "../logger.ts"; /** * The default duration in milliseconds that a membership is considered valid for. From 6513f3bfa7cb7f410e3ce9cac6a5a4d7f930b0d8 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 8 Oct 2025 19:43:30 +0200 Subject: [PATCH 27/31] Update src/matrixrtc/CallMembership.ts Co-authored-by: Robin --- src/matrixrtc/CallMembership.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index c11159d024e..7982471f893 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -208,8 +208,8 @@ const checkSessionsMembershipData = (data: IContent, errors: string[]): data is } 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") + !(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"); } From 27801ce42d21c8d94046e7254b92ea66aa09f9ed Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 19:45:35 +0200 Subject: [PATCH 28/31] lint Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 7982471f893..47fe0c36869 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -208,8 +208,12 @@ const checkSessionsMembershipData = (data: IContent, errors: string[]): data is } 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")) + !( + 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"); } From d1df0c8a9f700674a9a96a69d32a04114b7a1291 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 20:57:44 +0200 Subject: [PATCH 29/31] make test less strict for ew tests Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 47fe0c36869..f21637a387e 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -16,7 +16,7 @@ limitations under the License. import { MXID_PATTERN } from "../models/room-member.ts"; import { deepCompare } from "../utils.ts"; -import { isLivekitFocusSelection, type LivekitFocusSelection } from "./LivekitTransport.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"; @@ -203,7 +203,7 @@ const checkSessionsMembershipData = (data: IContent, errors: string[]): data is 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 && !isLivekitFocusSelection(data.focus_active)) { + if (data.focus_active !== undefined) { errors.push(prefix + "focus_active has an invalid type"); } if ( From b9cd1e02c62ed8d285c13b4322f3d114528d894c Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 21:03:27 +0200 Subject: [PATCH 30/31] Typescript downstream test adjustments Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index f21637a387e..f7d907ccb7d 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -323,11 +323,11 @@ export class CallMembership { switch (kind) { case "rtc": { const intent = data.application["m.call.intent"]; - if (intent !== undefined && typeof intent !== "string") { - logger.warn("RTC membership has invalid m.call.intent"); - return undefined; + if (typeof intent === "string") { + return intent; } - return intent; + logger.warn("RTC membership has invalid m.call.intent"); + return undefined; } case "session": default: From caeae6c6de0a0a87bbdf4069b3ae9cf90da776ae Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 8 Oct 2025 21:08:28 +0200 Subject: [PATCH 31/31] err Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index f7d907ccb7d..59ff3778e7a 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -203,7 +203,7 @@ const checkSessionsMembershipData = (data: IContent, errors: string[]): data is 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) { + if (data.focus_active === undefined) { errors.push(prefix + "focus_active has an invalid type"); } if (