diff --git a/spec/unit/matrixrtc/types.spec.ts b/spec/unit/matrixrtc/types.spec.ts new file mode 100644 index 0000000000..2fb572e7a9 --- /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 120000ms", () => { + const res = parseCallNotificationContent({ ...validBase, lifetime: 130000 }); + expect(res.lifetime).toBe(120000); + }); + + 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/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index b344a22d8b..98d057b25a 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 120000ms 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"] >= 120000 ? 120000 : 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.