UNPKG

12.5 kBJavaScriptView Raw
1/*
2Copyright 2015, 2016 OpenMarket Ltd
3Copyright 2017 New Vector Ltd
4
5Licensed under the Apache License, Version 2.0 (the "License");
6you may not use this file except in compliance with the License.
7You may obtain a copy of the License at
8
9 http://www.apache.org/licenses/LICENSE-2.0
10
11Unless required by applicable law or agreed to in writing, software
12distributed under the License is distributed on an "AS IS" BASIS,
13WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14See the License for the specific language governing permissions and
15limitations under the License.
16*/
17
18import {escapeRegExp, globToRegexp} from "./utils";
19
20/**
21 * @module pushprocessor
22 */
23
24const RULEKINDS_IN_ORDER = ['override', 'content', 'room', 'sender', 'underride'];
25
26/**
27 * Construct a Push Processor.
28 * @constructor
29 * @param {Object} client The Matrix client object to use
30 */
31function PushProcessor(client) {
32 const cachedGlobToRegex = {
33 // $glob: RegExp,
34 };
35
36 const matchingRuleFromKindSet = (ev, kindset, device) => {
37 for (let ruleKindIndex = 0;
38 ruleKindIndex < RULEKINDS_IN_ORDER.length;
39 ++ruleKindIndex) {
40 const kind = RULEKINDS_IN_ORDER[ruleKindIndex];
41 const ruleset = kindset[kind];
42
43 for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
44 const rule = ruleset[ruleIndex];
45 if (!rule.enabled) {
46 continue;
47 }
48
49 const rawrule = templateRuleToRaw(kind, rule, device);
50 if (!rawrule) {
51 continue;
52 }
53
54 if (this.ruleMatchesEvent(rawrule, ev)) {
55 rule.kind = kind;
56 return rule;
57 }
58 }
59 }
60 return null;
61 };
62
63 const templateRuleToRaw = function(kind, tprule, device) {
64 const rawrule = {
65 'rule_id': tprule.rule_id,
66 'actions': tprule.actions,
67 'conditions': [],
68 };
69 switch (kind) {
70 case 'underride':
71 case 'override':
72 rawrule.conditions = tprule.conditions;
73 break;
74 case 'room':
75 if (!tprule.rule_id) {
76 return null;
77 }
78 rawrule.conditions.push({
79 'kind': 'event_match',
80 'key': 'room_id',
81 'value': tprule.rule_id,
82 });
83 break;
84 case 'sender':
85 if (!tprule.rule_id) {
86 return null;
87 }
88 rawrule.conditions.push({
89 'kind': 'event_match',
90 'key': 'user_id',
91 'value': tprule.rule_id,
92 });
93 break;
94 case 'content':
95 if (!tprule.pattern) {
96 return null;
97 }
98 rawrule.conditions.push({
99 'kind': 'event_match',
100 'key': 'content.body',
101 'pattern': tprule.pattern,
102 });
103 break;
104 }
105 if (device) {
106 rawrule.conditions.push({
107 'kind': 'device',
108 'profile_tag': device,
109 });
110 }
111 return rawrule;
112 };
113
114 const eventFulfillsCondition = function(cond, ev) {
115 const condition_functions = {
116 "event_match": eventFulfillsEventMatchCondition,
117 "device": eventFulfillsDeviceCondition,
118 "contains_display_name": eventFulfillsDisplayNameCondition,
119 "room_member_count": eventFulfillsRoomMemberCountCondition,
120 "sender_notification_permission": eventFulfillsSenderNotifPermCondition,
121 };
122 if (condition_functions[cond.kind]) {
123 return condition_functions[cond.kind](cond, ev);
124 }
125 // unknown conditions: we previously matched all unknown conditions,
126 // but given that rules can be added to the base rules on a server,
127 // it's probably better to not match unknown conditions.
128 return false;
129 };
130
131 const eventFulfillsSenderNotifPermCondition = function(cond, ev) {
132 const notifLevelKey = cond['key'];
133 if (!notifLevelKey) {
134 return false;
135 }
136
137 const room = client.getRoom(ev.getRoomId());
138 if (!room || !room.currentState) {
139 return false;
140 }
141
142 // Note that this should not be the current state of the room but the state at
143 // the point the event is in the DAG. Unfortunately the js-sdk does not store
144 // this.
145 return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender());
146 };
147
148 const eventFulfillsRoomMemberCountCondition = function(cond, ev) {
149 if (!cond.is) {
150 return false;
151 }
152
153 const room = client.getRoom(ev.getRoomId());
154 if (!room || !room.currentState || !room.currentState.members) {
155 return false;
156 }
157
158 const memberCount = room.currentState.getJoinedMemberCount();
159
160 const m = cond.is.match(/^([=<>]*)([0-9]*)$/);
161 if (!m) {
162 return false;
163 }
164 const ineq = m[1];
165 const rhs = parseInt(m[2]);
166 if (isNaN(rhs)) {
167 return false;
168 }
169 switch (ineq) {
170 case '':
171 case '==':
172 return memberCount == rhs;
173 case '<':
174 return memberCount < rhs;
175 case '>':
176 return memberCount > rhs;
177 case '<=':
178 return memberCount <= rhs;
179 case '>=':
180 return memberCount >= rhs;
181 default:
182 return false;
183 }
184 };
185
186 const eventFulfillsDisplayNameCondition = function(cond, ev) {
187 const content = ev.getContent();
188 if (!content || !content.body || typeof content.body != 'string') {
189 return false;
190 }
191
192 const room = client.getRoom(ev.getRoomId());
193 if (!room || !room.currentState || !room.currentState.members ||
194 !room.currentState.getMember(client.credentials.userId)) {
195 return false;
196 }
197
198 const displayName = room.currentState.getMember(client.credentials.userId).name;
199
200 // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay
201 // as shorthand for [^0-9A-Za-z_].
202 const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", 'i');
203 return content.body.search(pat) > -1;
204 };
205
206 const eventFulfillsDeviceCondition = function(cond, ev) {
207 return false; // XXX: Allow a profile tag to be set for the web client instance
208 };
209
210 const eventFulfillsEventMatchCondition = function(cond, ev) {
211 if (!cond.key) {
212 return false;
213 }
214
215 const val = valueForDottedKey(cond.key, ev);
216 if (!val || typeof val != 'string') {
217 return false;
218 }
219
220 if (cond.value) {
221 return cond.value === val;
222 }
223
224 let regex;
225
226 if (cond.key == 'content.body') {
227 regex = createCachedRegex('(^|\\W)', cond.pattern, '(\\W|$)');
228 } else {
229 regex = createCachedRegex('^', cond.pattern, '$');
230 }
231
232 return !!val.match(regex);
233 };
234
235 const createCachedRegex = function(prefix, glob, suffix) {
236 if (cachedGlobToRegex[glob]) {
237 return cachedGlobToRegex[glob];
238 }
239 cachedGlobToRegex[glob] = new RegExp(
240 prefix + globToRegexp(glob) + suffix,
241 'i', // Case insensitive
242 );
243 return cachedGlobToRegex[glob];
244 };
245
246 const valueForDottedKey = function(key, ev) {
247 const parts = key.split('.');
248 let val;
249
250 // special-case the first component to deal with encrypted messages
251 const firstPart = parts[0];
252 if (firstPart == 'content') {
253 val = ev.getContent();
254 parts.shift();
255 } else if (firstPart == 'type') {
256 val = ev.getType();
257 parts.shift();
258 } else {
259 // use the raw event for any other fields
260 val = ev.event;
261 }
262
263 while (parts.length > 0) {
264 const thispart = parts.shift();
265 if (!val[thispart]) {
266 return null;
267 }
268 val = val[thispart];
269 }
270 return val;
271 };
272
273 const matchingRuleForEventWithRulesets = function(ev, rulesets) {
274 if (!rulesets || !rulesets.device) {
275 return null;
276 }
277 if (ev.getSender() == client.credentials.userId) {
278 return null;
279 }
280
281 const allDevNames = Object.keys(rulesets.device);
282 for (let i = 0; i < allDevNames.length; ++i) {
283 const devname = allDevNames[i];
284 const devrules = rulesets.device[devname];
285
286 const matchingRule = matchingRuleFromKindSet(devrules, devname);
287 if (matchingRule) {
288 return matchingRule;
289 }
290 }
291 return matchingRuleFromKindSet(ev, rulesets.global);
292 };
293
294 const pushActionsForEventAndRulesets = function(ev, rulesets) {
295 const rule = matchingRuleForEventWithRulesets(ev, rulesets);
296 if (!rule) {
297 return {};
298 }
299
300 const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
301
302 // Some actions are implicit in some situations: we add those here
303 if (actionObj.tweaks.highlight === undefined) {
304 // if it isn't specified, highlight if it's a content
305 // rule but otherwise not
306 actionObj.tweaks.highlight = (rule.kind == 'content');
307 }
308
309 return actionObj;
310 };
311
312 this.ruleMatchesEvent = function(rule, ev) {
313 let ret = true;
314 for (let i = 0; i < rule.conditions.length; ++i) {
315 const cond = rule.conditions[i];
316 ret &= eventFulfillsCondition(cond, ev);
317 }
318 //console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
319 return ret;
320 };
321
322
323 /**
324 * Get the user's push actions for the given event
325 *
326 * @param {module:models/event.MatrixEvent} ev
327 *
328 * @return {PushAction}
329 */
330 this.actionsForEvent = function(ev) {
331 return pushActionsForEventAndRulesets(ev, client.pushRules);
332 };
333
334 /**
335 * Get one of the users push rules by its ID
336 *
337 * @param {string} ruleId The ID of the rule to search for
338 * @return {object} The push rule, or null if no such rule was found
339 */
340 this.getPushRuleById = function(ruleId) {
341 for (const scope of ['device', 'global']) {
342 if (client.pushRules[scope] === undefined) continue;
343
344 for (const kind of RULEKINDS_IN_ORDER) {
345 if (client.pushRules[scope][kind] === undefined) continue;
346
347 for (const rule of client.pushRules[scope][kind]) {
348 if (rule.rule_id === ruleId) return rule;
349 }
350 }
351 }
352 return null;
353 };
354}
355
356/**
357 * Convert a list of actions into a object with the actions as keys and their values
358 * eg. [ 'notify', { set_tweak: 'sound', value: 'default' } ]
359 * becomes { notify: true, tweaks: { sound: 'default' } }
360 * @param {array} actionlist The actions list
361 *
362 * @return {object} A object with key 'notify' (true or false) and an object of actions
363 */
364PushProcessor.actionListToActionsObject = function(actionlist) {
365 const actionobj = { 'notify': false, 'tweaks': {} };
366 for (let i = 0; i < actionlist.length; ++i) {
367 const action = actionlist[i];
368 if (action === 'notify') {
369 actionobj.notify = true;
370 } else if (typeof action === 'object') {
371 if (action.value === undefined) {
372 action.value = true;
373 }
374 actionobj.tweaks[action.set_tweak] = action.value;
375 }
376 }
377 return actionobj;
378};
379
380/**
381 * @typedef {Object} PushAction
382 * @type {Object}
383 * @property {boolean} notify Whether this event should notify the user or not.
384 * @property {Object} tweaks How this event should be notified.
385 * @property {boolean} tweaks.highlight Whether this event should be highlighted
386 * on the UI.
387 * @property {boolean} tweaks.sound Whether this notification should produce a
388 * noise.
389 */
390
391/** The PushProcessor class. */
392module.exports = PushProcessor;
393