From f8ac6d5353a0f57f328afaf2d3ee7da6bec3d655 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 30 Oct 2025 09:59:28 -0400 Subject: [PATCH 1/5] Delayed event management: split endpoints, no auth Add dedicated endpoints for each of the cancel/restart/send actions for updating a delayed event, and make them unauthenticated. Also keep support for the original endpoint where the update action is in the request body, and make the split-endpoint versions fall back to it if they are unsupported by the homeserver. --- spec/unit/embedded.spec.ts | 38 +++-- spec/unit/matrix-client.spec.ts | 131 +++++++++++++++++- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 3 + spec/unit/matrixrtc/MembershipManager.spec.ts | 88 +++++++----- spec/unit/matrixrtc/mocks.ts | 6 + src/client.ts | 124 ++++++++++++++++- src/embedded.ts | 54 +++++++- src/errors.ts | 9 +- src/matrixrtc/MatrixRTCSession.ts | 6 +- src/matrixrtc/MembershipManager.ts | 30 ++-- 10 files changed, 419 insertions(+), 70 deletions(-) diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index fac2c9958eb..dc1d43a6b5a 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -531,17 +531,37 @@ describe("RoomWidgetClient", () => { ).rejects.toThrow(); }); - it("updates delayed events", async () => { - await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); - expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); - for (const action of [ - UpdateDelayedEventAction.Cancel, - UpdateDelayedEventAction.Restart, - UpdateDelayedEventAction.Send, - ]) { + it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])( + "can %s scheduled delayed events (action in request body)", + async (action: UpdateDelayedEventAction) => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith( + MatrixCapabilities.MSC4157UpdateDelayedEvent, + ); await client._unstable_updateDelayedEvent("id", action); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action); - } + }, + ); + + it("can cancel scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_cancelScheduledDelayedEvent("id"); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", UpdateDelayedEventAction.Cancel); + }); + + it("can restart scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_restartScheduledDelayedEvent("id"); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", UpdateDelayedEventAction.Restart); + }); + + it("can send scheduled delayed events (action in request path)", async () => { + await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] }); + expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent); + await client._unstable_sendScheduledDelayedEvent("id"); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", UpdateDelayedEventAction.Send); }); }); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 026142f8f10..db20f06eb66 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -801,6 +801,10 @@ describe("MatrixClient", function () { await expect( client._unstable_updateDelayedEvent("anyDelayId", UpdateDelayedEventAction.Send), ).rejects.toThrow(errorMessage); + + await expect(client._unstable_cancelScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); + await expect(client._unstable_restartScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); + await expect(client._unstable_sendScheduledDelayedEvent("anyDelayId")).rejects.toThrow(errorMessage); }); it("works with null threadId", async () => { @@ -1077,21 +1081,140 @@ describe("MatrixClient", function () { }); }); - it("can update delayed events", async () => { + it.each([UpdateDelayedEventAction.Cancel, UpdateDelayedEventAction.Restart, UpdateDelayedEventAction.Send])( + "can %s scheduled delayed events (action in request body)", + async (action: UpdateDelayedEventAction) => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action, + }, + }, + ]; + + await client._unstable_updateDelayedEvent(delayId, action); + }, + ); + + it("can cancel scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`, + }, + ]; + + await client._unstable_cancelScheduledDelayedEvent(delayId); + }); + + it("can restart scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/restart`, + }, + ]; + + await client._unstable_restartScheduledDelayedEvent(delayId); + }); + + it("can send scheduled delayed events (action in request path)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/send`, + }, + ]; + + await client._unstable_sendScheduledDelayedEvent(delayId); + }); + + it("can cancel scheduled delayed events (action in request path fallback when unsupported)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/cancel`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action: UpdateDelayedEventAction.Cancel, + }, + }, + ]; + + await client._unstable_cancelScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); + }); + + it("can restart scheduled delayed events (action in request path fallback when unsupported)", async () => { const delayId = "id"; - const action = UpdateDelayedEventAction.Restart; httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/restart`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, { method: "POST", prefix: unstableMSC4140Prefix, path: `/delayed_events/${encodeURIComponent(delayId)}`, data: { - action, + action: UpdateDelayedEventAction.Restart, }, }, ]; - await client._unstable_updateDelayedEvent(delayId, action); + await client._unstable_restartScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); + }); + + it("can send scheduled delayed events (action in request path fallback when unsupported)", async () => { + const delayId = "id"; + httpLookups = [ + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}/send`, + error: { + httpStatus: 400, + errcode: "M_UNRECOGNIZED", + }, + }, + { + method: "POST", + prefix: unstableMSC4140Prefix, + path: `/delayed_events/${encodeURIComponent(delayId)}`, + data: { + action: UpdateDelayedEventAction.Send, + }, + }, + ]; + + await client._unstable_sendScheduledDelayedEvent(delayId); + expect(httpLookups).toHaveLength(0); }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 8eb11ecdd18..41848a8e621 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -523,6 +523,9 @@ describe("MatrixRTCSession", () => { client.sendEvent = sendEventMock; client._unstable_updateDelayedEvent = jest.fn(); + client._unstable_cancelScheduledDelayedEvent = jest.fn(); + client._unstable_restartScheduledDelayedEvent = jest.fn(); + client._unstable_sendScheduledDelayedEvent = jest.fn(); mockRoom = makeMockRoom([]); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index 8c4f90c0020..cf70c66d7a3 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -94,6 +94,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._unstable_cancelScheduledDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_sendScheduledDelayedEvent 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" }); @@ -122,7 +125,9 @@ describe("MembershipManager", () => { it("sends a membership event and schedules delayed leave when joining a call", async () => { // Spys/Mocks - const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock); + const restartScheduledDelayedEventHandle = createAsyncHandle( + client._unstable_restartScheduledDelayedEvent as Mock, + ); // Test const memberManager = new MembershipManager(undefined, room, client, callSession); @@ -143,7 +148,7 @@ describe("MembershipManager", () => { }, "_@alice:example.org_AAAAAAA_m.call", ); - updateDelayedEventHandle.resolve?.(); + restartScheduledDelayedEventHandle.resolve?.(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith( room.roomId, { delay: 8000 }, @@ -157,13 +162,13 @@ describe("MembershipManager", () => { it("reschedules delayed leave event if sending state cancels it", async () => { const memberManager = new MembershipManager(undefined, room, client, callSession); const waitForSendState = waitForMockCall(client.sendStateEvent); - const waitForUpdateDelaye = waitForMockCallOnce( - client._unstable_updateDelayedEvent, + const waitForRestartScheduledDelayedEvent = waitForMockCallOnce( + client._unstable_restartScheduledDelayedEvent, Promise.reject(new MatrixError({ errcode: "M_NOT_FOUND" })), ); memberManager.join([focus], focusActive); await waitForSendState; - await waitForUpdateDelaye; + await waitForRestartScheduledDelayedEvent; await jest.advanceTimersByTimeAsync(1); // Once for the initial event and once because of the errcode: "M_NOT_FOUND" // Different to "sends a membership event and schedules delayed leave when joining a call" where its only called once (1) @@ -179,7 +184,7 @@ describe("MembershipManager", () => { if (useOwnedStateEvents) { room.getVersion = jest.fn().mockReturnValue("org.matrix.msc3757.default"); } - const updatedDelayedEvent = waitForMockCall(client._unstable_updateDelayedEvent); + const restartScheduledDelayedEvent = waitForMockCall(client._unstable_restartScheduledDelayedEvent); const sentDelayedState = waitForMockCall( client._unstable_sendDelayedStateEvent, Promise.resolve({ @@ -265,13 +270,13 @@ describe("MembershipManager", () => { await sentDelayedState; // should have prepared the heartbeat to keep delaying the leave event while still connected - await updatedDelayedEvent; - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + await restartScheduledDelayedEvent; + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // ensures that we reach the code that schedules the timeout for the next delay update before we advance the timers. await jest.advanceTimersByTimeAsync(5000); // should update delayed disconnect - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2); } it("sends a membership event after rate limits during delayed event setup when joining a call", async () => { @@ -343,7 +348,7 @@ describe("MembershipManager", () => { // (onRTCSessionMemberUpdate) // - Only then do we resolve the sending of the delayed event. // - We test that the manager acknowledges the leave and sends a new membership state event. - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValueOnce( new MatrixError({ errcode: "M_NOT_FOUND" }), ); @@ -404,17 +409,17 @@ describe("MembershipManager", () => { manager.join([focus]); await jest.advanceTimersByTimeAsync(1); await manager.leave(); - expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", "send"); + expect(client._unstable_sendScheduledDelayedEvent).toHaveBeenLastCalledWith("id"); expect(client.sendStateEvent).toHaveBeenCalled(); }); it("send leave event when leave is called and resolving delayed leave fails", async () => { const manager = new MembershipManager({}, room, client, callSession); manager.join([focus]); await jest.advanceTimersByTimeAsync(1); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue("unknown"); + (client._unstable_sendScheduledDelayedEvent as Mock).mockRejectedValue("unknown"); await manager.leave(); - // We send a normal leave event since we failed using updateDelayedEvent with the "send" action. + // We send a normal leave event since we failed using sendScheduledDelayedEvent. expect(client.sendStateEvent).toHaveBeenLastCalledWith( room.roomId, "org.matrix.msc3401.call.member", @@ -438,6 +443,9 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("does nothing if own membership still present", async () => { const manager = new MembershipManager({}, room, client, callSession); @@ -447,6 +455,9 @@ describe("MembershipManager", () => { // reset all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_cancelScheduledDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); + (client._unstable_sendScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); await manager.onRTCSessionMemberUpdate([ @@ -462,6 +473,9 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).not.toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled(); expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_cancelScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).not.toHaveBeenCalled(); + expect(client._unstable_sendScheduledDelayedEvent).not.toHaveBeenCalled(); }); it("recreates membership if it is missing", async () => { const manager = new MembershipManager({}, room, client, callSession); @@ -469,7 +483,7 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); // Our own membership is removed: @@ -478,7 +492,7 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled(); }); it("updates the UpdateExpiry entry in the action scheduler", async () => { @@ -487,10 +501,10 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(1); // clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate` (client.sendStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockClear(); + (client._unstable_restartScheduledDelayedEvent as Mock).mockClear(); (client._unstable_sendDelayedStateEvent as Mock).mockClear(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValueOnce( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValueOnce( new MatrixError({ errcode: "M_NOT_FOUND" }), ); @@ -503,7 +517,7 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalled(); expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled(); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalled(); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalled(); expect(manager.status).toBe(Status.Connected); }); }); @@ -523,17 +537,17 @@ describe("MembershipManager", () => { // The first call is from checking id the server deleted the delayed event // so it does not need a `advanceTimersByTime` - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + // expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); for (let i = 2; i <= 12; i++) { // flush promises before advancing the timers to make sure schedulers are setup await jest.advanceTimersByTimeAsync(10_000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(i); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(i); // TODO: Check that update delayed event is called with the correct HTTP request timeout - // expect(client._unstable_updateDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); + // expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenLastCalledWith("id", 10_000, { localTimeoutMs: 20_000 }); } }); @@ -681,7 +695,7 @@ describe("MembershipManager", () => { }); describe("retries sending update delayed leave event restart", () => { it("resends the initial check delayed update event", async () => { - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -695,17 +709,17 @@ describe("MembershipManager", () => { // Hit rate limit await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); // Hit second rate limit. await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(2); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(2); // Setup resolve - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue(undefined); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue(undefined); await jest.advanceTimersByTimeAsync(1000); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); }); }); @@ -734,7 +748,7 @@ describe("MembershipManager", () => { // because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors. it("throws, when reaching maximum number of retries", async () => { const delayEventRestartError = jest.fn(); - (client._unstable_updateDelayedEvent as Mock).mockRejectedValue( + (client._unstable_restartScheduledDelayedEvent as Mock).mockRejectedValue( new MatrixError( { errcode: "M_LIMIT_EXCEEDED" }, 429, @@ -808,11 +822,11 @@ describe("MembershipManager", () => { manager.join([focus], focusActive); try { // Let the scheduler run one iteration so that we can send the join state event - await waitForMockCall(client._unstable_updateDelayedEvent); + await waitForMockCall(client._unstable_restartScheduledDelayedEvent); // We never resolve the delayed event so that we can test the probablyLeft event. // This simulates the case where the server does not respond to the delayed event. - client._unstable_updateDelayedEvent = jest.fn(() => stuckPromise); + client._unstable_restartScheduledDelayedEvent = jest.fn(() => stuckPromise); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); expect(manager.status).toBe(Status.Connected); expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); @@ -822,18 +836,18 @@ describe("MembershipManager", () => { await jest.advanceTimersByTimeAsync(5000); // No emission after 5s expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(4999); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(3); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(3); expect(probablyLeftEmit).not.toHaveBeenCalledWith(true); // Reset mocks before we setup the next delayed event restart by advancing the timers 1 more ms. - (client._unstable_updateDelayedEvent as Mock).mockResolvedValue({}); + (client._unstable_restartScheduledDelayedEvent as Mock).mockResolvedValue({}); // Emit after 10s await jest.advanceTimersByTimeAsync(1); - expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(4); + expect(client._unstable_restartScheduledDelayedEvent).toHaveBeenCalledTimes(4); expect(probablyLeftEmit).toHaveBeenCalledWith(true); // Mock a sync which does not include our own membership @@ -898,8 +912,8 @@ describe("MembershipManager", () => { 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 restartScheduledDelayedEventHandle = createAsyncHandle( + client._unstable_restartScheduledDelayedEvent as Mock, ); const memberManager = new StickyEventMembershipManager(undefined, room, client, callSession); @@ -925,7 +939,7 @@ describe("MembershipManager", () => { msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", }, ); - updateDelayedEventHandle.resolve?.(); + restartScheduledDelayedEventHandle.resolve?.(); // Ensure we have sent the delayed disconnect event. expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 24982afe257..999d6c92b43 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -52,6 +52,9 @@ export type MockClient = Pick< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" @@ -67,6 +70,9 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { sendStateEvent: jest.fn(), cancelPendingEvent: jest.fn(), _unstable_updateDelayedEvent: jest.fn(), + _unstable_cancelScheduledDelayedEvent: jest.fn(), + _unstable_restartScheduledDelayedEvent: jest.fn(), + _unstable_sendScheduledDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), _unstable_sendStickyEvent: jest.fn(), _unstable_sendStickyDelayedEvent: jest.fn(), diff --git a/src/client.ts b/src/client.ts index 10638069934..c9ebe7ab020 100644 --- a/src/client.ts +++ b/src/client.ts @@ -106,6 +106,7 @@ import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-m import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts"; import { isSendDelayedEventRequestOpts, + UpdateDelayedEventAction, type DelayedEventInfo, type IAddThreePidOnlyBody, type IBindThreePidBody, @@ -130,7 +131,6 @@ import { type KnockRoomOpts, type SendDelayedEventRequestOpts, type SendDelayedEventResponse, - type UpdateDelayedEventAction, } from "./@types/requests.ts"; import { type AccountDataEvents, @@ -3590,12 +3590,132 @@ export class MatrixClient extends TypedEventEmitter { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "cancelScheduledDelayedEvent", + ); + } + + try { + const path = utils.encodeUri("/delayed_events/$delayId/cancel", { + $delayId: delayId, + }); + return await this.http.request(Method.Post, path, undefined, undefined, { + ...requestOptions, + prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, + }); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { + return await this._unstable_updateDelayedEvent( + delayId, + UpdateDelayedEventAction.Cancel, + requestOptions, + ); + } else { + throw e; + } + } + } + + /** + * Restart the scheduled delivery of the delayed event matching the given {@link delayId}. + * + * Note: This endpoint is unstable, and can throw an `Error`. + * Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details. + * + * @throws A M_NOT_FOUND error if no matching delayed event could be found. + */ + // eslint-disable-next-line + public async _unstable_restartScheduledDelayedEvent( + delayId: string, + requestOptions: IRequestOpts = {}, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "restartScheduledDelayedEvent", + ); + } + + try { + const path = utils.encodeUri("/delayed_events/$delayId/restart", { + $delayId: delayId, + }); + return await this.http.request(Method.Post, path, undefined, undefined, { + ...requestOptions, + prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, + }); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { + return await this._unstable_updateDelayedEvent( + delayId, + UpdateDelayedEventAction.Restart, + requestOptions, + ); + } else { + throw e; + } + } + } + + /** + * Immediately send the delayed event matching the given {@link delayId}, + * instead of waiting for its scheduled delivery. + * + * Note: This endpoint is unstable, and can throw an `Error`. + * Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) for more details. + * + * @throws A M_NOT_FOUND error if no matching delayed event could be found. + */ + // eslint-disable-next-line + public async _unstable_sendScheduledDelayedEvent( + delayId: string, + requestOptions: IRequestOpts = {}, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "sendScheduledDelayedEvent", + ); + } + + try { + const path = utils.encodeUri("/delayed_events/$delayId/send", { + $delayId: delayId, + }); + return await this.http.request(Method.Post, path, undefined, undefined, { + ...requestOptions, + prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, + }); + } catch (e) { + if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { + return await this._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions); + } else { + throw e; + } + } + } + /** * Send a receipt. * @param event - The event being acknowledged diff --git a/src/embedded.ts b/src/embedded.ts index 7bc18483c3d..8f201fce3de 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -37,7 +37,7 @@ import { type ISendEventResponse, type SendDelayedEventRequestOpts, type SendDelayedEventResponse, - type UpdateDelayedEventAction, + UpdateDelayedEventAction, } from "./@types/requests.ts"; import { EventType, type StateEvents } from "./@types/event.ts"; import { logger } from "./logger.ts"; @@ -473,6 +473,58 @@ export class RoomWidgetClient extends MatrixClient { return {}; } + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "cancelScheduledDelayedEvent", + ); + } + + await this.widgetApi + .updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel) + .catch(timeoutToConnectionError); + return {}; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "restartScheduledDelayedEvent", + ); + } + + await this.widgetApi + .updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart) + .catch(timeoutToConnectionError); + return {}; + } + + /** + * @experimental This currently relies on an unstable MSC (MSC4140). + */ + // eslint-disable-next-line + public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "sendScheduledDelayedEvent", + ); + } + + await this.widgetApi.updateDelayedEvent(delayId, UpdateDelayedEventAction.Send).catch(timeoutToConnectionError); + return {}; + } + /** * by {@link MatrixClient.encryptAndSendToDevice}. */ diff --git a/src/errors.ts b/src/errors.ts index 672aee3bb42..9a9a0a548b5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -59,7 +59,14 @@ export class ClientStoppedError extends Error { export class UnsupportedDelayedEventsEndpointError extends Error { public constructor( message: string, - public clientEndpoint: "sendDelayedEvent" | "updateDelayedEvent" | "sendDelayedStateEvent" | "getDelayedEvents", + public clientEndpoint: + | "sendDelayedEvent" + | "updateDelayedEvent" + | "cancelScheduledDelayedEvent" + | "restartScheduledDelayedEvent" + | "sendScheduledDelayedEvent" + | "sendDelayedStateEvent" + | "getDelayedEvents", ) { super(message); this.name = "UnsupportedDelayedEventsEndpointError"; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 63bc8adf68d..4e7b2b30067 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -178,7 +178,8 @@ export interface MembershipConfig { * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs * helps by keeping more delayed event reset candidates in flight, * improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration, - * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) + * but only applies to calls to the `_unstable_restartScheduledDelayedEvent` endpoint + * or the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; @@ -514,6 +515,9 @@ export class MatrixRTCSession extends TypedEventEmitter< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 533f9adfc45..f31b2647721 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -16,11 +16,7 @@ limitations under the License. import { AbortError } from "p-retry"; import { EventType, RelationType } from "../@types/event.ts"; -import { - type ISendEventResponse, - type SendDelayedEventResponse, - UpdateDelayedEventAction, -} from "../@types/requests.ts"; +import { type ISendEventResponse, type SendDelayedEventResponse } 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"; @@ -169,7 +165,14 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number): type MembershipManagerClient = Pick< MatrixClient, - "getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "getUserId" + | "getDeviceId" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" + | "_unstable_updateDelayedEvent" + | "_unstable_cancelScheduledDelayedEvent" + | "_unstable_restartScheduledDelayedEvent" + | "_unstable_sendScheduledDelayedEvent" >; /** @@ -544,7 +547,7 @@ export class MembershipManager private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise { // Remove all running updates and restarts return await this.client - ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel) + ._unstable_cancelScheduledDelayedEvent(delayId) .then(() => { this.state.delayId = undefined; this.resetRateLimitCounter(MembershipActionType.SendDelayedEvent); @@ -552,7 +555,7 @@ export class MembershipManager }) .catch((e) => { const repeatActionType = MembershipActionType.SendDelayedEvent; - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "cancelScheduledDelayedEvent"); if (update) return update; if (this.isNotFoundError(e)) { @@ -606,10 +609,7 @@ export class MembershipManager // The obvious choice here would be to use the `IRequestOpts` to set the timeout. Since this call might be forwarded // to the widget driver this information would get lost. That is why we mimic the AbortError using the race. - return await Promise.race([ - this.client._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart), - abortPromise, - ]) + return await Promise.race([this.client._unstable_restartScheduledDelayedEvent(delayId), abortPromise]) .then(() => { // Whenever we successfully restart the delayed event we update the `state.expectedServerDelayLeaveTs` // which stores the predicted timestamp at which the server will send the delayed leave event if there wont be any further @@ -637,7 +637,7 @@ export class MembershipManager if (this.isUnsupportedDelayedEndpoint(e)) return {}; // TODO this also needs a test: get rate limit while checking id delayed event is scheduled - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "restartScheduledDelayedEvent"); if (update) return update; // In other error cases we have no idea what is happening @@ -647,7 +647,7 @@ export class MembershipManager private async sendScheduledDelayedLeaveEventOrFallbackToSendLeaveEvent(delayId: string): Promise { return await this.client - ._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send) + ._unstable_sendScheduledDelayedEvent(delayId) .then(() => { this.state.hasMemberStateEvent = false; this.resetRateLimitCounter(MembershipActionType.SendScheduledDelayedLeaveEvent); @@ -661,7 +661,7 @@ export class MembershipManager this.state.delayId = undefined; return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "updateDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "sendScheduledDelayedEvent"); if (update) return update; // On any other error we fall back to SendLeaveEvent (this includes hard errors from rate limiting) From 267c14973909c1c2009d6011dc1c3b04d63921be Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 30 Oct 2025 10:44:40 -0400 Subject: [PATCH 2/5] Don't @link parameters in method docstrings as TypeDoc doesn't support that --- src/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index c9ebe7ab020..ba8558a634b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3597,7 +3597,7 @@ export class MatrixClient extends TypedEventEmitter Date: Thu, 30 Oct 2025 11:25:30 -0400 Subject: [PATCH 3/5] Reduce code duplication --- src/client.ts | 98 +++++++++++++++++---------------------------------- 1 file changed, 32 insertions(+), 66 deletions(-) diff --git a/src/client.ts b/src/client.ts index ba8558a634b..3da226d1ea8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3583,17 +3583,7 @@ export class MatrixClient extends TypedEventEmitter { - if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { - throw new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "cancelScheduledDelayedEvent", - ); - } - - try { - const path = utils.encodeUri("/delayed_events/$delayId/cancel", { - $delayId: delayId, - }); - return await this.http.request(Method.Post, path, undefined, undefined, { - ...requestOptions, - prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, - }); - } catch (e) { - if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { - return await this._unstable_updateDelayedEvent( - delayId, - UpdateDelayedEventAction.Cancel, - requestOptions, - ); - } else { - throw e; - } - } + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Cancel, requestOptions); } /** @@ -3650,32 +3615,7 @@ export class MatrixClient extends TypedEventEmitter { - if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { - throw new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "restartScheduledDelayedEvent", - ); - } - - try { - const path = utils.encodeUri("/delayed_events/$delayId/restart", { - $delayId: delayId, - }); - return await this.http.request(Method.Post, path, undefined, undefined, { - ...requestOptions, - prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, - }); - } catch (e) { - if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") { - return await this._unstable_updateDelayedEvent( - delayId, - UpdateDelayedEventAction.Restart, - requestOptions, - ); - } else { - throw e; - } - } + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Restart, requestOptions); } /** @@ -3691,17 +3631,26 @@ export class MatrixClient extends TypedEventEmitter { + return this.updateScheduledDelayedEvent(delayId, UpdateDelayedEventAction.Send, requestOptions); + } + + private async updateScheduledDelayedEvent( + delayId: string, + action: UpdateDelayedEventAction, + requestOptions: IRequestOpts = {}, ): Promise { if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { throw new UnsupportedDelayedEventsEndpointError( "Server does not support the delayed events API", - "sendScheduledDelayedEvent", + `${action}ScheduledDelayedEvent`, ); } try { - const path = utils.encodeUri("/delayed_events/$delayId/send", { + const path = utils.encodeUri("/delayed_events/$delayId/$action", { $delayId: delayId, + $action: action, }); return await this.http.request(Method.Post, path, undefined, undefined, { ...requestOptions, @@ -3709,13 +3658,30 @@ export class MatrixClient extends TypedEventEmitter { + const path = utils.encodeUri("/delayed_events/$delayId", { + $delayId: delayId, + }); + const data = { + action, + }; + return await this.http.request(Method.Post, path, undefined, data, { + ...requestOptions, + prefix: `${ClientPrefix.Unstable}/${UNSTABLE_MSC4140_DELAYED_EVENTS}`, + }); + } + /** * Send a receipt. * @param event - The event being acknowledged From ad5f2117bd9878e4a841e96862b6a97a6bf2be88 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 30 Oct 2025 12:00:58 -0400 Subject: [PATCH 4/5] Reduce code duplication again --- src/embedded.ts | 34 +++------------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/embedded.ts b/src/embedded.ts index 8f201fce3de..93f16502572 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -478,17 +478,7 @@ export class RoomWidgetClient extends MatrixClient { */ // eslint-disable-next-line public async _unstable_cancelScheduledDelayedEvent(delayId: string): Promise { - if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { - throw new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "cancelScheduledDelayedEvent", - ); - } - - await this.widgetApi - .updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel) - .catch(timeoutToConnectionError); - return {}; + return this._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel); } /** @@ -496,17 +486,7 @@ export class RoomWidgetClient extends MatrixClient { */ // eslint-disable-next-line public async _unstable_restartScheduledDelayedEvent(delayId: string): Promise { - if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { - throw new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "restartScheduledDelayedEvent", - ); - } - - await this.widgetApi - .updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart) - .catch(timeoutToConnectionError); - return {}; + return this._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Restart); } /** @@ -514,15 +494,7 @@ export class RoomWidgetClient extends MatrixClient { */ // eslint-disable-next-line public async _unstable_sendScheduledDelayedEvent(delayId: string): Promise { - if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { - throw new UnsupportedDelayedEventsEndpointError( - "Server does not support the delayed events API", - "sendScheduledDelayedEvent", - ); - } - - await this.widgetApi.updateDelayedEvent(delayId, UpdateDelayedEventAction.Send).catch(timeoutToConnectionError); - return {}; + return this._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Send); } /** From 2b7667ddb6c4d07d87b6aa032829cf34543d0382 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Thu, 30 Oct 2025 15:19:52 -0400 Subject: [PATCH 5/5] Add a little more test coverage --- spec/unit/embedded.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index dc1d43a6b5a..566e7e69928 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -603,6 +603,13 @@ describe("RoomWidgetClient", () => { "Server does not support", ); } + for (const updateDelayedEvent of [ + client._unstable_cancelScheduledDelayedEvent, + client._unstable_restartScheduledDelayedEvent, + client._unstable_sendScheduledDelayedEvent, + ]) { + await expect(updateDelayedEvent.call(client, "id")).rejects.toThrow("Server does not support"); + } }); }); });