diff --git a/spec/unit/pushprocessor.spec.ts b/spec/unit/pushprocessor.spec.ts index ee30eb9a65..da23a0dead 100644 --- a/spec/unit/pushprocessor.spec.ts +++ b/spec/unit/pushprocessor.spec.ts @@ -1049,3 +1049,98 @@ describe("getPushRuleGlobRegex", () => { expect(input.split(regex)).toEqual(["Foo ", "@room", " Bar"]); }); }); + +describe("recipient_permission condition (MSC4506 knock push rule)", () => { + const roomId = "!knockroom:server"; + const adminId = "@admin:server"; + const knockerId = "@knocker:server"; + + const knockRule: IPushRule = { + rule_id: ".org.matrix.msc4506.rule.knock", + default: true, + enabled: true, + conditions: [ + { kind: ConditionKind.EventPropertyIs, key: "type", value: "m.room.member" }, + { kind: ConditionKind.EventPropertyIs, key: "content.membership", value: "knock" }, + { kind: ConditionKind.RecipientPermissionPrefix, key: "invite" }, + ], + actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }], + }; + + const makeClient = (plContent: IContent): MatrixClient => + ({ + getRoom: () => ({ + currentState: { + getStateEvents: (type: string, stateKey: string) => + type === EventType.RoomPowerLevels && stateKey === "" ? { getContent: () => plContent } : null, + getMember: () => null, + getJoinedMemberCount: () => 2, + members: {}, + }, + }), + ...mockClientMethodsUser(adminId), + supportsIntentionalMentions: () => true, + pushRules: { + device: {}, + global: { + override: [ + knockRule, + { + rule_id: ".m.rule.member_event", + default: true, + enabled: true, + conditions: [{ kind: ConditionKind.EventPropertyIs, key: "type", value: "m.room.member" }], + actions: [], + }, + ], + }, + }, + }) as unknown as MatrixClient; + + const mkKnock = (): MatrixEvent => + utils.mkEvent({ + type: "m.room.member", + room: roomId, + user: knockerId, + skey: knockerId, + event: true, + content: { membership: "knock" }, + }); + + const actionsFor = (plContent: IContent): IActionsObject => { + const pushProcessor = new PushProcessor(makeClient(plContent)); + return pushProcessor.actionsForEvent(mkKnock()); + }; + + it("notifies a user whose power level allows them to invite", () => { + const actions = actionsFor({ invite: 50, users: { [adminId]: 100 } }); + expect(actions.notify).toBeTruthy(); + }); + + it("does not notify a user below the required invite level", () => { + const actions = actionsFor({ invite: 50, users: { [adminId]: 0 } }); + expect(actions?.notify).toBeFalsy(); + }); + + it("uses the spec default invite level (0) when absent", () => { + // invite defaults to 0, users_default defaults to 0 -> everyone can invite + const actions = actionsFor({ users: {} }); + expect(actions.notify).toBeTruthy(); + }); + + it("respects users_default for the recipient's level", () => { + const actions = actionsFor({ invite: 25, users_default: 30 }); + expect(actions.notify).toBeTruthy(); + }); + + it("does not match an unknown permission key", () => { + const pushProcessor = new PushProcessor(makeClient({ invite: 0, frobnicate: 0, users: { [adminId]: 100 } })); + const ruleWithBadKey: IPushRule = { + ...knockRule, + conditions: [{ kind: ConditionKind.RecipientPermissionPrefix, key: "frobnicate" }], + }; + expect( + pushProcessor.ruleMatchesEvent({ ...ruleWithBadKey, rule_id: "test", kind: "override" } as any, mkKnock()), + ).toBe(false); + }); +}); diff --git a/src/@types/PushRules.ts b/src/@types/PushRules.ts index 3f064f7b62..134b4795b4 100644 --- a/src/@types/PushRules.ts +++ b/src/@types/PushRules.ts @@ -64,6 +64,8 @@ export enum ConditionKind { ContainsDisplayName = "contains_display_name", RoomMemberCount = "room_member_count", SenderNotificationPermission = "sender_notification_permission", + RecipientPermission = "recipient_permission", + RecipientPermissionPrefix = "org.matrix.msc4506.recipient_permission", CallStarted = "call_started", CallStartedPrefix = "org.matrix.msc3914.call_started", } @@ -103,6 +105,12 @@ export interface ISenderNotificationPermissionCondition extends IPushRuleConditi key: string; } +export interface IRecipientPermissionCondition extends IPushRuleCondition< + ConditionKind.RecipientPermission | ConditionKind.RecipientPermissionPrefix +> { + key: string; +} + export interface ICallStartedCondition extends IPushRuleCondition { // no additional fields } @@ -120,6 +128,7 @@ export type PushRuleCondition = | IContainsDisplayNameCondition | IRoomMemberCountCondition | ISenderNotificationPermissionCondition + | IRecipientPermissionCondition | ICallStartedCondition | ICallStartedPrefixCondition; @@ -143,6 +152,8 @@ export enum RuleId { Message = ".m.rule.message", EncryptedMessage = ".m.rule.encrypted", InviteToSelf = ".m.rule.invite_for_me", + Knock = ".m.rule.knock", + KnockUnstable = ".org.matrix.msc4506.rule.knock", MemberEvent = ".m.rule.member_event", IncomingCall = ".m.rule.call", SuppressNotices = ".m.rule.suppress_notices", diff --git a/src/pushprocessor.ts b/src/pushprocessor.ts index 2044977402..8d4400e5c7 100644 --- a/src/pushprocessor.ts +++ b/src/pushprocessor.ts @@ -29,6 +29,7 @@ import { type IEventPropertyIsCondition, type IPushRule, type IPushRules, + type IRecipientPermissionCondition, type IRoomMemberCountCondition, type ISenderNotificationPermissionCondition, type PushRuleAction, @@ -479,6 +480,9 @@ export class PushProcessor { return this.eventFulfillsRoomMemberCountCondition(cond, ev); case ConditionKind.SenderNotificationPermission: return this.eventFulfillsSenderNotifPermCondition(cond, ev); + case ConditionKind.RecipientPermission: + case ConditionKind.RecipientPermissionPrefix: + return this.eventFulfillsRecipientPermCondition(cond, ev); case ConditionKind.CallStarted: case ConditionKind.CallStartedPrefix: return this.eventFulfillsCallStartedCondition(cond, ev); @@ -510,6 +514,41 @@ export class PushProcessor { return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()!); } + /** + * MSC4506 `recipient_permission` condition: matches if the user these push + * rules are being evaluated for (i.e. us) has a power level at least that + * required to perform the `m.room.power_levels` action named by `key` + * (e.g. "invite"), in the room the event is in. + */ + private eventFulfillsRecipientPermCondition(cond: IRecipientPermissionCondition, ev: MatrixEvent): boolean { + const actionKey = cond["key"]; + // Only the power-levels permission actions are valid keys. + const defaultLevels: Record = { invite: 0, kick: 50, ban: 50, redact: 50 }; + if (!actionKey || !(actionKey in defaultLevels)) { + return false; + } + + const room = this.client.getRoom(ev.getRoomId()); + const userId = this.client.getUserId(); + if (!room?.currentState || !userId) { + return false; + } + + // Note that this should not be the current state of the room but the state at + // the point the event is in the DAG. Unfortunately the js-sdk does not store + // this. + const plContent = room.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() ?? {}; + const requiredLevel = + typeof plContent[actionKey] === "number" ? plContent[actionKey] : defaultLevels[actionKey]; + const ourLevel = + typeof plContent.users?.[userId] === "number" + ? plContent.users[userId] + : typeof plContent.users_default === "number" + ? plContent.users_default + : 0; + return ourLevel >= requiredLevel; + } + private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean { if (!cond.is) { return false;