diff --git a/.github/workflows/notify-downstream.yaml b/.github/workflows/notify-downstream.yaml index 7139b90e6..2e9bbe895 100644 --- a/.github/workflows/notify-downstream.yaml +++ b/.github/workflows/notify-downstream.yaml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it - uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 + uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} repository: ${{ matrix.repo }} diff --git a/.github/workflows/release-drafter-workflow.yml b/.github/workflows/release-drafter-workflow.yml index 3b6d16aa5..682621123 100644 --- a/.github/workflows/release-drafter-workflow.yml +++ b/.github/workflows/release-drafter-workflow.yml @@ -21,7 +21,7 @@ jobs: ref: staging fetch-depth: 0 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: node-version-file: package.json cache: "yarn" diff --git a/.github/workflows/release-gitflow.yml b/.github/workflows/release-gitflow.yml index de9e806c6..3a6abce0f 100644 --- a/.github/workflows/release-gitflow.yml +++ b/.github/workflows/release-gitflow.yml @@ -33,7 +33,7 @@ jobs: sparse-checkout: | scripts/release - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json diff --git a/.github/workflows/release-make.yml b/.github/workflows/release-make.yml index 6e65fe2ef..8b505ae4c 100644 --- a/.github/workflows/release-make.yml +++ b/.github/workflows/release-make.yml @@ -125,7 +125,7 @@ jobs: git config --global user.email "releases@riot.im" git config --global user.name "RiotRobot" - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 1a9ec554d..9c46fc44d 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -25,7 +25,7 @@ jobs: ref: staging - name: 🔧 Yarn cache - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a6a4904c3..0a6348ee2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: ref: staging token: ${{ secrets.ELEMENT_BOT_TOKEN }} - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version: "lts/*" @@ -76,7 +76,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: 🔧 Yarn cache - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 99543269d..da03e52d9 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -33,7 +33,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -61,7 +61,7 @@ jobs: - name: Build Types run: "yarn build:types" - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "npm" node-version-file: "examples/node/package.json" @@ -85,7 +85,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -102,7 +102,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -127,7 +127,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version-file: package.json @@ -147,7 +147,7 @@ jobs: with: repository: element-hq/element-web - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3495cc56..b0c41fc94 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Node id: setupNode - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 with: cache: "yarn" node-version: ${{ matrix.node }} diff --git a/.github/workflows/triage-stale.yml b/.github/workflows/triage-stale.yml index 31da38096..055b841eb 100644 --- a/.github/workflows/triage-stale.yml +++ b/.github/workflows/triage-stale.yml @@ -12,7 +12,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10 with: operations-per-run: 250 days-before-issue-stale: -1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9da58c2..f69624798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +Changes in [39.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v39.0.0) (2025-10-21) +================================================================================================== +## 🚨 BREAKING CHANGES + +* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5. + +## ✨ Features + +* [MatrixRTC] Multi SFU support + m.rtc.member event type support ([#5022](https://github.com/matrix-org/matrix-js-sdk/pull/5022)). Contributed by @toger5. +* Implement Sticky Events MSC4354 ([#5028](https://github.com/matrix-org/matrix-js-sdk/pull/5028)). Contributed by @Half-Shot. +* feat(client): allow disabling VoIP support ([#5021](https://github.com/matrix-org/matrix-js-sdk/pull/5021)). Contributed by @pkuzco. + +## 🐛 Bug Fixes + +* Only use the first 3 viaServers specified ([#5034](https://github.com/matrix-org/matrix-js-sdk/pull/5034)). Contributed by @t3chguy. +* Fetch the user's device info before processing a verification request ([#5030](https://github.com/matrix-org/matrix-js-sdk/pull/5030)). Contributed by @andybalaam. + + Changes in [38.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v38.4.0) (2025-10-07) ================================================================================================== ## ✨ Features diff --git a/package.json b/package.json index d64294f15..af827a924 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "38.4.0", + "version": "39.0.0", "description": "Matrix Client-Server SDK for Javascript", "engines": { "node": ">=22.0.0" @@ -97,9 +97,9 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.26.0", - "eslint-plugin-jest": "^28.0.0", - "eslint-plugin-jsdoc": "^50.0.0", - "eslint-plugin-matrix-org": "2.1.0", + "eslint-plugin-jest": "^29.0.0", + "eslint-plugin-jsdoc": "^61.0.0", + "eslint-plugin-matrix-org": "^3.0.0", "eslint-plugin-n": "^14.0.0", "eslint-plugin-tsdoc": "^0.4.0", "eslint-plugin-unicorn": "^56.0.0", diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 90199bde3..026142f8f 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -1053,17 +1053,28 @@ describe("MatrixClient", function () { ); }); - it("can look up delayed events", async () => { - httpLookups = [ - { - method: "GET", - prefix: unstableMSC4140Prefix, - path: "/delayed_events", - data: [], - }, - ]; + describe("lookups", () => { + const statuses = [undefined, "scheduled" as const, "finalised" as const]; + const delayIds = [undefined, "dxyz", ["d123"], ["d456", "d789"]]; + const inputs = statuses.flatMap((status) => + delayIds.map((delayId) => [status, delayId] as [(typeof statuses)[0], (typeof delayIds)[0]]), + ); + it.each(inputs)("can look up delayed events (status = %s, delayId = %s)", async (status, delayId) => { + httpLookups = [ + { + method: "GET", + prefix: unstableMSC4140Prefix, + path: "/delayed_events", + expectQueryParams: { + status, + delay_id: delayId, + }, + data: [], + }, + ]; - await client._unstable_getDelayedEvents(); + await client._unstable_getDelayedEvents(status, delayId); + }); }); it("can update delayed events", async () => { diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 1c64e53bf..844813047 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -16,27 +16,30 @@ limitations under the License. import { encodeBase64, + type EventTimeline, EventType, MatrixClient, + type MatrixError, MatrixEvent, RelationType, - type MatrixError, type Room, } from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; -import { secureRandomString } from "../../../src/randomstring"; import { makeMockEvent, makeMockRoom, makeKey, type MembershipData, mockRoomState, - rtcMembershipTemplate, + mockRTCEvent, sessionMembershipTemplate, + rtcMembershipTemplate, } from "./mocks"; 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"; const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" }; @@ -66,11 +69,293 @@ describe("MatrixRTCSession", () => { sess = undefined; }); - describe("roomSessionForRoom", () => { - it("creates a room-scoped session from room state", async () => { - const mockRoom = makeMockRoom([membershipTemplate]); + describe.each([ + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + createWithDefaults: true, // Create MatrixRTCSession with defaults + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: false, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: true, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: false, + testCreateSticky: true, + }, + ])( + "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", + (testConfig) => { + it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.getLiveTimeline.mockReturnValue({ + getState: jest.fn().mockReturnValue(undefined), + } as unknown as EventTimeline); + if (testConfig.listenForMemberStateEvents) { + // eslint-disable-next-line jest/no-conditional-expect + expect(async () => { + await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); + }).toThrow(); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(async () => { + await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); + }).not.toThrow(); + } + }); - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); + it("creates a room-scoped session from room state", async () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].slotDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.slotDescription.id).toEqual(""); + }); + + it("ignores memberships where application is not m.call", async () => { + const testMembership = Object.assign({}, membershipTemplate, { + application: "not-m.call", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); + }); + + it("ignores memberships where callId is not empty", async () => { + const testMembership = Object.assign({}, membershipTemplate, { + call_id: "not-empty", + scope: "m.room", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); + }); + + it("ignores expired memberships events", async () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + + jest.advanceTimersByTime(2000); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); + + it("ignores memberships events of members not in the room", async () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(0); + }); + + it("ignores memberships events with no sender", async () => { + // Force the sender to be undefined. + const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(0); + }); + + it("honours created_ts", async () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); + + it("returns empty session if no membership events are present", async () => { + const mockRoom = makeMockRoom([], testConfig.testCreateSticky); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships).toHaveLength(0); + }); + + it("safely ignores events with no memberships section", async () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline.mockReturnValue({ + getState: jest.fn().mockReturnValue({ + on: jest.fn(), + off: jest.fn(), + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), + }), + } as unknown as EventTimeline); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + + it("safely ignores events with junk memberships section", async () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline.mockReturnValue({ + getState: jest.fn().mockReturnValue({ + on: jest.fn(), + off: jest.fn(), + getStateEvents: (_type: string, _stateKey: string) => [event], + events: new Map([ + [ + EventType.GroupCallMemberPrefix, + { + size: () => true, + has: (_stateKey: string) => true, + get: (_stateKey: string) => event, + values: () => [event], + }, + ], + ]), + }), + } as unknown as EventTimeline); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + + it("ignores memberships with no device_id", async () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.device_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + const sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + + it("ignores memberships with no call_id", async () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.call_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = await MatrixRTCSession.sessionForSlot( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess.memberships).toHaveLength(0); + }); + }, + ); + + describe("roomSessionForRoom 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]); + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + ); + return [ev as StickyMatrixEvent]; + }); + + // Expect for there to be one membership as the state has been merged down. + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].slotDescription.id).toEqual(""); expect(sess?.memberships[0].scope).toEqual("m.room"); @@ -79,149 +364,67 @@ describe("MatrixRTCSession", () => { expect(sess?.memberships[0].isExpired()).toEqual(false); expect(sess?.slotDescription.id).toEqual(""); }); - - it("ignores memberships where application is not m.call", async () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", - }); - const mockRoom = makeMockRoom([testMembership]); - const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - - it("ignores memberships where callId is not empty", async () => { - const testMembership = Object.assign({}, membershipTemplate, { - call_id: "not-empty", - scope: "m.room", - }); - const mockRoom = makeMockRoom([testMembership]); - const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - - it("ignores expired memberships events", async () => { - jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); - - jest.advanceTimersByTime(2000); - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - jest.useRealTimers(); - }); - - it("ignores memberships events of members not in the room", async () => { + 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]); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(0); - }); + const stickyUserId = "@stickyev:user.example"; + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. + ); + return [ev as StickyMatrixEvent]; + }); - it("honours created_ts", async () => { - jest.useFakeTimers(); - jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; - const mockRoom = makeMockRoom([expiredMembership]); - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - jest.useRealTimers(); - }); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); - it("returns empty session if no membership events are present", async () => { - const mockRoom = makeMockRoom([]); - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); + const memberships = sess.memberships; + expect(memberships.length).toEqual(2); + expect(memberships[0].sender).toEqual(stickyUserId); + expect(memberships[0].slotDescription.id).toEqual(""); + expect(memberships[0].scope).toEqual("m.room"); + expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].deviceId).toEqual("AAAAAAA"); + expect(memberships[0].isExpired()).toEqual(false); - it("safely ignores events with no memberships section", async () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - }), - }; - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + // Then state + expect(memberships[1].sender).toEqual(membershipTemplate.user_id); - it("safely ignores events with junk memberships section", async () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ - getState: jest.fn().mockReturnValue({ - on: jest.fn(), - off: jest.fn(), - getStateEvents: (_type: string, _stateKey: string) => [event], - events: new Map([ - [ - EventType.GroupCallMemberPrefix, - { - size: () => true, - has: (_stateKey: string) => true, - get: (_stateKey: string) => event, - values: () => [event], - }, - ], - ]), - }), - }), - }; - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); + expect(sess?.slotDescription.id).toEqual(""); }); + it("handles an incoming sticky event to an existing session", async () => { + const mockRoom = makeMockRoom([membershipTemplate]); + const stickyUserId = "@stickyev:user.example"; - it("ignores memberships with no device_id", async () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - const sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); - }); - - it("ignores memberships with no call_id", async () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = await MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); + sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess.memberships.length).toEqual(1); + const stickyEv = mockRTCEvent( + { + ...membershipTemplate, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. + ) as StickyMatrixEvent; + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + return [stickyEv]; + }); + mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []); + expect(sess.memberships.length).toEqual(2); }); it("fetches related events if needed from room", async () => { const testMembership = { @@ -233,13 +436,11 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([testMembership]); const now = Date.now(); - mockRoom.findEventById = jest - .fn() - .mockImplementation((id) => - id === "id" - ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }) - : undefined, - ); + mockRoom.findEventById.mockImplementation((id) => + id === "id" + ? new MatrixEvent({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }) + : undefined, + ); sess = await MatrixRTCSession.sessionForSlot(client, mockRoom, callSession); expect(sess.memberships[0].createdTs()).toBe(now + 100); }); @@ -254,7 +455,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([testMembership]); const now = Date.now(); - mockRoom.findEventById = jest.fn().mockReturnValue(undefined); + mockRoom.findEventById.mockReturnValue(undefined); client.fetchRoomEvent = jest .fn() .mockResolvedValue({ content: { ...rtcMembershipTemplate }, origin_server_ts: now + 100 }); @@ -391,6 +592,12 @@ describe("MatrixRTCSession", () => { expect(sess!.isJoined()).toEqual(true); }); + it("uses the sticky events membership manager implementation", () => { + sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true }); + expect(sess!.isJoined()).toEqual(true); + expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true); + }); + it("sends a notification when starting a call and emit DidSendCallNotification", async () => { // Simulate a join, including the update to the room state // Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 7d2f60f8a..a6d862cb0 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -14,157 +14,145 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; +import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, sessionMembershipTemplate, mockRoomState } from "./mocks"; +import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; import { logger } from "../../../src/logger"; -describe("MatrixRTCSessionManager", () => { - let client: MatrixClient; +describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( + "MatrixRTCSessionManager ($eventKind)", + ({ eventKind }) => { + let client: MatrixClient; - beforeEach(async () => { - client = new MatrixClient({ baseUrl: "base_url" }); - await client.matrixRTC.start(); - }); - - afterEach(() => { - client.stopClient(); - client.matrixRTC.stop(); - }); - - it("Fires event when session starts", async () => { - const onStarted = jest.fn(); - const { promise, resolve } = Promise.withResolvers(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, (...v) => { - onStarted(...v); - resolve(); - }); - - try { - const room1 = makeMockRoom([sessionMembershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - await promise; - expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void { + if (eventKind === "memberState") { + mockRoomState(room, [{ user_id: membershipTemplate.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 }); + client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000)); + } } - }); - it("Doesn't fire event if unrelated sessions starts", () => { - const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - } - }); - - it("Fires event when session ends", async () => { - const onEnded = jest.fn(); - const { promise: endPromise, resolve: rEnd } = Promise.withResolvers(); - client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const { promise: startPromise, resolve: rStart } = Promise.withResolvers(); - client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, rEnd); - client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, rStart); - - const room1 = makeMockRoom([sessionMembershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - await startPromise; - - mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - await endPromise; - expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); - - it("Fires correctly with for with custom sessionDescription", async () => { - const onStarted = jest.fn(); - const onEnded = jest.fn(); - // create a session manager with a custom session description - const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" }); - - // manually start the session manager (its not the default one started by the client) - await sessionManager.start(); - const { promise: startPromise, resolve: rStart } = Promise.withResolvers(); - const { promise: endPromise, resolve: rEnd } = Promise.withResolvers(); - - sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, (v) => { - onEnded(v); - rEnd(); - }); - sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, (v) => { - onStarted(v); - rStart(); + beforeEach(() => { + client = new MatrixClient({ baseUrl: "base_url" }); + client.matrixRTC.start(); }); - try { - const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other" }]); + afterEach(() => { + client.stopClient(); + client.matrixRTC.stop(); + }); + + it("Fires event when session starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Doesn't fire event if unrelated sessions starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Fires event when session ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membershipData: MembershipData[] = [membershipTemplate]; + const room1 = makeMockRoom(membershipData, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); - + jest.spyOn(client, "getRoom").mockReturnValue(room1); client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - onStarted.mockClear(); - const room2 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.notCall", call_id: "test" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); + sendLeaveMembership(room1, membershipData); - client.emit(ClientEvent.Room, room2); - await startPromise; - expect(onStarted).toHaveBeenCalled(); - onStarted.mockClear(); + expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); - mockRoomState(room2, [{ user_id: sessionMembershipTemplate.user_id }]); - jest.spyOn(client, "getRoom").mockReturnValue(room2); + it("Fires correctly with custom sessionDescription", () => { + const onStarted = jest.fn(); + const onEnded = jest.fn(); + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, { + id: "test", + application: "m.notCall", + }); - const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - await endPromise; - expect(onEnded).toHaveBeenCalled(); - onEnded.mockClear(); + // manually start the session manager (its not the default one started by the client) + sessionManager.start(); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); + 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"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + onStarted.mockClear(); + + // 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"); + jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); + client.emit(ClientEvent.Room, room2); + expect(onStarted).toHaveBeenCalled(); + onStarted.mockClear(); + + // Stop room1's RTC session. Tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room2); + sendLeaveMembership(room2, room2MembershipData); + expect(onEnded).toHaveBeenCalled(); + onEnded.mockClear(); + + // Stop room1's RTC session. Not tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room1); + sendLeaveMembership(room1, room1MembershipData); + expect(onEnded).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + } + }); + + it("Doesn't fire event if unrelated sessions ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const room1 = makeMockRoom(membership, eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); jest.spyOn(client, "getRoom").mockReturnValue(room1); - const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null); - expect(onEnded).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - } - }); + client.emit(ClientEvent.Room, room1); - it("Doesn't fire event if unrelated sessions ends", () => { - const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([{ ...sessionMembershipTemplate, application: "m.other_app" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); + sendLeaveMembership(room1, membership); - client.emit(ClientEvent.Room, room1); - - mockRoomState(room1, [{ user_id: sessionMembershipTemplate.user_id }]); - - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - - expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); -}); + expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); + }, +); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 211c74cde..bee3c5731 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -23,6 +23,7 @@ import { MatrixError, UnsupportedDelayedEventsEndpointError, type Room, + MAX_STICKY_DURATION_MS, } from "../../../src"; import { MembershipManagerEvent, @@ -94,7 +95,9 @@ describe("MembershipManager", () => { // Provide a default mock that is like the default "non error" server behaviour. (client._unstable_sendDelayedStateEvent as Mock).mockResolvedValue({ delay_id: "id" }); (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); - (client.sendStateEvent as Mock).mockResolvedValue(undefined); + (client._unstable_sendStickyEvent as Mock).mockResolvedValue({ event_id: "id" }); + (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client.sendStateEvent as Mock).mockResolvedValue({ event_id: "id" }); }); afterEach(() => { @@ -152,45 +155,6 @@ describe("MembershipManager", () => { expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); }); - it("sends a rtc membership event when using `useRtcMemberFormat`", async () => { - // Spys/Mocks - - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); - - // Test - const memberManager = new MembershipManager({ useRtcMemberFormat: true }, room, client, callSession); - memberManager.join([], focus); - // expects - await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" })); - // This should check for send sticky once we merge with the sticky matrixRTC branch. - expect(client.sendStateEvent).toHaveBeenCalledWith( - room.roomId, - "org.matrix.msc4143.rtc.member", - { - application: { type: "m.call" }, - member: { - user_id: "@alice:example.org", - id: "_@alice:example.org_AAAAAAA_m.call", - device_id: "AAAAAAA", - }, - slot_id: "m.call#", - rtc_transports: [focus], - versions: [], - msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", - }, - "_@alice:example.org_AAAAAAA_m.call", - ); - updateDelayedEventHandle.resolve?.(); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( - room.roomId, - { delay: 8000 }, - "org.matrix.msc4143.rtc.member", - {}, - "_@alice:example.org_AAAAAAA_m.call", - ); - expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1); - }); - it("reschedules delayed leave event if sending state cancels it", async () => { const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); @@ -927,6 +891,63 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalledTimes(0); }); }); + + describe("StickyEventMembershipManager", () => { + beforeEach(() => { + // Provide a default mock that is like the default "non error" server behaviour. + (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client._unstable_sendStickyEvent as Mock).mockResolvedValue(undefined); + }); + + describe("join()", () => { + describe("sends an rtc membership event", () => { + it("sends a membership event and schedules delayed leave when joining a call", async () => { + const updateDelayedEventHandle = createAsyncHandle( + client._unstable_updateDelayedEvent as Mock, + ); + const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession); + + memberManager.join([], focus); + + await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" })); + // Test we sent the initial join + expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith( + room.roomId, + 3600000, + null, + "org.matrix.msc4143.rtc.member", + { + application: { type: "m.call" }, + member: { + user_id: "@alice:example.org", + id: "_@alice:example.org_AAAAAAA_m.call", + device_id: "AAAAAAA", + }, + slot_id: "m.call#", + rtc_transports: [focus], + versions: [], + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + }, + ); + updateDelayedEventHandle.resolve?.(); + + // Ensure we have sent the delayed disconnect event. + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( + room.roomId, + MAX_STICKY_DURATION_MS, + { delay: 8000 }, + null, + "org.matrix.msc4143.rtc.member", + { + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + }, + ); + // ..once + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + }); }); it("Should prefix log with MembershipManager used", async () => { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index f522f2881..4637b9178 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { EventEmitter } from "stream"; +import { type Mocked } from "jest-mock"; import { EventType, MatrixEvent, type Room, RoomEvent, type MatrixClient } from "../../../src"; import { @@ -65,6 +66,8 @@ export type MockClient = Pick< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" >; /** @@ -79,15 +82,19 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { cancelPendingEvent: jest.fn(), _unstable_updateDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), + _unstable_sendStickyEvent: jest.fn(), + _unstable_sendStickyDelayedEvent: jest.fn(), }; } export function makeMockRoom( membershipData: MembershipData[], -): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { + useStickyEvents = false, +): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(membershipData, roomId); + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); + const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), @@ -95,11 +102,17 @@ export function makeMockRoom( getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - }) as unknown as Room; + _unstable_getStickyEvents: jest + .fn() + .mockImplementation(() => + useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], + ) as any, + findEventById: jest.fn(), + }); return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any), - }); + }) as unknown as Mocked void }>; } function makeMockRoomState(membershipData: MembershipData[], roomId: string) { @@ -143,6 +156,7 @@ export function makeMockEvent( roomId: string | undefined, content: any, timestamp?: number, + stateKey?: string, ): MatrixEvent { return new MatrixEvent({ event_id: secureRandomString(8), @@ -151,11 +165,27 @@ export function makeMockEvent( content, room_id: roomId, origin_server_ts: timestamp ?? 0, + state_key: stateKey, }); } -export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { - return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, Date.now()); +export function mockRTCEvent( + { user_id: sender, ...membershipData }: MembershipData, + roomId: string, + stickyDuration?: number, + timestamp?: number, +): MatrixEvent { + return { + ...makeMockEvent( + stickyDuration !== undefined ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, + sender, + roomId, + membershipData, + timestamp, + !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", + ), + unstableStickyExpiresAt: stickyDuration, + } as unknown as MatrixEvent; } export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership { diff --git a/spec/unit/matrixrtc/types.spec.ts b/spec/unit/matrixrtc/types.spec.ts new file mode 100644 index 000000000..7e45acb67 --- /dev/null +++ b/spec/unit/matrixrtc/types.spec.ts @@ -0,0 +1,135 @@ +/* +Copyright 2025 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 CallMembership } from "../../../src/matrixrtc"; +import { isMyMembership, parseCallNotificationContent } from "../../../src/matrixrtc/types"; + +describe("types", () => { + describe("isMyMembership", () => { + it("returns false if userId is different", () => { + expect( + isMyMembership( + { sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership, + "@bob:example.org", + "DEVICE", + ), + ).toBe(false); + }); + it("returns true if userId and device is the same", () => { + expect( + isMyMembership( + { sender: "@alice:example.org", deviceId: "DEVICE" } as CallMembership, + "@alice:example.org", + "DEVICE", + ), + ).toBe(true); + }); + }); +}); + +describe("IRTCNotificationContent", () => { + const validBase = Object.freeze({ + "m.mentions": { user_ids: [], room: true }, + "notification_type": "notification", + "sender_ts": 123, + "lifetime": 1000, + }); + + it("parses valid content", () => { + const res = parseCallNotificationContent({ ...validBase }); + expect(res).toMatchObject(validBase); + }); + + it("caps lifetime to 90000ms", () => { + const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 }); + expect(res.lifetime).toBe(90000); + }); + + it("throws on malformed m.mentions", () => { + expect(() => + parseCallNotificationContent({ + ...validBase, + "m.mentions": "not an object", + } as any), + ).toThrow("malformed m.mentions"); + }); + + it("throws on missing or invalid notification_type", () => { + expect(() => + parseCallNotificationContent({ + ...validBase, + notification_type: undefined, + } as any), + ).toThrow("Missing or invalid notification_type"); + + expect(() => + parseCallNotificationContent({ + ...validBase, + notification_type: 123 as any, + } as any), + ).toThrow("Missing or invalid notification_type"); + }); + + it("throws on missing or invalid sender_ts", () => { + expect(() => + parseCallNotificationContent({ + ...validBase, + sender_ts: undefined, + } as any), + ).toThrow("Missing or invalid sender_ts"); + + expect(() => + parseCallNotificationContent({ + ...validBase, + sender_ts: "123" as any, + } as any), + ).toThrow("Missing or invalid sender_ts"); + }); + + it("throws on missing or invalid lifetime", () => { + expect(() => + parseCallNotificationContent({ + ...validBase, + lifetime: undefined, + } as any), + ).toThrow("Missing or invalid lifetime"); + + expect(() => + parseCallNotificationContent({ + ...validBase, + lifetime: "1000" as any, + } as any), + ).toThrow("Missing or invalid lifetime"); + }); + + it("accepts valid relation (m.reference)", () => { + // Note: parseCallNotificationContent currently checks `relation.rel_type` rather than `m.relates_to`. + const res = parseCallNotificationContent({ + ...validBase, + relation: { rel_type: "m.reference", event_id: "$ev" }, + } as any); + expect(res).toBeTruthy(); + }); + + it("throws on invalid relation rel_type", () => { + expect(() => + parseCallNotificationContent({ + ...validBase, + relation: { rel_type: "m.annotation", event_id: "$ev" }, + } as any), + ).toThrow("Invalid relation"); + }); +}); diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index a51fe461c..e7ea91b60 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -259,4 +259,164 @@ describe("RoomStickyEvents", () => { expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); }); + + describe("handleRedaction", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + it("should not emit if the event does not exist in the map", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + origin_server_ts: Date.now(), + }); + stickyEvents.addStickyEvents([ev]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + stickyEvents.handleRedaction("$123456"); + expect(emitSpy).not.toHaveBeenCalled(); + }); + it("should emit a remove when the event exists in the map without a predecessor", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + stickyEvents.addStickyEvents([ev]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + stickyEvents.handleRedaction(stickyEvent.event_id); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); + }); + it("should emit a remove when the event has no sticky key", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + origin_server_ts: Date.now(), + }); + stickyEvents.addStickyEvents([ev]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + stickyEvents.handleRedaction(stickyEvent.event_id); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); + }); + it("should emit an update when the event exists in the map with a predecessor", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event. + const newerEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newer-ev", + origin_server_ts: Date.now() + 1000, + }); + stickyEvents.addStickyEvents([ev, newerEv]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + stickyEvents.handleRedaction(newerEv.getId()!); + expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []); + }); + it("should emit a remove if the previous event has expired", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event. + const newerEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newer-ev", + origin_server_ts: Date.now() + 1000, + }); + stickyEvents.addStickyEvents([ev, newerEv]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + // Expire the older event. + jest.advanceTimersByTime(stickyEvent.msc4354_sticky.duration_ms); + // Redact the newer event + stickyEvents.handleRedaction(newerEv.getId()!); + expect(emitSpy).toHaveBeenCalledWith([], [], [newerEv]); + }); + it("should recurse the chain of events if the previous event has been redacted", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event. + const middleEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newer-ev", + origin_server_ts: Date.now() + 1000, + }); + jest.advanceTimersByTime(1000); + const newestEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newest-ev", + origin_server_ts: Date.now() + 2000, + }); + stickyEvents.addStickyEvents([ev, middleEv, newestEv]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + // Mark the middle event as redacted. + middleEv.setUnsigned({ + redacted_because: { + event_id: "$foo", + } as any, + }); + // Redact the newer event + stickyEvents.handleRedaction(newestEv.getId()!); + // expect immediate transition from newestEv -> ev and skipping middleEv + expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []); + }); + it("should revert to the most recent valid event regardless of insertion order", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event. + const middleEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newer-ev", + origin_server_ts: Date.now() + 1000, + }); + jest.advanceTimersByTime(1000); + const newestEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newest-ev", + origin_server_ts: Date.now() + 2000, + }); + // Invert in reverse order, to make sure we retain the older events. + stickyEvents.addStickyEvents([newestEv, middleEv, ev]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + // Mark the middle event as redacted. + middleEv.setUnsigned({ + redacted_because: { + event_id: "$foo", + } as any, + }); + // Redact the newer event + stickyEvents.handleRedaction(newestEv.getId()!); + expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newestEv }], []); + }); + it("should handle redaction when using `handleRedaction` with a `MatrixEvent` parameter", () => { + const emitSpy = jest.fn(); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + jest.advanceTimersByTime(1000); // Advance time so we can insert a newer event. + const newerEv = new MatrixEvent({ + ...stickyEvent, + event_id: "$newer-ev", + origin_server_ts: Date.now() + 1000, + }); + stickyEvents.addStickyEvents([ev, newerEv]); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + stickyEvents.handleRedaction(newerEv); + expect(emitSpy).toHaveBeenCalledWith([], [{ current: ev, previous: newerEv }], []); + }); + }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index e9c77ef4e..3c83cb1d7 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -854,9 +854,27 @@ describe("RustCrypto", () => { }); }); + it("getSecretStorageStatus", async () => { + const mockSecretStorage = { + getDefaultKeyId: jest.fn().mockResolvedValue("blah"), + isStored: jest.fn().mockResolvedValue({ blah: {} }), + } as unknown as Mocked; + const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage); + await expect(rustCrypto.getSecretStorageStatus()).resolves.toEqual({ + defaultKeyId: "blah", + ready: true, + secretStorageKeyValidityMap: { + "m.cross_signing.master": true, + "m.cross_signing.self_signing": true, + "m.cross_signing.user_signing": true, + }, + }); + }); + it("isSecretStorageReady", async () => { const mockSecretStorage = { getDefaultKeyId: jest.fn().mockResolvedValue(null), + isStored: jest.fn().mockResolvedValue(null), } as unknown as Mocked; const rustCrypto = await makeTestRustCrypto(undefined, undefined, undefined, mockSecretStorage); await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false); diff --git a/src/@types/event.ts b/src/@types/event.ts index 1364d9ca7..96780da84 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -338,6 +338,7 @@ export interface TimelineEvents { [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; + [EventType.RTCMembership]: RtcMembershipData | { msc4354_sticky_key: string }; // An object containing just the sticky key is empty. } /** diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 713a6b7f2..4797d2acb 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -20,6 +20,7 @@ import { type IEventWithRoomId, type SearchKey } from "./search.ts"; import { type IRoomEventFilter } from "../filter.ts"; import { type Direction } from "../models/event-timeline.ts"; import { type PushRuleAction } from "./PushRules.ts"; +import { type MatrixError } from "../matrix.ts"; import { type IRoomEvent } from "../sync-accumulator.ts"; import { type EventType, type RelationType, type RoomType } from "./event.ts"; @@ -136,12 +137,22 @@ type DelayedPartialStateEvent = DelayedPartialTimelineEvent & { type DelayedPartialEvent = DelayedPartialTimelineEvent | DelayedPartialStateEvent; +export type DelayedEventInfoItem = DelayedPartialEvent & + SendDelayedEventResponse & + SendDelayedEventRequestOpts & { + running_since: number; + }; + export type DelayedEventInfo = { - delayed_events: (DelayedPartialEvent & - SendDelayedEventResponse & - SendDelayedEventRequestOpts & { - running_since: number; - })[]; + scheduled?: DelayedEventInfoItem[]; + finalised?: { + delayed_event: DelayedEventInfoItem; + outcome: "send" | "cancel"; + reason: "error" | "action" | "delay"; + error?: MatrixError["data"]; + event_id?: string; + origin_server_ts?: number; + }[]; next_batch?: string; }; diff --git a/src/client.ts b/src/client.ts index bd1f83626..106380699 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3537,13 +3537,17 @@ export class MatrixClient extends TypedEventEmitter { + public async _unstable_getDelayedEvents( + status?: "scheduled" | "finalised", + delayId?: string | string[], + fromToken?: string, + ): Promise { if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { throw new UnsupportedDelayedEventsEndpointError( "Server does not support the delayed events API", @@ -3551,7 +3555,11 @@ export class MatrixClient extends TypedEventEmitter; + /** + * Inspect the status of secret storage, in more detail than {@link isSecretStorageReady}. + */ + getSecretStorageStatus(): Promise; + /** * Bootstrap [secret storage](https://spec.matrix.org/v1.12/client-server-api/#storage). * @@ -1148,6 +1153,30 @@ export interface CryptoCallbacks { cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void; } +/** + * The result of a call to {@link CryptoApi.getSecretStorageStatus}. + */ +export interface SecretStorageStatus { + /** Whether secret storage is fully populated. The same as {@link CryptoApi.isSecretStorageReady}. */ + ready: boolean; + + /** The ID of the current default secret storage key. */ + defaultKeyId: string | null; + + /** + * For each secret that we checked whether it is correctly stored in secret storage with the default secret storage key. + * + * Note that we will only check that the key backup key is stored if key backup is currently enabled (i.e. that + * {@link CryptoApi.getActiveSessionBackupVersion} returns non-null). `m.megolm_backup.v1` will only be present in that case. + * + * (This is an object rather than a `Map` so that it JSON.stringify()s nicely, since its main purpose is to end up + * in logs.) + */ + secretStorageKeyValidityMap: { + [P in SecretStorageKey]?: boolean; + }; +} + /** * Parameter of {@link CryptoApi#bootstrapSecretStorage} */ diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index ce61720c3..50be122d6 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -194,6 +194,10 @@ export type SessionMembershipData = { * 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; }; const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 429f3fa4a..4f396c493 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -17,7 +17,6 @@ limitations under the License. import { type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; -import { MatrixEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; @@ -25,7 +24,7 @@ import { KnownMembership } from "../@types/membership.ts"; import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; -import { MembershipManager } from "./MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; import type { @@ -51,6 +50,8 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { MatrixEvent } from "../models/event.ts"; +import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; /** * Events emitted by MatrixRTCSession @@ -124,14 +125,6 @@ export function slotDescriptionToId(slotDescription: SlotDescription): string { // - we use a `Ms` postfix if the option is a duration to avoid using words like: // `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms. export interface MembershipConfig { - /** - * Use the new Manager. - * - * Default: `false`. - * @deprecated does nothing anymore we always default to the new membership manager. - */ - useNewMembershipManager?: boolean; - /** * The timeout (in milliseconds) after we joined the call, that our membership should expire * unless we have explicitly updated it. @@ -193,7 +186,14 @@ export interface MembershipConfig { * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; - useRtcMemberFormat?: boolean; + + /** + * Send membership using sticky events rather than state events. + * This also make the client use the new m.rtc.member MSC4354 event format. (instead of m.call.member) + * + * **WARNING**: This is an unstable feature and not all clients will support it. + */ + unstableSendStickyEvents?: boolean; } export interface EncryptionConfig { @@ -239,6 +239,19 @@ export interface EncryptionConfig { } export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; +interface SessionMembershipsForRoomOpts { + /** + * Listen for incoming sticky member events. If disabled, this session will + * ignore any incoming sticky events. + */ + listenForStickyEvents: boolean; + /** + * Listen for incoming member state events (legacy). If disabled, this session will + * ignore any incoming state events. + */ + listenForMemberStateEvents: boolean; +} + /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * This class doesn't deal with media at all, just membership & properties of a session. @@ -308,7 +321,10 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionMembershipsForSlot` instead. */ public static async callMembershipsForRoom( - room: Pick, + room: Pick< + Room, + "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents" + >, client: Pick, ): Promise { return await MatrixRTCSession.sessionMembershipsForSlot(room, client, { @@ -321,7 +337,10 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated use `MatrixRTCSession.slotMembershipsForRoom` instead. */ public static async sessionMembershipsForRoom( - room: Pick, + room: Pick< + Room, + "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents" + >, client: Pick, sessionDescription: SlotDescription, ): Promise { @@ -331,23 +350,61 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. + * + * By default, this will return *both* sticky and member state events. */ public static async sessionMembershipsForSlot( - room: Pick, + room: Pick< + Room, + "getLiveTimeline" | "roomId" | "hasMembershipState" | "findEventById" | "_unstable_getStickyEvents" + >, client: Pick, slotDescription: SlotDescription, existingMemberships?: CallMembership[], + { listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }, ): Promise { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); - const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); - if (!roomState) { - logger.warn("Couldn't get state for room " + room.roomId); - throw new Error("Could't get state for room " + room.roomId); + let callMemberEvents = [] as MatrixEvent[]; + if (listenForStickyEvents) { + // prefill with sticky events + callMemberEvents = [...room._unstable_getStickyEvents()].filter( + (e) => e.getType() === EventType.RTCMembership, + ); + } + if (listenForMemberStateEvents) { + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + if (!roomState) { + logger.warn("Couldn't get state for room " + room.roomId); + throw new Error("Could't get state for room " + room.roomId); + } + const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); + callMemberEvents = callMemberEvents.concat( + callMemberStateEvents.filter( + (callMemberStateEvent) => + !callMemberEvents.some( + // only care about state events which have keys which we have not yet seen in the sticky events. + (stickyEvent) => + stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(), + ), + ), + ); } - const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); const callMemberships: CallMembership[] = []; const createMembership = async (memberEvent: MatrixEvent): Promise => { + const content = memberEvent.getContent(); + + // Ignore sticky keys for the count + const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length; + // Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms) + if (eventKeysCount === 0) return undefined; + + // We first decide if its a MSC4143 event (per device state key) + if (!(eventKeysCount > 1 && "application" in content)) return undefined; + const relatedEventId = memberEvent.relationEventId; const fetchRelatedEvent = async (): Promise => { const eventData = await client @@ -415,11 +472,21 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionForSlot` with sessionDescription `{ id: "", application: "m.call" }` instead. */ - public static async roomSessionForRoom(client: MatrixClient, room: Room): Promise { - const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, { - id: "", - application: "m.call", - }); + public static async roomSessionForRoom( + client: MatrixClient, + room: Room, + opts?: SessionMembershipsForRoomOpts, + ): Promise { + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot( + room, + client, + { + id: "", + application: "m.call", + }, + undefined, + opts, + ); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } @@ -443,8 +510,15 @@ export class MatrixRTCSession extends TypedEventEmitter< client: MatrixClient, room: Room, slotDescription: SlotDescription, + opts?: SessionMembershipsForRoomOpts, ): Promise { - const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot(room, client, slotDescription); + const callMemberships = await MatrixRTCSession.sessionMembershipsForSlot( + room, + client, + slotDescription, + undefined, + opts, + ); return new MatrixRTCSession(client, room, callMemberships, slotDescription); } @@ -478,10 +552,12 @@ export class MatrixRTCSession extends TypedEventEmitter< | "off" | "getUserId" | "getDeviceId" + | "sendEvent" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" - | "sendEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" | "encryptAndSendToDevice" | "decryptEventIfNeeded" @@ -489,7 +565,14 @@ export class MatrixRTCSession extends TypedEventEmitter< >, private roomSubset: Pick< Room, - "on" | "off" | "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "findEventById" + | "on" + | "off" + | "getLiveTimeline" + | "roomId" + | "getVersion" + | "hasMembershipState" + | "findEventById" + | "_unstable_getStickyEvents" >, public memberships: CallMembership[], /** @@ -504,9 +587,10 @@ export class MatrixRTCSession extends TypedEventEmitter< const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); + this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); + this.setExpiryTimer(); } - /* * Returns true if we intend to be participating in the MatrixRTC session. * This is determined by checking if the relativeExpiry has been set. @@ -526,7 +610,9 @@ export class MatrixRTCSession extends TypedEventEmitter< } const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); + this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); } + private reEmitter = new TypedReEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap @@ -556,14 +642,15 @@ export class MatrixRTCSession extends TypedEventEmitter< return; } else { // Create MembershipManager and pass the RTCSession logger (with room id info) - - this.membershipManager = new MembershipManager( - joinConfig, - this.roomSubset, - this.client, - this.slotDescription, - this.logger, - ); + this.membershipManager = joinConfig?.unstableSendStickyEvents + ? new StickyEventMembershipManager( + joinConfig, + this.roomSubset, + this.client, + this.slotDescription, + this.logger, + ) + : new MembershipManager(joinConfig, this.roomSubset, this.client, this.slotDescription, this.logger); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, @@ -802,10 +889,27 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when the Matrix room members have changed. */ - public onRoomMemberUpdate = (): void => { + public readonly onRoomMemberUpdate = (): void => { void this.recalculateSessionMembers(); }; + /** + * Call this when a sticky event update has occured. + */ + private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = ( + added, + updated, + removed, + ): void => { + if ( + [...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some( + (e) => e.getType() === EventType.RTCMembership, + ) + ) { + void this.recalculateSessionMembers(); + } + }; + /** * Call this when something changed that may impacts the current MatrixRTC members in this session. */ @@ -861,6 +965,8 @@ export class MatrixRTCSession extends TypedEventEmitter< // If anyone else joins the session it is no longer our responsibility to send the notification. // (If we were the joiner we already did sent the notification in the block above.) if (this.memberships.length > 0) this.pendingNotificationToSend = undefined; + } else { + this.logger.debug(`No membership changes detected for room ${this.roomSubset.roomId}`); } // This also needs to be done if `changed` = false // A member might have updated their fingerprint (created_ts) diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index cacbc254b..1da8ce701 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -18,7 +18,7 @@ import { type Logger } from "../logger.ts"; import { type MatrixClient, ClientEvent } from "../client.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; -import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; +import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; @@ -73,6 +73,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + private readonly onEvent = (event: MatrixEvent): void => { + if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested. + + if (event.getType() !== EventType.RTCMembership) return; + + const room = this.client.getRoom(event.getRoomId()); + if (!room) return; + + void this.refreshRoom(room); + }; + + private readonly onRoomState = (event: MatrixEvent): void => { + if (event.getType() !== EventType.GroupCallMemberPrefix) { + return; + } const room = this.client.getRoom(event.getRoomId()); if (!room) { this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); return; } - if (event.getType() == EventType.GroupCallMemberPrefix) { - void this.refreshRoom(room); - } + void this.refreshRoom(room); }; private async refreshRoom(room: Room): Promise { diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index a102b4662..0fd95c21a 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -16,7 +16,12 @@ limitations under the License. import { AbortError } from "p-retry"; import { EventType, RelationType } from "../@types/event.ts"; -import { UpdateDelayedEventAction } from "../@types/requests.ts"; +import { + type ISendEventResponse, + type SendDelayedEventResponse, + UpdateDelayedEventAction, +} from "../@types/requests.ts"; +import { type EmptyObject } from "../@types/common.ts"; import type { MatrixClient } from "../client.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; import { type Logger, logger as rootLogger } from "../logger.ts"; @@ -85,6 +90,12 @@ On Leave: ───────── STOP ALL ABOVE (s) Successful restart/resend */ +/** + * Call membership should always remain sticky for this amount + * of time. + */ +const MEMBERSHIP_STICKY_DURATION_MS = 60 * 60 * 1000; // 60 minutes + /** * The different types of actions the MembershipManager can take. * @internal @@ -145,6 +156,23 @@ export interface MembershipManagerState { probablyLeft: boolean; } +function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + insert: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + replace: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +type MembershipManagerClient = Pick< + MatrixClient, + "getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" +>; + /** * This class is responsible for sending all events relating to the own membership of a matrixRTC call. * It has the following tasks: @@ -163,8 +191,8 @@ export class MembershipManager implements IMembershipManager { private activated = false; - private logger: Logger; - private callIntent: RTCCallIntent | undefined; + private readonly logger: Logger; + protected callIntent: RTCCallIntent | undefined; public isActivated(): boolean { return this.activated; @@ -296,16 +324,9 @@ export class MembershipManager * @param client */ public constructor( - private joinConfig: (SessionConfig & MembershipConfig) | undefined, - private room: Pick, - private client: Pick< - MatrixClient, - | "getUserId" - | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" - >, + private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined, + protected readonly room: Pick, + protected readonly client: MembershipManagerClient, public readonly slotDescription: SlotDescription, parentLogger?: Logger, ) { @@ -362,11 +383,11 @@ export class MembershipManager }; } // Membership Event static parameters: - private deviceId: string; - private memberId: string; + protected deviceId: string; + protected memberId: string; + protected rtcTransport?: Transport; /** @deprecated This will be removed in favor or rtcTransport becoming a list of actively used transports */ private fociPreferred?: Transport[]; - private rtcTransport?: Transport; // Config: private delayedLeaveEventDelayMsOverride?: number; @@ -381,9 +402,13 @@ export class MembershipManager return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000; } private computeNextExpiryActionTs(iteration: number): number { - return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs; + return ( + this.state.startTime + + Math.min(this.membershipEventExpiryMs, MEMBERSHIP_STICKY_DURATION_MS) * iteration - + this.membershipEventExpiryHeadroomMs + ); } - private get delayedLeaveEventDelayMs(): number { + protected get delayedLeaveEventDelayMs(): number { return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000; } private get delayedLeaveEventRestartMs(): number { @@ -395,13 +420,10 @@ export class MembershipManager private get maximumNetworkErrorRetryCount(): number { return this.joinConfig?.maximumNetworkErrorRetryCount ?? 10; } - private get delayedLeaveEventRestartLocalTimeoutMs(): number { return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } - private get useRtcMemberFormat(): boolean { - return this.joinConfig?.useRtcMemberFormat ?? false; - } + // LOOP HANDLER: private membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -456,22 +478,23 @@ export class MembershipManager } } + // an abstraction to switch between sending state or a sticky event + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + {}, + this.memberId, + ); + // HANDLERS (used in the membershipLoopHandler) private sendOrResendDelayedLeaveEvent(): Promise { // We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false) // or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event. // (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...) // In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}" - return this.client - ._unstable_sendDelayedStateEvent( - this.room.roomId, - { - delay: this.delayedLeaveEventDelayMs, - }, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - {}, // leave event - this.memberId, - ) + return this.clientSendDelayedDisconnectMembership() .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; this.setAndEmitProbablyLeft(false); @@ -495,7 +518,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent"); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -651,14 +674,19 @@ export class MembershipManager }); } - private sendJoinEvent(): Promise { - return this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs), - this.memberId, - ) + protected clientSendMembership: ( + myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, + ) => Promise = (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership as EmptyObject | SessionMembershipData, + this.memberId, + ); + }; + + private async sendJoinEvent(): Promise { + return this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) .then(() => { this.setAndEmitProbablyLeft(false); this.state.startTime = Date.now(); @@ -698,13 +726,9 @@ export class MembershipManager private updateExpiryOnJoinedEvent(): Promise { const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1; - return this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.memberId, - ) + return this.clientSendMembership( + this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), + ) .then(() => { // Success, we reset retries and schedule update. this.resetRateLimitCounter(MembershipActionType.UpdateExpiry); @@ -725,14 +749,8 @@ export class MembershipManager throw e; }); } - private sendFallbackLeaveEvent(): Promise { - return this.client - .sendStateEvent( - this.room.roomId, - this.useRtcMemberFormat ? EventType.RTCMembership : EventType.GroupCallMemberPrefix, - {}, - this.memberId, - ) + private async sendFallbackLeaveEvent(): Promise { + return this.clientSendMembership({}) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -758,46 +776,29 @@ export class MembershipManager /** * Constructs our own membership */ - private makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { const ownMembership = this.ownMembership; - if (this.useRtcMemberFormat) { - const relationObject = ownMembership?.eventId - ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } - : {}; - return { - application: { - type: this.slotDescription.application, - ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), - }, - slot_id: slotDescriptionToId(this.slotDescription), - rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], - member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, - versions: [], - ...relationObject, - [UNSTABLE_STICKY_KEY.name]: this.memberId, - }; - } else { - const focusObjects = - this.rtcTransport === undefined - ? { - focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, - foci_preferred: this.fociPreferred ?? [], - } - : { - focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, - foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], - }; - return { - "application": this.slotDescription.application, - "call_id": this.slotDescription.id, - "scope": "m.room", - "device_id": this.deviceId, - expires, - "m.call.intent": this.callIntent, - ...focusObjects, - ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), - }; - } + + const focusObjects = + this.rtcTransport === undefined + ? { + focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const, + foci_preferred: this.fociPreferred ?? [], + } + : { + focus_active: { type: "livekit", focus_selection: "multi_sfu" } as const, + foci_preferred: [this.rtcTransport, ...(this.fociPreferred ?? [])], + }; + return { + "application": this.slotDescription.application, + "call_id": this.slotDescription.id, + "scope": "m.room", + "device_id": this.deviceId, + expires, + "m.call.intent": this.callIntent, + ...focusObjects, + ...(ownMembership !== undefined ? { created_ts: ownMembership.createdTs() } : undefined), + }; } // Error checks and handlers @@ -832,7 +833,7 @@ export class MembershipManager return false; } - private actionUpdateFromErrors( + protected actionUpdateFromErrors( error: unknown, type: MembershipActionType, method: string, @@ -880,7 +881,7 @@ export class MembershipManager return createInsertActionUpdate(type, resendDelay); } - throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error)); + throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error }); } /** @@ -1024,14 +1025,69 @@ export class MembershipManager } } -function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - insert: [{ ts: Date.now() + (offset ?? 0), type }], - }; -} +/** + * Implementation of the Membership manager that uses sticky events + * rather than state events. + */ +export class StickyEventMembershipManager extends MembershipManager { + public constructor( + joinConfig: (SessionConfig & MembershipConfig) | undefined, + room: Pick, + private readonly clientWithSticky: MembershipManagerClient & + Pick, + sessionDescription: SlotDescription, + parentLogger?: Logger, + ) { + super(joinConfig, room, clientWithSticky, sessionDescription, parentLogger); + } -function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - replace: [{ ts: Date.now() + (offset ?? 0), type }], + protected clientSendDelayedDisconnectMembership: () => Promise = () => + this.clientWithSticky._unstable_sendStickyDelayedEvent( + this.room.roomId, + MEMBERSHIP_STICKY_DURATION_MS, + { delay: this.delayedLeaveEventDelayMs }, + null, + EventType.RTCMembership, + { msc4354_sticky_key: this.memberId }, + ); + + protected clientSendMembership: ( + myMembership: RtcMembershipData | SessionMembershipData | EmptyObject, + ) => Promise = (myMembership) => { + return this.clientWithSticky._unstable_sendStickyEvent( + this.room.roomId, + MEMBERSHIP_STICKY_DURATION_MS, + null, + EventType.RTCMembership, + { ...myMembership, msc4354_sticky_key: this.memberId }, + ); }; + + private static nameMap = new Map([ + ["sendStateEvent", "_unstable_sendStickyEvent"], + ["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"], + ]); + protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined { + return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); + } + + protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { + const ownMembership = this.ownMembership; + + const relationObject = ownMembership?.eventId + ? { "m.relation": { rel_type: RelationType.Reference, event_id: ownMembership?.eventId } } + : {}; + return { + application: { + type: this.slotDescription.application, + ...(this.callIntent ? { "m.call.intent": this.callIntent } : {}), + }, + slot_id: slotDescriptionToId(this.slotDescription), + rtc_transports: this.rtcTransport ? [this.rtcTransport] : [], + member: { device_id: this.deviceId, user_id: this.client.getUserId()!, id: this.memberId }, + versions: [], + ...relationObject, + [UNSTABLE_STICKY_KEY.name]: this.memberId, + }; + } } diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 08c32a206..fe4b47b10 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -13,7 +13,7 @@ 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 { IMentions } from "../matrix.ts"; +import type { IContent, IMentions } from "../matrix.ts"; import type { RelationEvent } from "../types.ts"; import type { CallMembership } from "./CallMembership.ts"; @@ -102,9 +102,45 @@ export type RTCNotificationType = "ring" | "notification"; * May be any string, although `"audio"` and `"video"` are commonly accepted values. */ export type RTCCallIntent = "audio" | "video" | string; + +/** + * This will check if the content has all the expected fields to be a valid IRTCNotificationContent. + * It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided. + * @param content + * @throws if the content is invalid + * @returns a parsed IRTCNotificationContent + */ +export function parseCallNotificationContent(content: IContent): IRTCNotificationContent { + if (content["m.mentions"] && typeof content["m.mentions"] !== "object") { + throw new Error("malformed m.mentions"); + } + if (typeof content["notification_type"] !== "string") { + throw new Error("Missing or invalid notification_type"); + } + if (typeof content["sender_ts"] !== "number") { + throw new Error("Missing or invalid sender_ts"); + } + if (typeof content["lifetime"] !== "number") { + throw new Error("Missing or invalid lifetime"); + } + + if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") { + throw new Error("Invalid relation"); + } + if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") { + throw new Error("Invalid m.call.intent"); + } + + const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"]; + return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent; +} + +/** + * Interface for `org.matrix.msc4075.rtc.notification` events. + * Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first. + */ export interface IRTCNotificationContent extends RelationEvent { - "m.mentions": IMentions; - "decline_reason"?: string; + "m.mentions"?: IMentions; "notification_type": RTCNotificationType; /** * The initial intent of the calling user. diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index d0fcaea72..a90b2463b 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -32,7 +32,7 @@ export enum RoomStickyEventsEvent { Update = "RoomStickyEvents.Update", } -type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; +export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; export type RoomStickyEventsMap = { /** @@ -48,17 +48,64 @@ export type RoomStickyEventsMap = { ) => void; }; +type UserId = `@${string}`; + +function assertIsUserId(value: unknown): asserts value is UserId { + if (typeof value !== "string") throw new Error("Not a string"); + if (!value.startsWith("@")) throw new Error("Not a userId"); +} + /** * Tracks sticky events on behalf of one room, and fires an event * whenever a sticky event is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { - private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + /** + * Sticky event map is a nested map of: + * eventType -> `content.sticky_key sender` -> StickyMatrixEvent[] + * + * The events are ordered in latest to earliest expiry, so that the first event + * in the array will always be the "current" one. + */ + private readonly stickyEventsMap = new Map>(); + /** + * These are sticky events that have no sticky key and therefore exist outside the tuple + * system above. They are just held in this Set until they expire. + */ private readonly unkeyedStickyEvents = new Set(); private stickyEventTimer?: ReturnType; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; + /** + * Sort two sticky events by order of expiry. This assumes the sticky events have the same + * `type`, `sticky_key` and `sender`. + * @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner. + */ + private static sortStickyEvent(eventA: StickyMatrixEvent, eventB: StickyMatrixEvent): number { + // Sticky events with the same key have to use the same expiration duration. + // Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time. + if (eventB.getTs() !== eventA.getTs()) { + return eventB.getTs() - eventA.getTs(); + } + + if ((eventB.getId() ?? "") > (eventA.getId() ?? "")) { + return 1; + } + + // This should fail as we've got corruption in our sticky array. + throw Error("Comparing two sticky events with the same event ID is not allowed."); + } + + /** + * Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`. + * @param stickyKey The sticky key of an event. + * @param sender The sender of the event. + */ + private static stickyMapKey(stickyKey: string, sender: UserId): string { + return `${stickyKey}${sender}`; + } + /** * Get all sticky events that are currently active. * @returns An iterable set of events. @@ -66,7 +113,11 @@ export class RoomStickyEventsStore extends TypedEventEmitter { yield* this.unkeyedStickyEvents; for (const innerMap of this.stickyEventsMap.values()) { - yield* innerMap.values(); + // Inner map contains a map of sender+stickykeys => all sticky events + for (const events of innerMap.values()) { + // The first sticky event is the "current" one in the sticky map. + yield events[0]; + } } } @@ -78,7 +129,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter - // E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes: - // "rtc.member.event.@foo:bar@bar:baz" - const innerMapKey = `${stickyKey}${sender}`; - prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey); + const stickyEvent = event as StickyMatrixEvent; - // sticky events are not allowed to expire sooner than their predecessor. - if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) { - logger.info("ignored sticky event with older expiry time", stickyKey); - return { added: false }; - } else if ( - prevEvent && - event.getTs() === prevEvent.getTs() && - (event.getId() ?? "") < (prevEvent.getId() ?? "") - ) { - // This path is unlikely, as it requires both events to have the same TS. - logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey); - return { added: false }; - } - if (!this.stickyEventsMap.has(type)) { - this.stickyEventsMap.set(type, new Map()); - } - this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent); - } else { - this.unkeyedStickyEvents.add(event as StickyMatrixEvent); + if (stickyKey === undefined) { + this.unkeyedStickyEvents.add(stickyEvent); + // Recalculate the next expiry time. + this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); + + this.scheduleStickyTimer(); + return { added: true }; } + // Why this is safe: + // A type may contain anything but the *sender* is tightly + // constrained so that a key will always end with a @ + // E.g. Where a malicious event type might be "rtc.member.event@foo:bar" the key becomes: + // "rtc.member.event.@foo:bar@bar:baz" + const innerMapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender); + const currentEventSet = [stickyEvent, ...(this.stickyEventsMap.get(type)?.get(innerMapKey) ?? [])].sort( + RoomStickyEventsStore.sortStickyEvent, + ); + if (!this.stickyEventsMap.has(type)) { + this.stickyEventsMap.set(type, new Map()); + } + this.stickyEventsMap.get(type)?.set(innerMapKey, currentEventSet); + // Recalculate the next expiry time. - this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); + this.nextStickyEventExpiryTs = Math.min(stickyEvent.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); this.scheduleStickyTimer(); - return { added: true, prevEvent }; + return { + added: currentEventSet[0] === stickyEvent, + prevEvent: currentEventSet?.[1], + }; } /** @@ -217,17 +265,24 @@ export class RoomStickyEventsStore extends TypedEventEmitter= event.unstableStickyExpiresAt) { - logger.debug("Expiring sticky event", event.getId()); - removedEvents.push(event); + if (now >= currentEvent.unstableStickyExpiresAt) { + logger.debug("Expiring sticky event", currentEvent.getId()); + removedEvents.push(currentEvent); this.stickyEventsMap.get(eventType)!.delete(innerMapKey); } else { + // Ensure we remove any previous events which have now expired, to avoid unbounded memory consumption. + this.stickyEventsMap + .get(eventType)! + .set(innerMapKey, [ + currentEvent, + ...previousEvents.filter((e) => e.unstableStickyExpiresAt <= now), + ]); // If not removing the event, check to see if it's the next lowest expiry. this.nextStickyEventExpiryTs = Math.min( this.nextStickyEventExpiryTs, - event.unstableStickyExpiresAt, + currentEvent.unstableStickyExpiresAt, ); } } @@ -253,6 +308,93 @@ export class RoomStickyEventsStore extends TypedEventEmitter !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent); + this.stickyEventsMap.get(eventType)?.set(mapKey, newEvents); + if (newEvents.length) { + this.emit( + RoomStickyEventsEvent.Update, + [], + [ + { + // This looks confusing. This emits that the newer event + // has been redacted and the previous event has taken it's place. + previous: currentEvent, + current: newEvents[0], + }, + ], + [], + ); + } else { + // We did not find a previous event, so just expire. + innerMap.delete(mapKey); + if (innerMap.size === 0) { + this.stickyEventsMap.delete(eventType); + } + this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]); + } + return; + } + + // We only know the event ID of the redacted event, so we need to + // traverse the map to find our event. + for (const innerMap of this.stickyEventsMap.values()) { + for (const [currentEvent] of innerMap.values()) { + if (currentEvent.getId() !== redactEventId) { + continue; + } + // Found the event. + return this.handleRedaction(currentEvent); + } + } + } + /** * Clear all events and stop the timer from firing. */ diff --git a/src/models/room.ts b/src/models/room.ts index ffd35c2a7..6c657c49f 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2633,6 +2633,14 @@ export class Room extends ReadReceipt { // if we know about this event, redact its contents now. const redactedEvent = redactId ? this.findEventById(redactId) : undefined; + if (redactId) { + try { + this.stickyEvents.handleRedaction(redactedEvent || redactId); + } catch (ex) { + // Non-critical failure, but we should warn. + logger.error("Failed to handle redaction for sticky event", ex); + } + } if (redactedEvent) { this.applyEventAsRedaction(event, redactedEvent); } diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 638bf37d1..dbb0abf7c 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -65,6 +65,7 @@ import { type KeyBackupRestoreOpts, type KeyBackupRestoreResult, type OwnDeviceKeys, + type SecretStorageStatus, type StartDehydrationOpts, UserVerificationStatus, type VerificationRequest, @@ -78,7 +79,7 @@ import { type ServerSideSecretStorage, } from "../secret-storage.ts"; import { CrossSigningIdentity } from "./CrossSigningIdentity.ts"; -import { secretStorageCanAccessSecrets, secretStorageContainsCrossSigningKeys } from "./secret-storage.ts"; +import { secretStorageContainsCrossSigningKeys } from "./secret-storage.ts"; import { isVerificationEvent, RustVerificationRequest, verificationMethodIdentifierToMethod } from "./verification.ts"; import { EventType, MsgType } from "../@types/event.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; @@ -827,6 +828,13 @@ export class RustCrypto extends TypedEventEmitter { + return (await this.getSecretStorageStatus()).ready; + } + + /** + * Implementation of {@link CryptoApi#getSecretStorageStatus} + */ + public async getSecretStorageStatus(): Promise { // make sure that the cross-signing keys are stored const secretsToCheck: SecretStorageKey[] = [ "m.cross_signing.master", @@ -834,13 +842,32 @@ export class RustCrypto extends TypedEventEmitter= 2.1.2 < 3.0.0": version "2.1.2" @@ -6241,17 +6299,22 @@ semver@^7.5.3, semver@^7.5.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== -semver@^7.6.0, semver@^7.7.1: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.6.0, semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== semver@^7.6.3: version "7.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== -set-function-length@^1.2.1: +semver@^7.7.1: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -6273,6 +6336,15 @@ set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -6285,15 +6357,45 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -side-channel@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== dependencies: - call-bind "^1.0.7" es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" @@ -6376,9 +6478,9 @@ spdx-expression-parse@^4.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.21" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" - integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== + version "3.0.22" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz#abf5a08a6f5d7279559b669f47f0a43e8f3464ef" + integrity sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ== sprintf-js@~1.0.2: version "1.0.3" @@ -6397,6 +6499,14 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + string-argv@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" @@ -6454,22 +6564,26 @@ string-width@^8.0.0: get-east-asian-width "^1.3.0" strip-ansi "^7.1.0" -string.prototype.trim@^1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" - integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" define-properties "^1.2.1" - es-abstract "^1.23.0" + es-abstract "^1.23.5" es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" -string.prototype.trimend@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz#3651b8513719e8a9f48de7f2f77640b26652b229" - integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.2" define-properties "^1.2.1" es-object-atoms "^1.0.0" @@ -6612,6 +6726,14 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +to-valid-identifier@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/to-valid-identifier/-/to-valid-identifier-0.1.1.tgz#bfd09b8c63ab0c0235e23c8243f7630f45b3e7a9" + integrity sha512-/m+BsP+oLrdYptSVuWdRanXBD0N1qiyx2GtAunpPz+TRPENw7IpEndqyPTwxe67wLiu16ZinauXESdOL1eDYBQ== + dependencies: + "@sindresorhus/base62" "^0.1.0" + reserved-identifiers "^1.0.0" + tough-cookie@^4.1.2: version "4.1.4" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" @@ -6641,11 +6763,6 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== -ts-api-utils@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.0.1.tgz#660729385b625b939aaa58054f45c058f33f10cd" - integrity sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w== - ts-api-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" @@ -6702,6 +6819,11 @@ type-detect@4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.2.0.tgz#e259430307710e77721ecf6f545840acad72195f" + integrity sha512-5zknd7Dss75pMSED270A1RQS3KloqRJA9XbXLe0eCxyw7xXFb3rd+9B0UQ/0E+LQT6lnrLviEolYORlRWamn4w== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -6722,49 +6844,50 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -typed-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" - integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.3" es-errors "^1.3.0" - is-typed-array "^1.1.13" + is-typed-array "^1.1.14" -typed-array-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" - integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" -typed-array-byte-offset@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" - integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.7" + call-bind "^1.0.8" for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" -typed-array-length@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" - integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== dependencies: call-bind "^1.0.7" for-each "^0.3.3" gopd "^1.0.1" - has-proto "^1.0.3" is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" typedoc-plugin-coverage@^4.0.0: version "4.0.1" @@ -6802,15 +6925,15 @@ uc.micro@^2.0.0, uc.micro@^2.1.0: resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== dependencies: - call-bind "^1.0.2" + call-bound "^1.0.3" has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" undici-types@~5.26.4: version "5.26.5" @@ -7014,26 +7137,57 @@ whatwg-url@^6.5.0: tr46 "^1.0.1" webidl-conversions "^4.0.2" -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" -which-typed-array@^1.1.14, which-typed-array@^1.1.15: - version "1.1.15" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" has-tostringtag "^1.0.2" which@^2.0.1: @@ -7161,6 +7315,6 @@ yocto-queue@^0.1.0: integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== zod@^4.1.11: - version "4.1.11" - resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5" - integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg== + version "4.1.12" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0" + integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==