RTC Slots: Refactoring of membership event parsing and handling (#5134)

* Split out membership into seperate files.

* First pass of merging in new changes.

* More cleanup

* fix import

* Lots of test fixes

* remove skips

* unrelated change

* docstring

* comment

* lint lint lint

* copyright updates

* cleanup

* Ensure we await initial membership in all tests.

* fix race

* Use promises which are more reliable

* Even more promise stability

* cleanup

* Cleanup

* rename legacy.ts -> session.ts

* Update imports

* cleanup

* Rename files

* Rename + remove claimed_

* renaming

* Rename function

* All the cleanup

* tidy

* commit changes

* fix call membership

* fix claimed

* update slot_id

* fix device_id / claimed_device_id

* Update src/matrixrtc/utils.ts

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* use an aggregate error

* Export types

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
Will Hunt
2026-02-24 10:51:23 +00:00
committed by GitHub
parent bd6547c081
commit 5ea1554612
22 changed files with 1041 additions and 746 deletions
+56 -63
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,23 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent, type MatrixEvent } from "../../../src";
import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
} as unknown as MatrixEvent;
}
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
import { type IContent, type MatrixEvent } from "../../../src/models/event.ts";
import { EventType } from "../../../src/@types/event.ts";
import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts";
function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership {
vi.mocked(ev.getContent).mockReturnValue(content);
@@ -40,6 +27,15 @@ function createCallMembership(ev: MatrixEvent, content: IContent): CallMembershi
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
} as unknown as MatrixEvent;
}
beforeEach(() => {
vi.useFakeTimers();
});
@@ -212,9 +208,40 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(true);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
});
afterEach(() => {
vi.useFakeTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
});
describe("RtcMembershipData", () => {
function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue(content),
getType: vi.fn().mockReturnValue(EventType.RTCMembership),
} as unknown as MatrixEvent;
}
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
@@ -234,6 +261,11 @@ describe("CallMembership", () => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("rejects membership with slot_id that contains extra #", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
@@ -334,13 +366,9 @@ describe("CallMembership", () => {
}).toThrow();
});
it.skip("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it.skip("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
// TODO link prev event
it.todo("considers memberships unexpired if local age low enough");
it.todo("considers memberships expired if local age large enough");
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
@@ -397,44 +425,9 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(false);
});
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("j9N1u04ZbvI9qKf3cxrf2NauD-fIGJ4uAcYkfI9V7SY");
});
afterEach(() => {
vi.useRealTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
it("uses unpadded base64 for RTC backend identities", async () => {
expect(
await CallMembership.computeRtcBackendIdentity(makeMockEvent(), {
kind: "rtc",
data: {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzRANDOMxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
},
}),
).toBe("2+h2ELE1XY/NsuveToZOekORCoyQMO6V0W7XZUWk5Q4");
});
});
+191 -122
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,25 +25,31 @@ import {
type MatrixEvent,
type Room,
} from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { MembershipManagerEvent } from "../../../src/matrixrtc/IMembershipManager";
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { KnownMembership } from "../../../src/@types/membership.ts";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession.ts";
import { MembershipManagerEvent } from "../../../src/matrixrtc/IMembershipManager.ts";
import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types.ts";
import {
makeMockEvent,
makeMockRoom,
membershipTemplate,
sessionMembershipTemplate,
makeKey,
type MembershipData,
mockRoomState,
mockRTCEvent,
owmMemberIdentity,
} from "./mocks";
rtcMembershipTemplate,
} from "./mocks.ts";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts";
import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager.ts";
import { flushPromises } from "../../test-utils/flushPromises.ts";
import {
computeRtcIdentityRaw,
type RtcMembershipData,
type SessionMembershipData,
} from "../../../src/matrixrtc/membershipData/index.ts";
const mockFocus = { type: "mock" };
@@ -64,6 +70,8 @@ describe("MatrixRTCSession", () => {
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
client.stopClient();
client.matrixRTC.stop();
if (sess) await sess.stop();
@@ -91,17 +99,50 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
testCreateSticky: true,
createWithDefaults: false,
},
{
listenForStickyEvents: true,
listenForMemberStateEvents: false,
testCreateSticky: true,
createWithDefaults: false,
},
])(
"roomsessionForSlot listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky",
(testConfig) => {
function generateMembership(
opts: { type: string; callId?: string; createdTs?: number; expires?: number; deviceId?: string } = {
type: "m.call",
},
): MembershipData {
if (testConfig.testCreateSticky) {
// Ignoring createdTs, expires which are legacy
return {
...rtcMembershipTemplate,
member: {
...rtcMembershipTemplate.member,
device_id: opts.deviceId ?? rtcMembershipTemplate.member.device_id,
},
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
application: {
...rtcMembershipTemplate.application,
type: opts.type,
},
} satisfies RtcMembershipData & { user_id: string };
}
return {
...sessionMembershipTemplate,
application: opts.type,
device_id: opts.deviceId ?? sessionMembershipTemplate.device_id,
call_id: opts.callId ?? sessionMembershipTemplate.call_id,
created_ts: opts.createdTs,
expires: opts.expires,
} satisfies SessionMembershipData & { user_id: string };
}
it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, async () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
mockRoom.getLiveTimeline.mockReturnValue({
getState: vi.fn().mockReturnValue(undefined),
} as unknown as EventTimeline);
@@ -111,8 +152,8 @@ describe("MatrixRTCSession", () => {
const stateWarningWasLogged = () =>
warnLogSpy.mock.calls.find((call) => (call[1] as string).includes("Couldn't get state for room"));
MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
await flushPromises();
const sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig);
await sess.initialMembershipCalculated;
if (testConfig.listenForMemberStateEvents) {
// eslint-disable-next-line @vitest/no-conditional-expect
@@ -124,7 +165,7 @@ describe("MatrixRTCSession", () => {
});
it("creates a room-scoped session from room state", async () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
sess = MatrixRTCSession.sessionForSlot(
client,
@@ -132,18 +173,18 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].scope).toEqual(testConfig.testCreateSticky ? undefined : "m.room");
expect(sess?.memberships[0].applicationData).toEqual({ type: "m.call" });
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.slotDescription.id).toEqual("ROOM");
});
it("ignores memberships where application is not m.call", () => {
const testMembership = Object.assign({}, membershipTemplate, {
const testMembership = Object.assign({}, sessionMembershipTemplate, {
application: "not-m.call",
});
const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky);
@@ -157,7 +198,7 @@ describe("MatrixRTCSession", () => {
});
it("ignores memberships where callId is not empty", () => {
const testMembership = Object.assign({}, membershipTemplate, {
const testMembership = Object.assign({}, sessionMembershipTemplate, {
call_id: "not-empty",
scope: "m.room",
});
@@ -171,12 +212,10 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships).toHaveLength(0);
});
it("ignores expired memberships events", async () => {
it("ignores expired memberships events if legacy session", async () => {
vi.useFakeTimers();
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.expires = 1000;
expiredMembership.device_id = "EXPIRED";
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky);
const expiredMembership = generateMembership({ type: "m.call", expires: 1000, deviceId: "EXPIRED" });
const mockRoom = makeMockRoom([generateMembership(), expiredMembership], testConfig.testCreateSticky);
vi.advanceTimersByTime(2000);
sess = MatrixRTCSession.sessionForSlot(
@@ -184,15 +223,14 @@ describe("MatrixRTCSession", () => {
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
expect(sess?.memberships.length).toEqual(1);
)!;
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(testConfig.testCreateSticky ? 2 : 1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
vi.useRealTimers();
});
it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
it("ignores memberships events of members not in the room", async () => {
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
sess = MatrixRTCSession.sessionForSlot(
client,
@@ -200,12 +238,16 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(0);
});
it("ignores memberships events with no sender", () => {
it("ignores memberships events with no sender", async () => {
// Force the sender to be undefined.
const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky);
const mockRoom = makeMockRoom(
[{ ...sessionMembershipTemplate, user_id: "" }],
testConfig.testCreateSticky,
);
mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join);
sess = MatrixRTCSession.sessionForSlot(
client,
@@ -213,15 +255,14 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(0);
});
it("honours created_ts", async () => {
vi.useFakeTimers();
vi.setSystemTime(500);
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.created_ts = 500;
expiredMembership.expires = 1000;
const expiredMembership = generateMembership({ type: "m.call", createdTs: 500, expires: 1000 });
const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky);
sess = MatrixRTCSession.sessionForSlot(
client,
@@ -229,12 +270,13 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500);
vi.useRealTimers();
await sess.initialMembershipCalculated;
expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(
testConfig.testCreateSticky ? undefined : 1500,
);
});
it("returns empty session if no membership events are present", () => {
it("returns empty session if no membership events are present", async () => {
const mockRoom = makeMockRoom([], testConfig.testCreateSticky);
sess = MatrixRTCSession.sessionForSlot(
client,
@@ -242,6 +284,7 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await sess.initialMembershipCalculated;
expect(sess?.memberships).toHaveLength(0);
});
@@ -317,8 +360,8 @@ describe("MatrixRTCSession", () => {
expect(sess.memberships).toHaveLength(0);
});
it("ignores memberships with no device_id", () => {
const testMembership = Object.assign({}, membershipTemplate);
it("ignores memberships with no device_id", async () => {
const testMembership = Object.assign({}, sessionMembershipTemplate);
(testMembership.device_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
const sess = MatrixRTCSession.sessionForSlot(
@@ -327,11 +370,12 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await sess.initialMembershipCalculated;
expect(sess.memberships).toHaveLength(0);
});
it("ignores memberships with no call_id", () => {
const testMembership = Object.assign({}, membershipTemplate);
it("ignores memberships with no call_id", async () => {
const testMembership = Object.assign({}, sessionMembershipTemplate);
(testMembership.call_id as string | undefined) = undefined;
const mockRoom = makeMockRoom([testMembership]);
sess = MatrixRTCSession.sessionForSlot(
@@ -340,21 +384,30 @@ describe("MatrixRTCSession", () => {
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await sess.initialMembershipCalculated;
expect(sess.memberships).toHaveLength(0);
});
it("assigns RTC backend identities to memberships", async () => {
const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky);
const mockRoom = makeMockRoom([generateMembership()], testConfig.testCreateSticky);
sess = MatrixRTCSession.sessionForSlot(
client,
mockRoom,
callSession,
testConfig.createWithDefaults ? undefined : testConfig,
);
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(1);
// Backend identity is expected to not be hashed with a legacy (session) membership
expect(sess?.memberships[0].rtcBackendIdentity).toEqual("@mock:user.example:AAAAAAA");
expect(sess?.memberships[0].rtcBackendIdentity).toEqual(
testConfig.testCreateSticky
? await computeRtcIdentityRaw(
rtcMembershipTemplate.member.user_id,
rtcMembershipTemplate.member.device_id,
rtcMembershipTemplate.member.id,
)
: "@mock:user.example:AAAAAAA",
);
});
},
);
@@ -362,14 +415,15 @@ describe("MatrixRTCSession", () => {
describe("roomsessionForSlot combined state", () => {
it("perfers sticky events when both membership and sticky events appear for the same user", async () => {
// Create a room with identical member state and sticky state for the same user.
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([rtcMembershipTemplate]);
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
const ev = mockRTCEvent(
{
...membershipTemplate,
msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`,
...rtcMembershipTemplate,
msc4354_sticky_key: `_${rtcMembershipTemplate.user_id}_${rtcMembershipTemplate.member.device_id}`,
},
mockRoom.roomId,
5000,
);
return [ev as StickyMatrixEvent];
});
@@ -379,10 +433,10 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].slotDescription.id).toEqual("ROOM");
expect(sess?.memberships[0].scope).toEqual("m.room");
expect(sess?.memberships[0].scope).toEqual(undefined);
expect(sess?.memberships[0].application).toEqual("m.call");
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].isExpired()).toEqual(false);
@@ -390,14 +444,18 @@ describe("MatrixRTCSession", () => {
});
it("combines sticky and membership events when both exist", async () => {
// Create a room with identical member state and sticky state for the same user.
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
const stickyUserId = "@stickyev:user.example";
mockRoom._unstable_getStickyEvents.mockImplementation(() => {
const ev = mockRTCEvent(
{
...membershipTemplate,
...rtcMembershipTemplate,
member: {
...rtcMembershipTemplate.member,
user_id: stickyUserId,
},
user_id: stickyUserId,
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`,
},
mockRoom.roomId,
15000,
@@ -410,37 +468,42 @@ describe("MatrixRTCSession", () => {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
await sess.initialMembershipCalculated;
const memberships = sess.memberships;
expect(memberships.length).toEqual(2);
expect(memberships[0].sender).toEqual(stickyUserId);
expect(memberships[0].slotDescription.id).toEqual("ROOM");
expect(memberships[0].scope).toEqual("m.room");
expect(memberships[0].application).toEqual("m.call");
expect(memberships[0].scope).toEqual(undefined);
expect(memberships[0].applicationData).toEqual({ type: "m.call" });
expect(memberships[0].deviceId).toEqual("AAAAAAA");
expect(memberships[0].isExpired()).toEqual(false);
// Then state
expect(memberships[1].sender).toEqual(membershipTemplate.user_id);
expect(memberships[1].sender).toEqual(sessionMembershipTemplate.user_id);
expect(sess?.slotDescription.id).toEqual("ROOM");
});
it("handles an incoming sticky event to an existing session", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate], false);
const stickyUserId = "@stickyev:user.example";
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, {
listenForStickyEvents: true,
listenForMemberStateEvents: true,
});
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess.memberships.length).toEqual(1);
const membershipRecalculated = new Promise((r) => sess?.once(MatrixRTCSessionEvent.MembershipsChanged, r));
const stickyEv = mockRTCEvent(
{
...membershipTemplate,
...rtcMembershipTemplate,
member: {
...rtcMembershipTemplate.member,
user_id: stickyUserId,
},
user_id: stickyUserId,
msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`,
msc4354_sticky_key: `_${stickyUserId}_${rtcMembershipTemplate.member.device_id}`,
},
mockRoom.roomId,
15000,
@@ -450,7 +513,7 @@ describe("MatrixRTCSession", () => {
return [stickyEv];
});
mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []);
await flushPromises();
await membershipRecalculated;
expect(sess.memberships.length).toEqual(2);
});
});
@@ -460,13 +523,13 @@ describe("MatrixRTCSession", () => {
vi.useFakeTimers();
vi.setSystemTime(4000);
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, { device_id: "foo", created_ts: 3000 }),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "foo", created_ts: 3000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess.getOldestMembership()!.deviceId).toEqual("old");
vi.useRealTimers();
});
@@ -483,12 +546,12 @@ describe("MatrixRTCSession", () => {
vi.useFakeTimers();
vi.setSystemTime(4000);
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, { "m.call.intent": intentA }),
Object.assign({}, membershipTemplate, { "m.call.intent": intentB }),
Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentA }),
Object.assign({}, sessionMembershipTemplate, { "m.call.intent": intentB }),
]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
expect(sess.getConsensusCallIntent()).toEqual(result);
vi.useRealTimers();
});
@@ -505,13 +568,13 @@ describe("MatrixRTCSession", () => {
vi.useFakeTimers();
vi.setSystemTime(3000);
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
Object.assign({}, sessionMembershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_preferred: [firstPreferredFocus],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
@@ -550,13 +613,13 @@ describe("MatrixRTCSession", () => {
});
it("does not provide focus if the selection method is unknown", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
Object.assign({}, sessionMembershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_preferred: [firstPreferredFocus],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, sessionMembershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
@@ -590,7 +653,7 @@ describe("MatrixRTCSession", () => {
mockRoom = makeMockRoom([]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
});
afterEach(async () => {
@@ -630,7 +693,7 @@ describe("MatrixRTCSession", () => {
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [{ ...membershipTemplate, user_id: client.getUserId()! }]);
mockRoomState(mockRoom, [{ ...sessionMembershipTemplate, user_id: client.getUserId()! }]);
await sess!._onRTCSessionMemberUpdate();
const ownMembershipId = sess?.memberships[0].eventId;
@@ -679,7 +742,7 @@ describe("MatrixRTCSession", () => {
mockRoomState(mockRoom, [
{
...membershipTemplate,
...sessionMembershipTemplate,
"user_id": client.getUserId()!,
// This is what triggers the intent type on the notification event.
"m.call.intent": "audio",
@@ -720,13 +783,16 @@ describe("MatrixRTCSession", () => {
it("doesn't send a notification when joining an existing call", async () => {
// Add another member to the call so that it is considered an existing call
mockRoomState(mockRoom, [membershipTemplate]);
mockRoomState(mockRoom, [sessionMembershipTemplate]);
await sess!._onRTCSessionMemberUpdate();
// Simulate a join, including the update to the room state
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { notificationType: "ring" });
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
mockRoomState(mockRoom, [
sessionMembershipTemplate,
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
]);
await sess!._onRTCSessionMemberUpdate();
// check we send out join event
@@ -741,9 +807,12 @@ describe("MatrixRTCSession", () => {
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 5000))]);
// But this time we want to simulate a race condition in which we receive a state event
// from someone else, starting the call before our own state event has been sent
mockRoomState(mockRoom, [membershipTemplate]);
mockRoomState(mockRoom, [sessionMembershipTemplate]);
await sess!._onRTCSessionMemberUpdate();
mockRoomState(mockRoom, [membershipTemplate, { ...membershipTemplate, user_id: client.getUserId()! }]);
mockRoomState(mockRoom, [
sessionMembershipTemplate,
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
]);
await sess!._onRTCSessionMemberUpdate();
// check we send out join event
@@ -758,9 +827,9 @@ describe("MatrixRTCSession", () => {
describe("onMembershipsChanged", () => {
it("only emit if membership changes", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
const onMembershipsChanged = vi.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
@@ -940,12 +1009,12 @@ describe("MatrixRTCSession", () => {
vi.useFakeTimers();
try {
// session with two members
const member2 = Object.assign({}, membershipTemplate, {
const member2 = Object.assign({}, sessionMembershipTemplate, {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
// joining will trigger an initial key send
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
@@ -959,14 +1028,14 @@ describe("MatrixRTCSession", () => {
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(1);
// member2 leaves triggering key rotation
mockRoomState(mockRoom, [membershipTemplate]);
mockRoomState(mockRoom, [sessionMembershipTemplate]);
await sess._onRTCSessionMemberUpdate();
// member2 re-joins which should trigger an immediate re-send
const keysSentPromise2 = new Promise<EncryptionKeysEventContent>((resolve) => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
mockRoomState(mockRoom, [sessionMembershipTemplate, member2]);
await sess._onRTCSessionMemberUpdate();
// but, that immediate resend is throttled so we need to wait a bit
vi.advanceTimersByTime(1000);
@@ -992,7 +1061,7 @@ describe("MatrixRTCSession", () => {
it("re-sends key if a new member joins", async () => {
vi.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
const keysSentPromise1 = new Promise<EncryptionKeysEventContent>((resolve) => {
@@ -1013,11 +1082,11 @@ describe("MatrixRTCSession", () => {
const onMembershipsChanged = vi.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
const member2 = Object.assign({}, membershipTemplate, {
const member2 = Object.assign({}, sessionMembershipTemplate, {
device_id: "BBBBBBB",
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
mockRoomState(mockRoom, [sessionMembershipTemplate, member2]);
await sess._onRTCSessionMemberUpdate();
await keysSentPromise2;
@@ -1036,8 +1105,8 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
const member1 = membershipTemplate;
const member2 = Object.assign({}, membershipTemplate, {
const member1 = sessionMembershipTemplate;
const member2 = Object.assign({}, sessionMembershipTemplate, {
device_id: "BBBBBBB",
});
@@ -1081,16 +1150,16 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
const member1 = { ...membershipTemplate, created_ts: 1000 };
const member1 = { ...sessionMembershipTemplate, created_ts: 1000 };
const member2 = {
...membershipTemplate,
...sessionMembershipTemplate,
created_ts: 1000,
device_id: "BBBBBBB",
};
const mockRoom = makeMockRoom([member1, member2]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
@@ -1150,12 +1219,12 @@ describe("MatrixRTCSession", () => {
vi.useFakeTimers();
try {
const KEY_DELAY = 3000;
const member2 = Object.assign({}, membershipTemplate, {
const member2 = Object.assign({}, sessionMembershipTemplate, {
device_id: "BBBBBBB",
});
const mockRoom = makeMockRoom([membershipTemplate, member2]);
const mockRoom = makeMockRoom([sessionMembershipTemplate, member2]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
const onMyEncryptionKeyChanged = vi.fn();
sess.on(
MatrixRTCSessionEvent.EncryptionKeyChanged,
@@ -1189,7 +1258,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
mockRoomState(mockRoom, [membershipTemplate]);
mockRoomState(mockRoom, [sessionMembershipTemplate]);
await sess._onRTCSessionMemberUpdate();
vi.advanceTimersByTime(KEY_DELAY);
@@ -1236,7 +1305,7 @@ describe("MatrixRTCSession", () => {
const membersToTest = 258;
const members: MembershipData[] = [];
for (let i = 0; i < membersToTest; i++) {
members.push(Object.assign({}, membershipTemplate, { device_id: `DEVICE${i}` }));
members.push(Object.assign({}, sessionMembershipTemplate, { device_id: `DEVICE${i}` }));
}
vi.useFakeTimers();
try {
@@ -1251,7 +1320,7 @@ describe("MatrixRTCSession", () => {
if (i === 0) {
// if first time around then set up the session
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
manageMediaKeys: true,
});
@@ -1278,9 +1347,9 @@ describe("MatrixRTCSession", () => {
const realSetTimeout = setTimeout;
vi.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
const keysSentPromise1 = new Promise((resolve) => {
sendEventMock.mockImplementation(resolve);
@@ -1295,11 +1364,11 @@ describe("MatrixRTCSession", () => {
const onMembershipsChanged = vi.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
const member2 = Object.assign({}, membershipTemplate, {
const member2 = Object.assign({}, sessionMembershipTemplate, {
device_id: "BBBBBBB",
});
mockRoomState(mockRoom, [membershipTemplate, member2]);
mockRoomState(mockRoom, [sessionMembershipTemplate, member2]);
await sess._onRTCSessionMemberUpdate();
await new Promise((resolve) => {
@@ -1320,10 +1389,10 @@ describe("MatrixRTCSession", () => {
sendToDeviceMock.mockImplementation(resolve);
});
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
await sess.initialMembershipCalculated;
sess.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, {
manageMediaKeys: true,
useExperimentalToDeviceTransport: true,
});
@@ -1342,17 +1411,17 @@ describe("MatrixRTCSession", () => {
});
describe("receiving", () => {
beforeAll(() => {
beforeEach(() => {
vi.useFakeTimers();
});
afterAll(() => {
afterEach(() => {
vi.useRealTimers();
});
it("collects keys from encryption events", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
@@ -1381,9 +1450,9 @@ describe("MatrixRTCSession", () => {
});
it("collects keys at non-zero indices", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
await flushPromises();
await sess.initialMembershipCalculated;
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", {
@@ -1413,7 +1482,7 @@ describe("MatrixRTCSession", () => {
});
it("collects keys by merging", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
@@ -1479,7 +1548,7 @@ describe("MatrixRTCSession", () => {
});
it("ignores older keys at same index", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
@@ -1543,7 +1612,7 @@ describe("MatrixRTCSession", () => {
});
it("key timestamps are treated as monotonic", async () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
mockRoom.emitTimelineEvent(
@@ -1592,7 +1661,7 @@ describe("MatrixRTCSession", () => {
});
it("ignores keys event for the local participant", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
sess!.joinRTCSession(owmMemberIdentity, [mockFocus], mockFocus, { manageMediaKeys: true });
@@ -1615,7 +1684,7 @@ describe("MatrixRTCSession", () => {
it("tracks total age statistics for collected keys", async () => {
vi.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
// defaults to getTs()
@@ -1671,7 +1740,7 @@ describe("MatrixRTCSession", () => {
});
describe("read status", () => {
it("returns the correct probablyLeft status", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
expect(sess!.probablyLeft).toBe(undefined);
@@ -1687,7 +1756,7 @@ describe("MatrixRTCSession", () => {
});
it("returns membershipStatus once joinRTCSession got called", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
const mockRoom = makeMockRoom([sessionMembershipTemplate]);
sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession);
expect(sess!.membershipStatus).toBe(undefined);
@@ -1696,7 +1765,7 @@ describe("MatrixRTCSession", () => {
});
});
it("reemits membershipManager events", () => {
sess = MatrixRTCSession.sessionForSlot(client, makeMockRoom([membershipTemplate]), callSession);
sess = MatrixRTCSession.sessionForSlot(client, makeMockRoom([sessionMembershipTemplate]), callSession);
const delayIdChanged = vi.fn();
sess.on(MembershipManagerEvent.DelayIdChanged, delayIdChanged);
const statusChanged = vi.fn();
@@ -16,23 +16,50 @@ limitations under the License.
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
import {
makeMockRoom,
type MembershipData,
sessionMembershipTemplate,
mockRoomState,
mockRTCEvent,
rtcMembershipTemplate,
} from "./mocks.ts";
import { logger } from "../../../src/logger";
import { flushPromises } from "../../test-utils/flushPromises";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
function generateMembership(opts: { type: string; callId?: string } = { type: "m.call" }): MembershipData {
if (eventKind === "sticky") {
return {
...rtcMembershipTemplate,
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
application: {
...rtcMembershipTemplate.application,
type: opts.type,
},
} satisfies RtcMembershipData & { user_id: string };
}
return {
...sessionMembershipTemplate,
application: opts.type,
call_id: opts.callId ?? sessionMembershipTemplate.call_id, // approximate version.
} satisfies SessionMembershipData & { user_id: string };
}
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
if (eventKind === "memberState") {
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
mockRoomState(room, [{ user_id: sessionMembershipTemplate.user_id }]);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
} else {
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
membershipData.splice(0, 1, { user_id: sessionMembershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
@@ -46,22 +73,17 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
vi.resetAllMocks();
});
it("Fires event when session starts", async () => {
const onStarted = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
const room1 = makeMockRoom([generateMembership({ type: "m.call" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
client.emit(ClientEvent.Room, room1);
await expect(sessionStartedPromise).resolves.toBeTruthy();
});
it("Doesn't fire event if unrelated sessions starts", () => {
@@ -69,7 +91,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
const room1 = makeMockRoom([generateMembership({ type: "m.other" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
@@ -80,17 +102,24 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
});
it("Fires event when session ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
const membershipData: MembershipData[] = [generateMembership()];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await flushPromises();
await sessionStartedPromise;
await sendLeaveMembership(room1, membershipData);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
await expect(sessionEndedPromise).resolves.toStrictEqual([
room1.roomId,
client.matrixRTC.getActiveRoomSession(room1),
]);
});
it("Fires correctly with custom sessionDescription", async () => {
@@ -106,48 +135,44 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
const sessionStartedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.other, we ignore this session because it lacks a call_id
const room1MembershipData: MembershipData[] = [generateMembership({ type: "m.other" })];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has matching call_id
const room2MembershipData: MembershipData[] = [generateMembership({ type: "m.notCall", callId: "test" })];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
await sessionStartedPromise;
// Stop room1's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
// Stop room2's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
await sessionEndedPromise;
});
it("Doesn't fire event if unrelated sessions ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const membership: MembershipData[] = [generateMembership({ type: "m.other_app" })];
const room1 = makeMockRoom(membership, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
+21 -17
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,15 +25,16 @@ import {
type Room,
MAX_STICKY_DURATION_MS,
} from "../../../src";
import { MembershipManagerEvent, Status, type Transport, type LivekitFocusSelection } from "../../../src/matrixrtc";
import {
MembershipManagerEvent,
Status,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
makeMockClient,
makeMockRoom,
sessionMembershipTemplate,
mockCallMembership,
type MockClient,
} from "./mocks.ts";
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
/**
* Create a promise that will resolve once a mocked method is called.
@@ -90,7 +91,7 @@ describe("MembershipManager", () => {
// Default to fake timers.
vi.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom([membershipTemplate]);
room = makeMockRoom([sessionMembershipTemplate]);
// Provide a default mock that is like the default "non error" server behaviour.
vi.mocked(client._unstable_sendDelayedStateEvent).mockResolvedValue({ delay_id: "id" });
vi.mocked(client._unstable_updateDelayedEvent).mockResolvedValue({});
@@ -400,7 +401,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(RESTART_DELAY);
// first simulate the sync, then resolve sending the delayed event.
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
// Let the scheduler run one iteration so that the new join gets sent
await vi.runOnlyPendingTimersAsync();
@@ -506,7 +507,7 @@ describe("MembershipManager", () => {
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
@@ -529,7 +530,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(sessionMembershipTemplate, room.roomId),
mockCallMembership(
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
room.roomId,
@@ -555,7 +556,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
// Our own membership is removed:
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersByTimeAsync(1);
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
@@ -578,7 +579,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(10_000);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
await vi.advanceTimersByTimeAsync(10_000);
@@ -950,7 +951,10 @@ describe("MembershipManager", () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
const membership = mockCallMembership(
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
@@ -964,7 +968,7 @@ describe("MembershipManager", () => {
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
{ ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
@@ -1039,7 +1043,7 @@ describe("MembershipManager", () => {
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const room = makeMockRoom([sessionMembershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@ import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManage
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { logger } from "../../../src/logger.ts";
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
@@ -983,7 +983,7 @@ describe("RTCEncryptionManager", () => {
rtcBackendIdentity: string,
): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
rtcBackendIdentity,
);
@@ -998,7 +998,7 @@ describe("RTCEncryptionManager", () => {
*/
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
`${userId}|${deviceId}`,
);
+3 -3
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "vitest";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
import { makeMockEvent, makeMockRoom, sessionMembershipTemplate, makeKey } from "./mocks.ts";
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
import { EventType, MatrixClient, RoomEvent } from "../../../src";
@@ -49,7 +49,7 @@ describe("RoomKeyTransport", () => {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
room = makeMockRoom([membershipTemplate]);
room = makeMockRoom([sessionMembershipTemplate]);
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
transport = new RoomKeyTransport(room, client, statistics, {
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "vitest";
import { makeMockEvent } from "./mocks";
import { makeMockEvent } from "./mocks.ts";
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
+39 -6
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -18,12 +18,12 @@ import { EventEmitter } from "stream";
import { type Mocked, type MockedObject } from "vitest";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { CallMembership } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string };
export const owmMemberIdentity: CallMembershipIdentityParts = {
deviceId: "AAAAAAA",
@@ -31,7 +31,7 @@ export const owmMemberIdentity: CallMembershipIdentityParts = {
userId: "@alice:example.org",
};
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = {
application: "m.call",
call_id: "",
user_id: "@mock:user.example",
@@ -52,6 +52,39 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = {
],
};
export const rtcMembershipTemplate: RtcMembershipData & { user_id: string } = {
user_id: "@mock:user.example",
application: {
type: "m.call",
},
member: {
id: "IDIDID",
user_id: "@mock:user.example",
device_id: "AAAAAAA",
},
slot_id: "m.call#ROOM",
versions: [],
rtc_transports: [
{
type: "livekit",
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.io",
type: "livekit",
},
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.dev",
type: "livekit",
},
],
},
],
msc4354_sticky_key: "m.call#",
};
export type MockClient = MockedObject<
Pick<
MatrixClient,
@@ -198,7 +231,7 @@ export function mockCallMembership(
const ev = mockRTCEvent(membershipData, roomId);
vi.mocked(ev.getContent).mockReturnValue(membershipData);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx");
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+2 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -60,7 +60,7 @@ import {
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
+169 -383
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,16 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MXID_PATTERN } from "../models/room-member.ts";
import { deepCompare } from "../utils.ts";
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import type { RTCCallIntent, Transport } from "./types.ts";
import { type MatrixEvent, type IContent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { sha256 } from "../digest.ts";
import { encodeUnpaddedBase64 } from "../base64.ts";
import { type Logger } from "../logger.ts";
import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Logger, logger } from "../logger.ts";
import { computeSlotId, slotIdToDescription } from "./utils.ts";
import {
checkRtcMembershipData,
computeRtcIdentityRaw,
type RtcMembershipData,
checkSessionsMembershipData,
type SessionMembershipData,
MatrixRTCMembershipParseError,
} from "./membershipData/index.ts";
import { EventType } from "../@types/event.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
@@ -32,331 +36,118 @@ import { type Logger } from "../logger.ts";
*/
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user";
type Member = {
user_id: string;
device_id: string;
/**
* Describes the source event type that provided the membership data.
*/
enum MembershipKind {
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
* The modern MSC4143 format event.
*/
id: string;
};
export interface RtcMembershipData {
"slot_id": string;
"member": Member;
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": {
type: string;
// other application specific keys
[key: string]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
RTC = "rtc",
/**
* The legacy call event type.
*/
Session = "session",
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
type MembershipData =
| { kind: MembershipKind.RTC; data: RtcMembershipData }
| { kind: MembershipKind.Session; data: SessionMembershipData };
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else {
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
return errors.length === 0;
};
/**
* MSC4143 (MatrixRTC) session membership data.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
*/
"focus_active": LivekitFocusSelection;
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: CallScope;
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
*/
"msc4354_sticky_key"?: string;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
const prefix = " - ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
return errors.length === 0;
};
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
type LimitedEvent = Pick<MatrixEvent, "getId" | "getSender" | "getTs" | "getType" | "getContent">;
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion.
export class CallMembership {
/**
* Parse the membershipdata from a call membership event.
* @param matrixEvent The Matrix event to read.
* @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format.
* @throws If the content is neither format.
*/
public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData {
const sender = matrixEvent.getSender();
const evType = matrixEvent.getType();
const data = matrixEvent.getContent();
if (sender === undefined) throw new Error("matrixEvent is missing sender field");
try {
// Event types are strictly checked here.
if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) {
return { kind: MembershipKind.RTC, data };
} else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) {
return { kind: MembershipKind.Session, data };
} else {
throw Error(`'${evType} is not a known call membership type`);
}
} catch (ex) {
if (ex instanceof MatrixRTCMembershipParseError) {
logger.debug("CallMembership.MatrixRTCMembershipParseError provided invalid data", data);
}
throw ex;
}
}
/**
* Parse the contents of a MatrixEvent and create a CallMembership instance.
* @param matrixEvent The Matrix event to read.
*/
public static async parseFromEvent(matrixEvent: LimitedEvent): Promise<CallMembership> {
const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent);
const rtcBackendIdentity =
membershipData.kind === MembershipKind.RTC
? await computeRtcIdentityRaw(
membershipData.data.member.user_id,
membershipData.data.member.device_id,
membershipData.data.member.id,
)
: `${matrixEvent.getSender()}:${membershipData.data.device_id}`;
return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity);
}
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private logger?: Logger;
private logger: Logger;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
public constructor(
/** The required parts of the Matrix event that this membership is based on */
matrixEvent: Pick<MatrixEvent, "getId" | "getSender" | "getTs">,
/**
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
*
*/
private readonly membershipData: MembershipData,
/**
*
* Anonymized identity to use with the RTC backend.
*
* The rtcBackendIdentity is a hashed version of all the identity parts:
* `sha256(${this.userId}|${this.deviceId}|${this.memberId})`
*
* It is used to anonymize the identity of the user in the RTC backend.
*/
public readonly rtcBackendIdentity: string,
/**
* The constructor will automatically create a properly tagged child logger instance.
*/
logger?: Logger,
) {
const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()];
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
this.matrixEventData = { eventId, sender, ts };
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
}
private readonly matrixEventData: { eventId: string; sender: string };
/**
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
* `${this.userId}:${this.deviceId}` for state events (kind = session)
* Use `parseFromEvent`.
* Constructor should only be used by tests.
* @private
* @param matrixEvent
* @param membershipData
* @param rtcBackendIdentity
*/
public static async computeRtcBackendIdentity(
matrixEvent: Pick<MatrixEvent, "getSender">,
membershipData: MembershipData,
): Promise<string> {
const { kind, data } = membershipData;
switch (kind) {
case "rtc": {
return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id);
}
case "session":
return `${matrixEvent.getSender()}:${data.device_id}`;
}
}
public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
return encodeUnpaddedBase64(await sha256(`${userId}|${deviceId}|${memberId}`));
}
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
public constructor(
/** The Matrix event that this membership is based on */
private readonly matrixEvent: LimitedEvent,
private readonly membershipData: MembershipData,
public readonly rtcBackendIdentity: string,
) {
const eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
const sessionErrors: string[] = [];
const rtcErrors: string[] = [];
if (checkSessionsMembershipData(content, sessionErrors)) {
return { kind: "session", data: content };
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
return { kind: "rtc", data: content };
} else {
const details =
sessionErrors.length < rtcErrors.length
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.user_id;
case "session":
case MembershipKind.Session:
default:
return this.matrixEventData.sender;
}
@@ -374,11 +165,11 @@ export class CallMembership {
const { kind, data } = this.membershipData;
if (data.application === "m.call") {
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.slot_id;
case "session":
case MembershipKind.Session:
default: {
const [application, id] = [this.application, data.call_id];
const [application, id] = [data.application, data.call_id];
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
@@ -400,7 +191,7 @@ export class CallMembership {
} else {
compatibilityAdaptedId = id;
}
return slotDescriptionToId({
return computeSlotId({
application,
id: compatibilityAdaptedId,
});
@@ -412,89 +203,82 @@ export class CallMembership {
// This is what the function should look like for any other application that did not
// go through a `""`=> `"ROOM"` rename
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.slot_id;
case "session":
default: {
const [application, id] = [this.application, data.call_id];
return slotDescriptionToId({ application, id });
}
case MembershipKind.Session:
default:
return computeSlotId({ application: data.application, id: data.call_id });
}
}
public get deviceId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.device_id;
case "session":
case MembershipKind.Session:
default:
return data.device_id;
}
}
public get callIntent(): RTCCallIntent | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc": {
const intent = data.application["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger?.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
default:
return data["m.call.intent"];
const intent = this.applicationData["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger.warn("RTC membership has invalid m.call.intent");
return undefined;
}
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
const { kind, data } = this.membershipData;
if (kind === MembershipKind.RTC) {
const id = data.slot_id.slice(`${data.application.type}#`.length);
return { application: data.application.type, id };
}
return slotIdToDescription(this.slotId);
}
/**
* The application `type`.
* @deprecated Use @see applicationData
*/
public get application(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
return this.applicationData.type;
}
/**
* Information about the application being used for the RTC session.
* May contain extra keys specific to the application.
*/
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.application;
case "session":
case MembershipKind.Session:
default:
// SessionData does not have application data as such. We return specific
// properties in use by other getters in this class, for compatibility.
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): CallScope | undefined {
public get scope(): SessionMembershipData["scope"] | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
return data.scope;
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
/**
* This computes the membership ID for the membership.
@@ -519,25 +303,33 @@ export class CallMembership {
case "rtc":
return data.member.id;
case "session":
default:
return (
// best case we have a client already publishing the right custom membershipId
data.membershipID ??
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
`${this.matrixEventData.sender}:${data.device_id}`
);
default:
throw Error("Not possible to get memberID without knowing the membership event kind");
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
public createdTs(): number {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEventData.ts;
case "session":
return this.matrixEvent.getTs();
case MembershipKind.Session:
default:
return data.created_ts ?? this.matrixEventData.ts;
return data.created_ts ?? this.matrixEvent.getTs();
}
}
@@ -548,9 +340,9 @@ export class CallMembership {
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
@@ -559,19 +351,20 @@ export class CallMembership {
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
* @deprecated Not used by RTC events.
*/
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
if (kind === MembershipKind.Session) {
const absExpiry = this.getAbsoluteExpiry();
if (absExpiry) {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry()! - Date.now();
return absExpiry - Date.now();
}
}
return undefined;
}
/**
@@ -580,9 +373,9 @@ export class CallMembership {
public isExpired(): boolean {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return false;
case "session":
case MembershipKind.Session:
default:
return this.getMsUntilExpiry()! <= 0;
}
@@ -608,30 +401,26 @@ export class CallMembership {
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports[0];
case "session":
case MembershipKind.Session:
switch (data.focus_active.focus_selection) {
case "multi_sfu":
return data.foci_preferred[0];
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
case "multi_sfu":
return data.foci_preferred[0];
default:
// `focus_selection` not understood.
return undefined;
}
break;
default:
return undefined;
}
return undefined;
}
/**
* The focus_active filed of the session membership (m.call.member).
* @deprecated focus_active is not used and will be removed in future versions.
*/
public getFocusActive(): LivekitFocusSelection | undefined {
const { kind, data } = this.membershipData;
if (kind === "session") return data.focus_active;
return undefined;
}
/**
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
@@ -639,14 +428,11 @@ export class CallMembership {
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports;
case "session":
case MembershipKind.Session:
default:
return data.foci_preferred;
}
}
public get kind(): MembershipData["kind"] {
return this.membershipData.kind;
}
}
+51 -63
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2023 - 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -34,6 +34,7 @@ import type {
IRTCNotificationContent,
RTCCallIntent,
Transport,
SlotDescription,
} from "./types.ts";
import {
MembershipManagerEvent,
@@ -46,6 +47,7 @@ import { TypedReEmitter } from "../ReEmitter.ts";
import { type IContent, type MatrixEvent } from "../models/event.ts";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { computeSlotId } from "./utils.ts";
/**
* Events emitted by MatrixRTCSession
@@ -96,21 +98,6 @@ export interface SessionConfig {
callIntent?: RTCCallIntent;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
id: string;
application: string;
}
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id] = slotId.split("#");
return { application, id };
}
export function slotDescriptionToId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
@@ -275,6 +262,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
public memberships: CallMembership[] = [];
/**
* Resolves when the session has calculated the initial membership of the session.
*/
public readonly initialMembershipCalculated: Promise<void>;
/**
* Does membership need to be recalculated? This is set to false upon
* recalculation.
*/
private membershipNeedsRecalculation = false;
/**
* The statistics for this session.
*/
@@ -315,7 +312,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* The slotId is the property that, per definition, groups memberships into one call.
*/
public get slotId(): string | undefined {
return slotDescriptionToId(this.slotDescription);
return computeSlotId(this.slotDescription);
}
/**
@@ -326,18 +323,20 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
public static async sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
slotId: string,
slotDescription: SlotDescription,
// default both true this implied we combine sticky and state events for the final call state
// (prefer sticky events in case of a duplicate)
options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS,
): Promise<CallMembership[]> {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const logger = rootLogger.getChild(
`[MatrixRTCSession ${room.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
const callMemberEvents = collectMembersEvents(room, options, logger);
const callMemberships = await computeBackendIdentityAndVerifyMemberEvents(
room,
callMemberEvents,
slotId,
slotDescription,
logger,
);
@@ -419,14 +418,14 @@ export class MatrixRTCSession extends TypedEventEmitter<
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this.logger = rootLogger.getChild(
`[MatrixRTCSession ${roomSubset.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
this.roomSubset.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
// We can ignore this promise because `recalculateSessionMembers` will emit
// `MatrixRTCSessionEvent.MembershipsChanged` once it has completed.
this.ensureRecalculateSessionMembers();
this.initialMembershipCalculated = this.ensureRecalculateSessionMembers();
this.setExpiryTimer();
}
/*
@@ -620,13 +619,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
return oldestMembership?.getTransport(oldestMembership);
}
/**
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
* @deprecated does not work with m.rtc.member. Do not rely on it.
*/
public getActiveFocus(): Transport | undefined {
return this.getOldestMembership()?.getFocusActive();
}
public getOldestMembership(): CallMembership | undefined {
return this.memberships[0];
}
@@ -695,7 +687,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
this.expiryTimeout = setTimeout(() => void this.ensureRecalculateSessionMembers(), soonestExpiry);
}
}
@@ -747,7 +739,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* Call this when the Matrix room members have changed.
*/
private readonly onRoomMemberUpdate = (): void => {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
};
/**
@@ -763,7 +755,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
(e) => e.getType() === EventType.RTCMembership,
)
) {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
}
};
@@ -777,22 +769,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
};
// helper variables to make sure we do not have parallel running recalculations.
private recalculateSessionMembersPromise: Promise<void> = Promise.resolve();
private recalculateSessionMembersDirty = false;
private recalculateSessionMembersPromise: Promise<void> | undefined = undefined;
private ensureRecalculateSessionMembers(): void {
if (this.recalculateSessionMembersPromise === undefined) {
this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => {
this.recalculateSessionMembersPromise = undefined;
if (this.recalculateSessionMembersDirty) {
this.ensureRecalculateSessionMembers();
this.recalculateSessionMembersDirty = false;
}
});
} else {
this.recalculateSessionMembersDirty = true;
/**
* Ensures that membership is recalculated when the state of the session may have changed.
* Also ensures that only one recalculation is made at a time.
* @returns A promise resolving when the state has been recalculated.
*/
private ensureRecalculateSessionMembers(): Promise<void> {
if (this.membershipNeedsRecalculation) {
// We have already requested recalcuation, don't attempt a new one.
return this.recalculateSessionMembersPromise;
}
this.membershipNeedsRecalculation = true;
// Chain the recalculation.
this.recalculateSessionMembersPromise = this.recalculateSessionMembersPromise
.finally()
.then(() => this.recalculateSessionMembers());
return this.recalculateSessionMembersPromise;
}
/**
@@ -803,11 +797,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This function should be called when the room members or call memberships might have changed.
*/
private readonly recalculateSessionMembers = async (): Promise<void> => {
// Clear the flag.
this.membershipNeedsRecalculation = false;
const oldMemberships = this.memberships;
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
this.room,
slotDescriptionToId(this.slotDescription),
this.slotDescription,
this.calculateMembershipsOpts,
);
@@ -857,7 +853,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
async function computeBackendIdentityAndVerifyMemberEvents(
room: Pick<Room, "hasMembershipState">,
callMemberEvents: MatrixEvent[],
slotId: string,
slotDescription: SlotDescription,
logger: Logger,
): Promise<CallMembership[]> {
const callMemberships: CallMembership[] = [];
@@ -866,22 +862,14 @@ async function computeBackendIdentityAndVerifyMemberEvents(
const content = memberEvent.getContent();
// Quick filter to avoid unneeded processing of invalid events or left events.
// A more thorough validation will be done later with CallMembership.membershipDataFromMatrixEvent.
if (!quickFilterNonRelevantContents(content, logger)) {
continue;
}
try {
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
const membership = await CallMembership.parseFromEvent(memberEvent);
const membership = new CallMembership(
memberEvent,
membershipData,
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
logger,
);
if (isValidMembership(membership, room, slotId, logger)) {
if (isValidMembership(membership, room, slotDescription, logger)) {
callMemberships.push(membership);
}
} catch (e) {
@@ -914,12 +902,12 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
function isValidMembership(
membership: CallMembership,
room: Pick<Room, "hasMembershipState">,
slotId: string,
slotDescription: SlotDescription,
logger: Logger,
): boolean {
if (membership.slotId !== slotId) {
if (membership.slotDescription.id !== slotDescription.id) {
logger.info(
`Ignoring membership of user ${membership.userId} for a different slot: user: ${JSON.stringify(membership.slotDescription)}, slotId: ${slotId})`,
`Ignoring membership of user ${membership.userId} for a different slot. Theirs: ${JSON.stringify(membership.slotDescription)}, Expected: ${JSON.stringify(slotDescription)}`,
);
return false;
}
+5 -3
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
import { type SlotDescription } from "./types.ts";
import { computeSlotId } from "./utils.ts";
export enum MatrixRTCSessionManagerEvents {
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
@@ -59,7 +61,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
this.logger = rootLogger.getChild(`[MatrixRTCSessionManager ${computeSlotId(slotDescription)}]`);
}
public start(): void {
+14 -17
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,19 +22,9 @@ 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 RtcMembershipData,
type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION } from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status, type SlotDescription } from "./types.ts";
import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
@@ -43,6 +33,8 @@ import {
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
import { type RtcMembershipData, type SessionMembershipData } from "./membershipData/index.ts";
import { computeSlotId } from "./utils.ts";
import { isLivekitTransportConfig } from "./LivekitTransport.ts";
/* MembershipActionTypes:
@@ -789,13 +781,14 @@ export class MembershipManager
/**
* Constructs our own membership
* @returns Only returns `SessionMembershipData`
*/
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const needsEmptyStringRoomFix =
this.slotDescription.application === "m.call" && this.slotDescription.id === "ROOM";
const focusObjects =
const focusObjects: Pick<SessionMembershipData, "foci_preferred" | "focus_active"> =
this.rtcTransport === undefined
? {
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
@@ -1099,7 +1092,11 @@ export class StickyEventMembershipManager extends MembershipManager {
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
}
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
/**
*
* @returns Only returns `RtcMembershipData`
*/
protected makeMyMembership(): RtcMembershipData {
const ownMembership = this.ownMembership;
const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined;
@@ -1111,7 +1108,7 @@ export class StickyEventMembershipManager extends MembershipManager {
type: this.slotDescription.application,
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
slot_id: computeSlotId(this.slotDescription),
// Make sure we do not add the alias to the transport.
// It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu.
rtc_transports: livekitTransport
+4 -3
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@ import {
type IEncryptionManager,
} from "./EncryptionManager.ts";
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
import { CallMembership } from "./CallMembership.ts";
import type { CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
@@ -33,6 +33,7 @@ import {
type Statistics,
} from "./types.ts";
import { OutdatedKeyFilter } from "./utils.ts";
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
@@ -139,7 +140,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
}
private async getOwnRtcBackendIdentity(): Promise<string> {
+1 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1
View File
@@ -19,5 +19,6 @@ export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts";
export { Status, parseCallNotificationContent, isMyMembership } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Thrown when an event is not valid for use with MatrixRTC.
*/
export class MatrixRTCMembershipParseError extends AggregateError {
public constructor(
public readonly type: string,
errors: string[],
) {
super(errors, `Does not match ${type}:\n${errors.join("\n")}`);
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts";
export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts";
export { MatrixRTCMembershipParseError } from "./common.ts";
+156
View File
@@ -0,0 +1,156 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MXID_PATTERN } from "../../models/room-member.ts";
import type { IContent } from "../../models/event.ts";
import type { RelationType } from "../../types.ts";
import { type RtcSlotEventContent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
import { sha256 } from "../../digest.ts";
import { encodeUnpaddedBase64Url } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts";
/**
* Represents the current form of MSC4143, which uses sticky events to store membership.
*/
export interface RtcMembershipData {
"slot_id": string;
"member": {
user_id: string;
device_id: string;
id: string;
};
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": RtcSlotEventContent["application"];
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
/**
* Validates that `data` matches the format expected by MSC4143.
* @param data The event content.
* @param sender The sender of the event.
* @returns true if `data` is valid RtcMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => {
const errors: string[] = [];
const prefix = " - ";
const expectedSlotPrefix = `${data?.application?.type}#`;
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else if (!data.slot_id.startsWith(expectedSlotPrefix)) {
errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`);
} else {
try {
slotIdToDescription(data.slot_id);
} catch (ex) {
errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`);
}
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") {
errors.push(prefix + "member.user_id must be string");
} else if (!MXID_PATTERN.test(data.member.user_id)) {
errors.push(prefix + "member.user_id must be a valid mxid");
}
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== sender) {
errors.push(prefix + "member.user_id must match the sender");
}
if (typeof data.member.device_id !== "string") {
errors.push(prefix + "member.device_id must be string");
}
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("RtcMembership", errors);
}
return true;
};
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
const hashInput = `${userId}|${deviceId}|${memberId}`;
const hashBuffer = await sha256(hashInput);
const hashedString = encodeUnpaddedBase64Url(hashBuffer);
return hashedString;
}
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent } from "../../matrix.ts";
import { type RTCCallIntent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
/**
* (MatrixRTC) session membership data.
* This represents the *OLD* form of MSC4143, which uses state events to store membership.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
* NOTE: This is still included for legacy reasons, but not consumed by the SDK.
*/
"focus_active": {
type: "livekit" | string;
focus_selection: "oldest_membership" | "multi_sfu" | string;
};
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: "m.room" | "m.user";
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
/**
* Validates that `data` matches the format expected by the legacy form of MSC4143.
* @param data The event content.
* @returns true if `data` is valid SessionMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => {
const prefix = " - ";
const errors: string[] = [];
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (typeof data.focus_active?.type !== "string") {
errors.push(prefix + "focus_active.type must be a string");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && typeof data.created_ts !== "number") {
errors.push(prefix + "created_ts must be number");
}
// application specific data (we first need to check if they exist)
if (data.scope !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("SessionMembership", errors);
}
return true;
};
+27 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -199,3 +199,29 @@ export interface Transport {
type: string;
[key: string]: unknown;
}
/**
* Event content for a `m.rtc.slot` state event.
*/
export interface RtcSlotEventContent<T extends string = string> {
application: {
type: T;
// other application specific keys
[key: string]: unknown;
};
slot_id: string;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
/**
* The application type. e.g. "m.call".
*/
application: string;
/**
* The application-specific slot ID. e.g. "ROOM".
*/
id: string;
}
+24 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts";
import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
@@ -47,3 +47,25 @@ export class OutdatedKeyFilter {
return false;
}
}
/**
* Converts a slot ID into it's component application and ID portions.
* @param slotId e.g. `m.call#call_id`
* @throws If the format of `slotId` is invalid.
*/
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id, ...unexpectedAdditionalValues] = slotId.split("#");
if (unexpectedAdditionalValues.length) {
throw Error(
"MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.",
);
}
return { application, id };
}
/**
* Converts a SlotDescription into it's slot ID format.
*/
export function computeSlotId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}