You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-26 17:03:12 +03:00
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
/*
|
|
Copyright 2015 - 2021 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 { escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils";
|
|
import { logger } from './logger';
|
|
import { MatrixClient } from "./client";
|
|
import { MatrixEvent } from "./models/event";
|
|
import {
|
|
ConditionKind,
|
|
IAnnotatedPushRule,
|
|
IContainsDisplayNameCondition,
|
|
IEventMatchCondition,
|
|
IPushRule,
|
|
IPushRules,
|
|
IRoomMemberCountCondition,
|
|
ISenderNotificationPermissionCondition,
|
|
PushRuleAction,
|
|
PushRuleActionName,
|
|
PushRuleCondition,
|
|
PushRuleKind,
|
|
PushRuleSet,
|
|
TweakName,
|
|
} from "./@types/PushRules";
|
|
import { EventType } from "./@types/event";
|
|
|
|
/**
|
|
* @module pushprocessor
|
|
*/
|
|
|
|
const RULEKINDS_IN_ORDER = [
|
|
PushRuleKind.Override,
|
|
PushRuleKind.ContentSpecific,
|
|
PushRuleKind.RoomSpecific,
|
|
PushRuleKind.SenderSpecific,
|
|
PushRuleKind.Underride,
|
|
];
|
|
|
|
// The default override rules to apply to the push rules that arrive from the server.
|
|
// We do this for two reasons:
|
|
// 1. Synapse is unlikely to send us the push rule in an incremental sync - see
|
|
// https://github.com/matrix-org/synapse/pull/4867#issuecomment-481446072 for
|
|
// more details.
|
|
// 2. We often want to start using push rules ahead of the server supporting them,
|
|
// and so we can put them here.
|
|
const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
|
|
{
|
|
// For homeservers which don't support MSC2153 yet
|
|
rule_id: ".m.rule.reaction",
|
|
default: true,
|
|
enabled: true,
|
|
conditions: [
|
|
{
|
|
kind: ConditionKind.EventMatch,
|
|
key: "type",
|
|
pattern: "m.reaction",
|
|
},
|
|
],
|
|
actions: [
|
|
PushRuleActionName.DontNotify,
|
|
],
|
|
},
|
|
{
|
|
// For homeservers which don't support MSC3786 yet
|
|
rule_id: ".org.matrix.msc3786.rule.room.server_acl",
|
|
default: true,
|
|
enabled: true,
|
|
conditions: [
|
|
{
|
|
kind: ConditionKind.EventMatch,
|
|
key: "type",
|
|
pattern: EventType.RoomServerAcl,
|
|
},
|
|
],
|
|
actions: [],
|
|
},
|
|
];
|
|
|
|
export interface IActionsObject {
|
|
notify: boolean;
|
|
tweaks: Partial<Record<TweakName, any>>;
|
|
}
|
|
|
|
export class PushProcessor {
|
|
/**
|
|
* Construct a Push Processor.
|
|
* @constructor
|
|
* @param {Object} client The Matrix client object to use
|
|
*/
|
|
constructor(private readonly client: MatrixClient) {}
|
|
|
|
/**
|
|
* Convert a list of actions into a object with the actions as keys and their values
|
|
* eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ]
|
|
* becomes { notify: true, tweaks: { sound: 'default' } }
|
|
* @param {array} actionList The actions list
|
|
*
|
|
* @return {object} A object with key 'notify' (true or false) and an object of actions
|
|
*/
|
|
public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject {
|
|
const actionObj: IActionsObject = { notify: false, tweaks: {} };
|
|
for (let i = 0; i < actionList.length; ++i) {
|
|
const action = actionList[i];
|
|
if (action === PushRuleActionName.Notify) {
|
|
actionObj.notify = true;
|
|
} else if (typeof action === 'object') {
|
|
if (action.value === undefined) {
|
|
action.value = true;
|
|
}
|
|
actionObj.tweaks[action.set_tweak] = action.value;
|
|
}
|
|
}
|
|
return actionObj;
|
|
}
|
|
|
|
/**
|
|
* Rewrites conditions on a client's push rules to match the defaults
|
|
* where applicable. Useful for upgrading push rules to more strict
|
|
* conditions when the server is falling behind on defaults.
|
|
* @param {object} incomingRules The client's existing push rules
|
|
* @returns {object} The rewritten rules
|
|
*/
|
|
public static rewriteDefaultRules(incomingRules: IPushRules): IPushRules {
|
|
let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
|
|
|
|
// These lines are mostly to make the tests happy. We shouldn't run into these
|
|
// properties missing in practice.
|
|
if (!newRules) newRules = {} as IPushRules;
|
|
if (!newRules.global) newRules.global = {} as PushRuleSet;
|
|
if (!newRules.global.override) newRules.global.override = [];
|
|
|
|
// Merge the client-level defaults with the ones from the server
|
|
const globalOverrides = newRules.global.override;
|
|
for (const override of DEFAULT_OVERRIDE_RULES) {
|
|
const existingRule = globalOverrides
|
|
.find((r) => r.rule_id === override.rule_id);
|
|
|
|
if (existingRule) {
|
|
// Copy over the actions, default, and conditions. Don't touch the user's preference.
|
|
existingRule.default = override.default;
|
|
existingRule.conditions = override.conditions;
|
|
existingRule.actions = override.actions;
|
|
} else {
|
|
// Add the rule
|
|
const ruleId = override.rule_id;
|
|
logger.warn(`Adding default global override for ${ruleId}`);
|
|
globalOverrides.push(override);
|
|
}
|
|
}
|
|
|
|
return newRules;
|
|
}
|
|
|
|
private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp
|
|
|
|
private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule {
|
|
for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) {
|
|
const kind = RULEKINDS_IN_ORDER[ruleKindIndex];
|
|
const ruleset = kindset[kind];
|
|
if (!ruleset) {
|
|
continue;
|
|
}
|
|
|
|
for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
|
|
const rule = ruleset[ruleIndex];
|
|
if (!rule.enabled) {
|
|
continue;
|
|
}
|
|
|
|
const rawrule = this.templateRuleToRaw(kind, rule);
|
|
if (!rawrule) {
|
|
continue;
|
|
}
|
|
|
|
if (this.ruleMatchesEvent(rawrule, ev)) {
|
|
return {
|
|
...rule,
|
|
kind,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private templateRuleToRaw(kind: PushRuleKind, tprule: any): any {
|
|
const rawrule = {
|
|
'rule_id': tprule.rule_id,
|
|
'actions': tprule.actions,
|
|
'conditions': [],
|
|
};
|
|
switch (kind) {
|
|
case PushRuleKind.Underride:
|
|
case PushRuleKind.Override:
|
|
rawrule.conditions = tprule.conditions;
|
|
break;
|
|
case PushRuleKind.RoomSpecific:
|
|
if (!tprule.rule_id) {
|
|
return null;
|
|
}
|
|
rawrule.conditions.push({
|
|
'kind': ConditionKind.EventMatch,
|
|
'key': 'room_id',
|
|
'value': tprule.rule_id,
|
|
});
|
|
break;
|
|
case PushRuleKind.SenderSpecific:
|
|
if (!tprule.rule_id) {
|
|
return null;
|
|
}
|
|
rawrule.conditions.push({
|
|
'kind': ConditionKind.EventMatch,
|
|
'key': 'user_id',
|
|
'value': tprule.rule_id,
|
|
});
|
|
break;
|
|
case PushRuleKind.ContentSpecific:
|
|
if (!tprule.pattern) {
|
|
return null;
|
|
}
|
|
rawrule.conditions.push({
|
|
'kind': ConditionKind.EventMatch,
|
|
'key': 'content.body',
|
|
'pattern': tprule.pattern,
|
|
});
|
|
break;
|
|
}
|
|
return rawrule;
|
|
}
|
|
|
|
private eventFulfillsCondition(cond: PushRuleCondition, ev: MatrixEvent): boolean {
|
|
switch (cond.kind) {
|
|
case ConditionKind.EventMatch:
|
|
return this.eventFulfillsEventMatchCondition(cond, ev);
|
|
case ConditionKind.ContainsDisplayName:
|
|
return this.eventFulfillsDisplayNameCondition(cond, ev);
|
|
case ConditionKind.RoomMemberCount:
|
|
return this.eventFulfillsRoomMemberCountCondition(cond, ev);
|
|
case ConditionKind.SenderNotificationPermission:
|
|
return this.eventFulfillsSenderNotifPermCondition(cond, ev);
|
|
}
|
|
|
|
// unknown conditions: we previously matched all unknown conditions,
|
|
// but given that rules can be added to the base rules on a server,
|
|
// it's probably better to not match unknown conditions.
|
|
return false;
|
|
}
|
|
|
|
private eventFulfillsSenderNotifPermCondition(
|
|
cond: ISenderNotificationPermissionCondition,
|
|
ev: MatrixEvent,
|
|
): boolean {
|
|
const notifLevelKey = cond['key'];
|
|
if (!notifLevelKey) {
|
|
return false;
|
|
}
|
|
|
|
const room = this.client.getRoom(ev.getRoomId());
|
|
if (!room?.currentState) {
|
|
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.
|
|
return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender());
|
|
}
|
|
|
|
private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean {
|
|
if (!cond.is) {
|
|
return false;
|
|
}
|
|
|
|
const room = this.client.getRoom(ev.getRoomId());
|
|
if (!room || !room.currentState || !room.currentState.members) {
|
|
return false;
|
|
}
|
|
|
|
const memberCount = room.currentState.getJoinedMemberCount();
|
|
|
|
const m = cond.is.match(/^([=<>]*)(\d*)$/);
|
|
if (!m) {
|
|
return false;
|
|
}
|
|
const ineq = m[1];
|
|
const rhs = parseInt(m[2]);
|
|
if (isNaN(rhs)) {
|
|
return false;
|
|
}
|
|
switch (ineq) {
|
|
case '':
|
|
case '==':
|
|
return memberCount == rhs;
|
|
case '<':
|
|
return memberCount < rhs;
|
|
case '>':
|
|
return memberCount > rhs;
|
|
case '<=':
|
|
return memberCount <= rhs;
|
|
case '>=':
|
|
return memberCount >= rhs;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private eventFulfillsDisplayNameCondition(cond: IContainsDisplayNameCondition, ev: MatrixEvent): boolean {
|
|
let content = ev.getContent();
|
|
if (ev.isEncrypted() && ev.getClearContent()) {
|
|
content = ev.getClearContent();
|
|
}
|
|
if (!content || !content.body || typeof content.body != 'string') {
|
|
return false;
|
|
}
|
|
|
|
const room = this.client.getRoom(ev.getRoomId());
|
|
if (!room || !room.currentState || !room.currentState.members ||
|
|
!room.currentState.getMember(this.client.credentials.userId)) {
|
|
return false;
|
|
}
|
|
|
|
const displayName = room.currentState.getMember(this.client.credentials.userId).name;
|
|
|
|
// N.B. we can't use \b as it chokes on unicode. however \W seems to be okay
|
|
// as shorthand for [^0-9A-Za-z_].
|
|
const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i');
|
|
return content.body.search(pat) > -1;
|
|
}
|
|
|
|
private eventFulfillsEventMatchCondition(cond: IEventMatchCondition, ev: MatrixEvent): boolean {
|
|
if (!cond.key) {
|
|
return false;
|
|
}
|
|
|
|
const val = this.valueForDottedKey(cond.key, ev);
|
|
if (typeof val !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
if (cond.value) {
|
|
return cond.value === val;
|
|
}
|
|
|
|
if (typeof cond.pattern !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
let regex;
|
|
|
|
if (cond.key == 'content.body') {
|
|
regex = this.createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)');
|
|
} else {
|
|
regex = this.createCachedRegex('^', cond.pattern, '$');
|
|
}
|
|
|
|
return !!val.match(regex);
|
|
}
|
|
|
|
private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
|
|
if (PushProcessor.cachedGlobToRegex[glob]) {
|
|
return PushProcessor.cachedGlobToRegex[glob];
|
|
}
|
|
PushProcessor.cachedGlobToRegex[glob] = new RegExp(
|
|
prefix + globToRegexp(glob) + suffix,
|
|
'i', // Case insensitive
|
|
);
|
|
return PushProcessor.cachedGlobToRegex[glob];
|
|
}
|
|
|
|
private valueForDottedKey(key: string, ev: MatrixEvent): any {
|
|
const parts = key.split('.');
|
|
let val;
|
|
|
|
// special-case the first component to deal with encrypted messages
|
|
const firstPart = parts[0];
|
|
if (firstPart === 'content') {
|
|
val = ev.getContent();
|
|
parts.shift();
|
|
} else if (firstPart === 'type') {
|
|
val = ev.getType();
|
|
parts.shift();
|
|
} else {
|
|
// use the raw event for any other fields
|
|
val = ev.event;
|
|
}
|
|
|
|
while (parts.length > 0) {
|
|
const thisPart = parts.shift();
|
|
if (isNullOrUndefined(val[thisPart])) {
|
|
return null;
|
|
}
|
|
val = val[thisPart];
|
|
}
|
|
return val;
|
|
}
|
|
|
|
private matchingRuleForEventWithRulesets(ev: MatrixEvent, rulesets): IAnnotatedPushRule {
|
|
if (!rulesets) {
|
|
return null;
|
|
}
|
|
if (ev.getSender() === this.client.credentials.userId) {
|
|
return null;
|
|
}
|
|
|
|
return this.matchingRuleFromKindSet(ev, rulesets.global);
|
|
}
|
|
|
|
private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets): IActionsObject {
|
|
const rule = this.matchingRuleForEventWithRulesets(ev, rulesets);
|
|
if (!rule) {
|
|
return {} as IActionsObject;
|
|
}
|
|
|
|
const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
|
|
|
|
// Some actions are implicit in some situations: we add those here
|
|
if (actionObj.tweaks.highlight === undefined) {
|
|
// if it isn't specified, highlight if it's a content
|
|
// rule but otherwise not
|
|
actionObj.tweaks.highlight = (rule.kind == PushRuleKind.ContentSpecific);
|
|
}
|
|
|
|
return actionObj;
|
|
}
|
|
|
|
public ruleMatchesEvent(rule: IPushRule, ev: MatrixEvent): boolean {
|
|
if (!rule.conditions?.length) return true;
|
|
|
|
let ret = true;
|
|
for (let i = 0; i < rule.conditions.length; ++i) {
|
|
const cond = rule.conditions[i];
|
|
// @ts-ignore
|
|
ret &= this.eventFulfillsCondition(cond, ev);
|
|
}
|
|
//console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
|
|
return ret;
|
|
}
|
|
|
|
/**
|
|
* Get the user's push actions for the given event
|
|
*
|
|
* @param {module:models/event.MatrixEvent} ev
|
|
*
|
|
* @return {PushAction}
|
|
*/
|
|
public actionsForEvent(ev: MatrixEvent): IActionsObject {
|
|
return this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
|
|
}
|
|
|
|
/**
|
|
* Get one of the users push rules by its ID
|
|
*
|
|
* @param {string} ruleId The ID of the rule to search for
|
|
* @return {object} The push rule, or null if no such rule was found
|
|
*/
|
|
public getPushRuleById(ruleId: string): IPushRule {
|
|
for (const scope of ['global']) {
|
|
if (this.client.pushRules[scope] === undefined) continue;
|
|
|
|
for (const kind of RULEKINDS_IN_ORDER) {
|
|
if (this.client.pushRules[scope][kind] === undefined) continue;
|
|
|
|
for (const rule of this.client.pushRules[scope][kind]) {
|
|
if (rule.rule_id === ruleId) return rule;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} PushAction
|
|
* @type {Object}
|
|
* @property {boolean} notify Whether this event should notify the user or not.
|
|
* @property {Object} tweaks How this event should be notified.
|
|
* @property {boolean} tweaks.highlight Whether this event should be highlighted
|
|
* on the UI.
|
|
* @property {boolean} tweaks.sound Whether this notification should produce a
|
|
* noise.
|
|
*/
|
|
|