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:
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/");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user