Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
41a2f47
WIP
robintown Nov 19, 2024
209eecd
temp
toger5 Aug 27, 2025
6156d4c
Fix imports
robintown Aug 27, 2025
b61e39a
Fix checkSessionsMembershipData thinking foci_preferred is required
robintown Aug 27, 2025
ca4a9c6
Merge branch 'develop' into voip-team/multi-SFU
robintown Sep 25, 2025
29879e8
incorporate CallMembership changes
toger5 Sep 30, 2025
86f33f9
use correct event type
toger5 Sep 30, 2025
bb7c23d
fix sonar cube conerns
toger5 Sep 30, 2025
8a5a8cd
callMembership tests
toger5 Sep 30, 2025
25f4d6f
make test correct
toger5 Sep 30, 2025
84a3d56
make sonar cube happy (it does not know about the type constraints...)
toger5 Sep 30, 2025
5bc970c
remove created_ts from RtcMembership
toger5 Sep 30, 2025
d94d02d
fix imports
toger5 Sep 30, 2025
74b793c
Update src/matrixrtc/IMembershipManager.ts
toger5 Oct 1, 2025
e829a7b
rename LivekitFocus.ts -> LivekitTransport.ts
toger5 Oct 1, 2025
f70cb14
add details to `getTransport`
toger5 Oct 1, 2025
a343e8c
Merge branch 'develop' into voip-team/multi-SFU
toger5 Oct 1, 2025
11f610d
review
toger5 Oct 7, 2025
66f202a
use DEFAULT_EXPIRE_DURATION in tests
toger5 Oct 7, 2025
4643844
fix test `does not provide focus if the selection method is unknown`
toger5 Oct 7, 2025
1057495
Update src/matrixrtc/CallMembership.ts
toger5 Oct 8, 2025
7b0dbbf
Move `m.call.intent` into the `application` section for rtc member ev…
toger5 Oct 8, 2025
cf14007
review on rtc object validation code.
toger5 Oct 8, 2025
23b60c4
user id check
toger5 Oct 8, 2025
62b5b50
review: Refactor RTC membership handling and improve error handling
toger5 Oct 8, 2025
5a3a26a
docstring updates
toger5 Oct 8, 2025
093c561
add back deprecated `getFocusInUse` & `getActiveFocus`
toger5 Oct 8, 2025
a850496
ci
toger5 Oct 8, 2025
6513f3b
Update src/matrixrtc/CallMembership.ts
toger5 Oct 8, 2025
27801ce
lint
toger5 Oct 8, 2025
d1df0c8
make test less strict for ew tests
toger5 Oct 8, 2025
b9cd1e0
Typescript downstream test adjustments
toger5 Oct 8, 2025
caeae6c
err
toger5 Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 60 additions & 7 deletions spec/unit/matrixrtc/CallMembership.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,19 +182,29 @@ 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: [],
msc4354_sticky_key: "abc123",
};

it("rejects membership with no slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: undefined });
}).toThrow();
});
it("rejects membership with invalid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
}).not.toThrow();
});

it("rejects membership with no application", () => {
expect(() => {
Expand Down Expand Up @@ -242,9 +252,52 @@ 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" },
});
}).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", () => {
Expand Down
2 changes: 1 addition & 1 deletion spec/unit/matrixrtc/MembershipManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
114 changes: 71 additions & 43 deletions src/matrixrtc/CallMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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";

/**
Expand Down Expand Up @@ -48,40 +48,48 @@
"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 = (
data: Partial<Record<keyof RtcMembershipData, any>>,
data: IContent,
errors: string[],
referenceUserId: 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 {
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") errors.push(prefix + "application.type must be a string");
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;
}
Expand All @@ -94,12 +102,21 @@
}

// 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["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent 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"];
Expand Down Expand Up @@ -179,20 +196,21 @@
"m.call.intent"?: RTCCallIntent;
};

const checkSessionsMembershipData = (
data: Partial<Record<keyof SessionMembershipData, any>>,
errors: string[],
): data is SessionMembershipData => {
const prefix = "Malformed session membership event: ";
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
const prefix = " - ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (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") {
Expand All @@ -213,45 +231,48 @@
// 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;

/** 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 {
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();
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 };
}

/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
Expand All @@ -267,7 +288,7 @@
}

/**
* 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 {
Expand All @@ -293,7 +314,14 @@
}

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"];

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Typescript Syntax Check

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / ESLint

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Node.js example

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Workflow Lint

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / JSDoc Checker

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Analyse Dead Code

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Jest [integ] (Node 22)

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Jest [integ] (Node lts/*)

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Jest [unit] (Node lts/*)

Type 'unknown' is not assignable to type 'string | undefined'.

Check failure on line 320 in src/matrixrtc/CallMembership.ts

View workflow job for this annotation

GitHub Actions / Jest [unit] (Node 22)

Type 'unknown' is not assignable to type 'string | undefined'.
case "session":
default:
return data["m.call.intent"];
}
}

/**
Expand All @@ -313,7 +341,7 @@
return data.application;
}
}
public get applicationData(): { type: string } & Record<string, any> {
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
Expand Down
9 changes: 2 additions & 7 deletions src/matrixrtc/MatrixRTCSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]));
Expand Down
6 changes: 3 additions & 3 deletions src/matrixrtc/MatrixRTCSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
public constructor(
rootLogger: Logger,
private client: MatrixClient,
private readonly sessionDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
private readonly slotDescription: SlotDescription = { application: "m.call", id: "" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
Expand All @@ -66,7 +66,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
// returning nothing, and breaks tests if you change it to return an empty array :'(
for (const room of this.client.getRooms() ?? []) {
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription);
const session = MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription);
if (session.memberships.length > 0) {
this.roomSessions.set(room.roomId, session);
}
Expand Down Expand Up @@ -102,7 +102,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
if (!this.roomSessions.has(room.roomId)) {
this.roomSessions.set(
room.roomId,
MatrixRTCSession.sessionForRoom(this.client, room, this.sessionDescription),
MatrixRTCSession.sessionForRoom(this.client, room, this.slotDescription),
);
}

Expand Down
Loading
Loading