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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")}`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user