fix: resolve Matrix room aliases and encode room IDs in the send path

Matrix notifications failed for anyone who entered a room alias. The send
endpoint only accepts a room ID, and an unresolved alias broke the request
outright: the leading "#" opens a URI fragment, so the PUT landed on
/_matrix/client/v3/rooms/ instead of the send endpoint. Element shows users
the alias, so this is the value most people paste in.

- Resolve "#alias:server" through GET /_matrix/client/v3/directory/room/{alias}
  before sending, per the client-server spec
- Percent-encode the room ID in the send path; ":" was previously sent raw
- Strip trailing slashes from the homeserver URL to avoid "//_matrix/..."

Alias resolution failures return false and log, matching how the provider
already handles send failures.

Closes #3478
This commit is contained in:
egeoztass committed 2026-08-14 17:55:36 +03:00
1 parent f97ca8565a
commit 099eaa38c3
2 files changed
+106 -5

No files matched your search

@@ -38,10 +38,13 @@ export class MatrixProvider extends NotificationProvider {
private sendMessageWithBody = async (notification: Partial<Notification>, body: unknown): Promise<boolean> => {
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<string> => {
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
@@ -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/");
});
});
});