diff --git a/server/src/domain/notifications/providers/matrix.ts b/server/src/domain/notifications/providers/matrix.ts index a1f35b0ca..1fafc44b1 100644 --- a/server/src/domain/notifications/providers/matrix.ts +++ b/server/src/domain/notifications/providers/matrix.ts @@ -38,10 +38,13 @@ export class MatrixProvider extends NotificationProvider { private sendMessageWithBody = async (notification: Partial, body: unknown): Promise => { const { homeserverUrl, accessToken, roomId } = notification; - const txnId = randomUUID(); - const url = `${homeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`; + const baseUrl = this.normalizeHomeserverUrl(homeserverUrl as string); try { + const resolvedRoomId = await this.resolveRoomId(baseUrl, accessToken as string, roomId as string); + const txnId = randomUUID(); + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoomId)}/send/m.room.message/${txnId}`; + await got.put(url, { json: body, headers: { @@ -63,6 +66,35 @@ export class MatrixProvider extends NotificationProvider { } }; + private normalizeHomeserverUrl(homeserverUrl: string): string { + return homeserverUrl.replace(/\/+$/, ""); + } + + /** + * The send endpoint only accepts a room ID (`!id:server`). Element shows users the room + * alias (`#name:server`) instead, and an unresolved alias breaks the request outright: + * the `#` opens a URI fragment, so the PUT lands on /rooms/ rather than the send + * endpoint. Resolve aliases through the directory first, per the client-server spec. + */ + private resolveRoomId = async (baseUrl: string, accessToken: string, roomIdOrAlias: string): Promise => { + if (!roomIdOrAlias.startsWith("#")) { + return roomIdOrAlias; + } + + const url = `${baseUrl}/_matrix/client/v3/directory/room/${encodeURIComponent(roomIdOrAlias)}`; + const response = await got.get(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + responseType: "json", + ...this.gotRequestOptions(), + }); + + const roomId = (response.body as { room_id?: string })?.room_id; + if (!roomId) { + throw new Error(`Matrix directory returned no room_id for alias ${roomIdOrAlias}`); + } + return roomId; + }; + /** * Build Matrix message from NotificationMessage * Returns both plain text and HTML formatted versions diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts index 65305e731..1294a677b 100644 --- a/server/test/unit/providers/notifications/matrixProvider.test.ts +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -4,7 +4,8 @@ import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWi import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; const mockGotPut = jest.fn().mockResolvedValue({}); -jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); +const mockGotGet = jest.fn().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); +jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut, get: mockGotGet } })); jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); const { MatrixProvider } = await import("../../../../src/domain/notifications/providers/matrix.ts"); @@ -23,13 +24,16 @@ testNotificationProviderContract("MatrixProvider", { }); describe("MatrixProvider", () => { - beforeEach(() => mockGotPut.mockReset().mockResolvedValue({})); + beforeEach(() => { + mockGotPut.mockReset().mockResolvedValue({}); + mockGotGet.mockReset().mockResolvedValue({ body: { room_id: "!resolved:example.com" } }); + }); describe("sendTestAlert", () => { it("sends to Matrix API and returns true", async () => { expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); expect(mockGotPut).toHaveBeenCalledWith( - expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"), + expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room%3Aexample.com/send/m.room.message/test-uuid-1234"), expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }), }) @@ -141,4 +145,69 @@ describe("MatrixProvider", () => { expect(html).toContain("#808080"); }); }); + + describe("room identifiers", () => { + const putUrl = () => mockGotPut.mock.calls[0][0] as string; + + it("percent-encodes the room ID in the send path", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(putUrl()).toContain("/rooms/!abc123%3Aexample.com/send/"); + }); + + it("does not resolve a room ID through the directory", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "!abc123:example.com" })); + expect(mockGotGet).not.toHaveBeenCalled(); + }); + + // An unresolved alias breaks the request outright: the "#" opens a URI fragment, so + // the PUT lands on /rooms/ instead of the send endpoint. + it("resolves a room alias through the directory before sending", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(true); + expect(mockGotGet).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/directory/room/%23alerts%3Aexample.com", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer token-abc" }) }) + ); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + + it("never leaves an unencoded # in the request URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" })); + expect(putUrl()).not.toContain("#"); + expect(new URL(putUrl()).hash).toBe(""); + }); + + it("returns false and logs when the alias cannot be resolved", async () => { + mockGotGet.mockRejectedValue(new Error("M_NOT_FOUND")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#missing:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("returns false when the directory response carries no room_id", async () => { + mockGotGet.mockResolvedValue({ body: {} }); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ roomId: "#alerts:example.com" }))).toBe(false); + expect(mockGotPut).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("strips a trailing slash from the homeserver URL", async () => { + const { provider } = createProvider(); + await provider.sendTestAlert(makeNotification({ homeserverUrl: "https://matrix.example.com/" })); + expect(putUrl()).toContain("https://matrix.example.com/_matrix/"); + expect(putUrl()).not.toContain("com//_matrix"); + }); + + it("resolves aliases for real alerts, not just test alerts", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification({ roomId: "#alerts:example.com" }) as any, makeMessage()); + expect(mockGotGet).toHaveBeenCalled(); + expect(putUrl()).toContain("/rooms/!resolved%3Aexample.com/send/"); + }); + }); });