@@ -0,0 +1 @@ | |||
Implement [MSC3664](https://github.com/matrix-org/matrix-doc/pull/3664). Contributed by Nico. |
@@ -25,6 +25,7 @@ use crate::push::Action; | |||
use crate::push::Condition; | |||
use crate::push::EventMatchCondition; | |||
use crate::push::PushRule; | |||
use crate::push::RelatedEventMatchCondition; | |||
use crate::push::SetTweak; | |||
use crate::push::TweakValue; | |||
@@ -114,6 +115,22 @@ pub const BASE_APPEND_OVERRIDE_RULES: &[PushRule] = &[ | |||
default: true, | |||
default_enabled: true, | |||
}, | |||
PushRule { | |||
rule_id: Cow::Borrowed("global/override/.im.nheko.msc3664.reply"), | |||
priority_class: 5, | |||
conditions: Cow::Borrowed(&[Condition::Known(KnownCondition::RelatedEventMatch( | |||
RelatedEventMatchCondition { | |||
key: Some(Cow::Borrowed("sender")), | |||
pattern: None, | |||
pattern_type: Some(Cow::Borrowed("user_id")), | |||
rel_type: Cow::Borrowed("m.in_reply_to"), | |||
include_fallbacks: None, | |||
}, | |||
))]), | |||
actions: Cow::Borrowed(&[Action::Notify, HIGHLIGHT_ACTION, SOUND_ACTION]), | |||
default: true, | |||
default_enabled: true, | |||
}, | |||
PushRule { | |||
rule_id: Cow::Borrowed("global/override/.m.rule.contains_display_name"), | |||
priority_class: 5, | |||
@@ -23,6 +23,7 @@ use regex::Regex; | |||
use super::{ | |||
utils::{get_glob_matcher, get_localpart_from_id, GlobMatchType}, | |||
Action, Condition, EventMatchCondition, FilteredPushRules, KnownCondition, | |||
RelatedEventMatchCondition, | |||
}; | |||
lazy_static! { | |||
@@ -49,6 +50,13 @@ pub struct PushRuleEvaluator { | |||
/// The power level of the sender of the event, or None if event is an | |||
/// outlier. | |||
sender_power_level: Option<i64>, | |||
/// The related events, indexed by relation type. Flattened in the same manner as | |||
/// `flattened_keys`. | |||
related_events_flattened: BTreeMap<String, BTreeMap<String, String>>, | |||
/// If msc3664, push rules for related events, is enabled. | |||
related_event_match_enabled: bool, | |||
} | |||
#[pymethods] | |||
@@ -60,6 +68,8 @@ impl PushRuleEvaluator { | |||
room_member_count: u64, | |||
sender_power_level: Option<i64>, | |||
notification_power_levels: BTreeMap<String, i64>, | |||
related_events_flattened: BTreeMap<String, BTreeMap<String, String>>, | |||
related_event_match_enabled: bool, | |||
) -> Result<Self, Error> { | |||
let body = flattened_keys | |||
.get("content.body") | |||
@@ -72,6 +82,8 @@ impl PushRuleEvaluator { | |||
room_member_count, | |||
notification_power_levels, | |||
sender_power_level, | |||
related_events_flattened, | |||
related_event_match_enabled, | |||
}) | |||
} | |||
@@ -156,6 +168,9 @@ impl PushRuleEvaluator { | |||
KnownCondition::EventMatch(event_match) => { | |||
self.match_event_match(event_match, user_id)? | |||
} | |||
KnownCondition::RelatedEventMatch(event_match) => { | |||
self.match_related_event_match(event_match, user_id)? | |||
} | |||
KnownCondition::ContainsDisplayName => { | |||
if let Some(dn) = display_name { | |||
if !dn.is_empty() { | |||
@@ -239,6 +254,79 @@ impl PushRuleEvaluator { | |||
compiled_pattern.is_match(haystack) | |||
} | |||
/// Evaluates a `related_event_match` condition. (MSC3664) | |||
fn match_related_event_match( | |||
&self, | |||
event_match: &RelatedEventMatchCondition, | |||
user_id: Option<&str>, | |||
) -> Result<bool, Error> { | |||
// First check if related event matching is enabled... | |||
if !self.related_event_match_enabled { | |||
return Ok(false); | |||
} | |||
// get the related event, fail if there is none. | |||
let event = if let Some(event) = self.related_events_flattened.get(&*event_match.rel_type) { | |||
event | |||
} else { | |||
return Ok(false); | |||
}; | |||
// If we are not matching fallbacks, don't match if our special key indicating this is a | |||
// fallback relation is not present. | |||
if !event_match.include_fallbacks.unwrap_or(false) | |||
&& event.contains_key("im.vector.is_falling_back") | |||
{ | |||
return Ok(false); | |||
} | |||
// if we have no key, accept the event as matching, if it existed without matching any | |||
// fields. | |||
let key = if let Some(key) = &event_match.key { | |||
key | |||
} else { | |||
return Ok(true); | |||
}; | |||
let pattern = if let Some(pattern) = &event_match.pattern { | |||
pattern | |||
} else if let Some(pattern_type) = &event_match.pattern_type { | |||
// The `pattern_type` can either be "user_id" or "user_localpart", | |||
// either way if we don't have a `user_id` then the condition can't | |||
// match. | |||
let user_id = if let Some(user_id) = user_id { | |||
user_id | |||
} else { | |||
return Ok(false); | |||
}; | |||
match &**pattern_type { | |||
"user_id" => user_id, | |||
"user_localpart" => get_localpart_from_id(user_id)?, | |||
_ => return Ok(false), | |||
} | |||
} else { | |||
return Ok(false); | |||
}; | |||
let haystack = if let Some(haystack) = event.get(&**key) { | |||
haystack | |||
} else { | |||
return Ok(false); | |||
}; | |||
// For the content.body we match against "words", but for everything | |||
// else we match against the entire value. | |||
let match_type = if key == "content.body" { | |||
GlobMatchType::Word | |||
} else { | |||
GlobMatchType::Whole | |||
}; | |||
let mut compiled_pattern = get_glob_matcher(pattern, match_type)?; | |||
compiled_pattern.is_match(haystack) | |||
} | |||
/// Match the member count against an 'is' condition | |||
/// The `is` condition can be things like '>2', '==3' or even just '4'. | |||
fn match_member_count(&self, is: &str) -> Result<bool, Error> { | |||
@@ -267,8 +355,15 @@ impl PushRuleEvaluator { | |||
fn push_rule_evaluator() { | |||
let mut flattened_keys = BTreeMap::new(); | |||
flattened_keys.insert("content.body".to_string(), "foo bar bob hello".to_string()); | |||
let evaluator = | |||
PushRuleEvaluator::py_new(flattened_keys, 10, Some(0), BTreeMap::new()).unwrap(); | |||
let evaluator = PushRuleEvaluator::py_new( | |||
flattened_keys, | |||
10, | |||
Some(0), | |||
BTreeMap::new(), | |||
BTreeMap::new(), | |||
true, | |||
) | |||
.unwrap(); | |||
let result = evaluator.run(&FilteredPushRules::default(), None, Some("bob")); | |||
assert_eq!(result.len(), 3); | |||
@@ -267,6 +267,8 @@ pub enum Condition { | |||
#[serde(tag = "kind")] | |||
pub enum KnownCondition { | |||
EventMatch(EventMatchCondition), | |||
#[serde(rename = "im.nheko.msc3664.related_event_match")] | |||
RelatedEventMatch(RelatedEventMatchCondition), | |||
ContainsDisplayName, | |||
RoomMemberCount { | |||
#[serde(skip_serializing_if = "Option::is_none")] | |||
@@ -299,6 +301,20 @@ pub struct EventMatchCondition { | |||
pub pattern_type: Option<Cow<'static, str>>, | |||
} | |||
/// The body of a [`Condition::RelatedEventMatch`] | |||
#[derive(Serialize, Deserialize, Debug, Clone)] | |||
pub struct RelatedEventMatchCondition { | |||
#[serde(skip_serializing_if = "Option::is_none")] | |||
pub key: Option<Cow<'static, str>>, | |||
#[serde(skip_serializing_if = "Option::is_none")] | |||
pub pattern: Option<Cow<'static, str>>, | |||
#[serde(skip_serializing_if = "Option::is_none")] | |||
pub pattern_type: Option<Cow<'static, str>>, | |||
pub rel_type: Cow<'static, str>, | |||
#[serde(skip_serializing_if = "Option::is_none")] | |||
pub include_fallbacks: Option<bool>, | |||
} | |||
/// The collection of push rules for a user. | |||
#[derive(Debug, Clone, Default)] | |||
#[pyclass(frozen)] | |||
@@ -391,15 +407,21 @@ impl PushRules { | |||
pub struct FilteredPushRules { | |||
push_rules: PushRules, | |||
enabled_map: BTreeMap<String, bool>, | |||
msc3664_enabled: bool, | |||
} | |||
#[pymethods] | |||
impl FilteredPushRules { | |||
#[new] | |||
pub fn py_new(push_rules: PushRules, enabled_map: BTreeMap<String, bool>) -> Self { | |||
pub fn py_new( | |||
push_rules: PushRules, | |||
enabled_map: BTreeMap<String, bool>, | |||
msc3664_enabled: bool, | |||
) -> Self { | |||
Self { | |||
push_rules, | |||
enabled_map, | |||
msc3664_enabled, | |||
} | |||
} | |||
@@ -414,13 +436,25 @@ impl FilteredPushRules { | |||
/// Iterates over all the rules and their enabled state, including base | |||
/// rules, in the order they should be executed in. | |||
fn iter(&self) -> impl Iterator<Item = (&PushRule, bool)> { | |||
self.push_rules.iter().map(|r| { | |||
let enabled = *self | |||
.enabled_map | |||
.get(&*r.rule_id) | |||
.unwrap_or(&r.default_enabled); | |||
(r, enabled) | |||
}) | |||
self.push_rules | |||
.iter() | |||
.filter(|rule| { | |||
// Ignore disabled experimental push rules | |||
if !self.msc3664_enabled | |||
&& rule.rule_id == "global/override/.im.nheko.msc3664.reply" | |||
{ | |||
return false; | |||
} | |||
true | |||
}) | |||
.map(|r| { | |||
let enabled = *self | |||
.enabled_map | |||
.get(&*r.rule_id) | |||
.unwrap_or(&r.default_enabled); | |||
(r, enabled) | |||
}) | |||
} | |||
} | |||
@@ -446,6 +480,17 @@ fn test_deserialize_condition() { | |||
let _: Condition = serde_json::from_str(json).unwrap(); | |||
} | |||
#[test] | |||
fn test_deserialize_unstable_msc3664_condition() { | |||
let json = r#"{"kind":"im.nheko.msc3664.related_event_match","key":"content.body","pattern":"coffee","rel_type":"m.in_reply_to"}"#; | |||
let condition: Condition = serde_json::from_str(json).unwrap(); | |||
assert!(matches!( | |||
condition, | |||
Condition::Known(KnownCondition::RelatedEventMatch(_)) | |||
)); | |||
} | |||
#[test] | |||
fn test_deserialize_custom_condition() { | |||
let json = r#"{"kind":"custom_tag"}"#; | |||
@@ -25,7 +25,9 @@ class PushRules: | |||
def rules(self) -> Collection[PushRule]: ... | |||
class FilteredPushRules: | |||
def __init__(self, push_rules: PushRules, enabled_map: Dict[str, bool]): ... | |||
def __init__( | |||
self, push_rules: PushRules, enabled_map: Dict[str, bool], msc3664_enabled: bool | |||
): ... | |||
def rules(self) -> Collection[Tuple[PushRule, bool]]: ... | |||
def get_base_rule_ids() -> Collection[str]: ... | |||
@@ -37,6 +39,8 @@ class PushRuleEvaluator: | |||
room_member_count: int, | |||
sender_power_level: Optional[int], | |||
notification_power_levels: Mapping[str, int], | |||
related_events_flattened: Mapping[str, Mapping[str, str]], | |||
related_event_match_enabled: bool, | |||
): ... | |||
def run( | |||
self, | |||
@@ -98,6 +98,9 @@ class ExperimentalConfig(Config): | |||
# MSC3773: Thread notifications | |||
self.msc3773_enabled: bool = experimental.get("msc3773_enabled", False) | |||
# MSC3664: Pushrules to match on related events | |||
self.msc3664_enabled: bool = experimental.get("msc3664_enabled", False) | |||
# MSC3848: Introduce errcodes for specific event sending failures | |||
self.msc3848_enabled: bool = experimental.get("msc3848_enabled", False) | |||
@@ -45,7 +45,6 @@ if TYPE_CHECKING: | |||
logger = logging.getLogger(__name__) | |||
push_rules_invalidation_counter = Counter( | |||
"synapse_push_bulk_push_rule_evaluator_push_rules_invalidation_counter", "" | |||
) | |||
@@ -107,6 +106,8 @@ class BulkPushRuleEvaluator: | |||
self.clock = hs.get_clock() | |||
self._event_auth_handler = hs.get_event_auth_handler() | |||
self._related_event_match_enabled = self.hs.config.experimental.msc3664_enabled | |||
self.room_push_rule_cache_metrics = register_cache( | |||
"cache", | |||
"room_push_rule_cache", | |||
@@ -218,6 +219,48 @@ class BulkPushRuleEvaluator: | |||
return pl_event.content if pl_event else {}, sender_level | |||
async def _related_events(self, event: EventBase) -> Dict[str, Dict[str, str]]: | |||
"""Fetches the related events for 'event'. Sets the im.vector.is_falling_back key if the event is from a fallback relation | |||
Returns: | |||
Mapping of relation type to flattened events. | |||
""" | |||
related_events: Dict[str, Dict[str, str]] = {} | |||
if self._related_event_match_enabled: | |||
related_event_id = event.content.get("m.relates_to", {}).get("event_id") | |||
relation_type = event.content.get("m.relates_to", {}).get("rel_type") | |||
if related_event_id is not None and relation_type is not None: | |||
related_event = await self.store.get_event( | |||
related_event_id, allow_none=True | |||
) | |||
if related_event is not None: | |||
related_events[relation_type] = _flatten_dict(related_event) | |||
reply_event_id = ( | |||
event.content.get("m.relates_to", {}) | |||
.get("m.in_reply_to", {}) | |||
.get("event_id") | |||
) | |||
# convert replies to pseudo relations | |||
if reply_event_id is not None: | |||
related_event = await self.store.get_event( | |||
reply_event_id, allow_none=True | |||
) | |||
if related_event is not None: | |||
related_events["m.in_reply_to"] = _flatten_dict(related_event) | |||
# indicate that this is from a fallback relation. | |||
if relation_type == "m.thread" and event.content.get( | |||
"m.relates_to", {} | |||
).get("is_falling_back", False): | |||
related_events["m.in_reply_to"][ | |||
"im.vector.is_falling_back" | |||
] = "" | |||
return related_events | |||
async def action_for_events_by_user( | |||
self, events_and_context: List[Tuple[EventBase, EventContext]] | |||
) -> None: | |||
@@ -286,6 +329,8 @@ class BulkPushRuleEvaluator: | |||
# the parent is part of a thread. | |||
thread_id = await self.store.get_thread_id(relation.parent_id) | |||
related_events = await self._related_events(event) | |||
# It's possible that old room versions have non-integer power levels (floats or | |||
# strings). Workaround this by explicitly converting to int. | |||
notification_levels = power_levels.get("notifications", {}) | |||
@@ -298,6 +343,8 @@ class BulkPushRuleEvaluator: | |||
room_member_count, | |||
sender_power_level, | |||
notification_levels, | |||
related_events, | |||
self._related_event_match_enabled, | |||
) | |||
users = rules_by_user.keys() | |||
@@ -77,6 +77,11 @@ class CapabilitiesRestServlet(RestServlet): | |||
"enabled": True, | |||
} | |||
if self.config.experimental.msc3664_enabled: | |||
response["capabilities"]["im.nheko.msc3664.related_event_match"] = { | |||
"enabled": self.config.experimental.msc3664_enabled, | |||
} | |||
return HTTPStatus.OK, response | |||
@@ -29,6 +29,7 @@ from typing import ( | |||
) | |||
from synapse.api.errors import StoreError | |||
from synapse.config.homeserver import ExperimentalConfig | |||
from synapse.replication.slave.storage._slaved_id_tracker import SlavedIdTracker | |||
from synapse.storage._base import SQLBaseStore | |||
from synapse.storage.database import ( | |||
@@ -62,7 +63,9 @@ logger = logging.getLogger(__name__) | |||
def _load_rules( | |||
rawrules: List[JsonDict], enabled_map: Dict[str, bool] | |||
rawrules: List[JsonDict], | |||
enabled_map: Dict[str, bool], | |||
experimental_config: ExperimentalConfig, | |||
) -> FilteredPushRules: | |||
"""Take the DB rows returned from the DB and convert them into a full | |||
`FilteredPushRules` object. | |||
@@ -80,7 +83,9 @@ def _load_rules( | |||
push_rules = PushRules(ruleslist) | |||
filtered_rules = FilteredPushRules(push_rules, enabled_map) | |||
filtered_rules = FilteredPushRules( | |||
push_rules, enabled_map, msc3664_enabled=experimental_config.msc3664_enabled | |||
) | |||
return filtered_rules | |||
@@ -160,7 +165,7 @@ class PushRulesWorkerStore( | |||
enabled_map = await self.get_push_rules_enabled_for_user(user_id) | |||
return _load_rules(rows, enabled_map) | |||
return _load_rules(rows, enabled_map, self.hs.config.experimental) | |||
async def get_push_rules_enabled_for_user(self, user_id: str) -> Dict[str, bool]: | |||
results = await self.db_pool.simple_select_list( | |||
@@ -219,7 +224,9 @@ class PushRulesWorkerStore( | |||
results: Dict[str, FilteredPushRules] = {} | |||
for user_id, rules in raw_rules.items(): | |||
results[user_id] = _load_rules(rules, enabled_map_by_user.get(user_id, {})) | |||
results[user_id] = _load_rules( | |||
rules, enabled_map_by_user.get(user_id, {}), self.hs.config.experimental | |||
) | |||
return results | |||
@@ -38,7 +38,9 @@ from tests.test_utils.event_injection import create_event, inject_member_event | |||
class PushRuleEvaluatorTestCase(unittest.TestCase): | |||
def _get_evaluator(self, content: JsonDict) -> PushRuleEvaluator: | |||
def _get_evaluator( | |||
self, content: JsonDict, related_events=None | |||
) -> PushRuleEvaluator: | |||
event = FrozenEvent( | |||
{ | |||
"event_id": "$event_id", | |||
@@ -58,6 +60,8 @@ class PushRuleEvaluatorTestCase(unittest.TestCase): | |||
room_member_count, | |||
sender_power_level, | |||
power_levels.get("notifications", {}), | |||
{} if related_events is None else related_events, | |||
True, | |||
) | |||
def test_display_name(self) -> None: | |||
@@ -292,6 +296,215 @@ class PushRuleEvaluatorTestCase(unittest.TestCase): | |||
{"sound": "default", "highlight": True}, | |||
) | |||
def test_related_event_match(self): | |||
evaluator = self._get_evaluator( | |||
{ | |||
"m.relates_to": { | |||
"event_id": "$parent_event_id", | |||
"key": "😀", | |||
"rel_type": "m.annotation", | |||
"m.in_reply_to": { | |||
"event_id": "$parent_event_id", | |||
}, | |||
} | |||
}, | |||
{ | |||
"m.in_reply_to": { | |||
"event_id": "$parent_event_id", | |||
"type": "m.room.message", | |||
"sender": "@other_user:test", | |||
"room_id": "!room:test", | |||
"content.msgtype": "m.text", | |||
"content.body": "Original message", | |||
}, | |||
"m.annotation": { | |||
"event_id": "$parent_event_id", | |||
"type": "m.room.message", | |||
"sender": "@other_user:test", | |||
"room_id": "!room:test", | |||
"content.msgtype": "m.text", | |||
"content.body": "Original message", | |||
}, | |||
}, | |||
) | |||
self.assertTrue( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@other_user:test", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@user:test", | |||
}, | |||
"@other_user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertTrue( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.annotation", | |||
"pattern": "@other_user:test", | |||
}, | |||
"@other_user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertTrue( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"rel_type": "m.in_reply_to", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"rel_type": "m.replace", | |||
}, | |||
"@other_user:test", | |||
"display_name", | |||
) | |||
) | |||
def test_related_event_match_with_fallback(self): | |||
evaluator = self._get_evaluator( | |||
{ | |||
"m.relates_to": { | |||
"event_id": "$parent_event_id", | |||
"key": "😀", | |||
"rel_type": "m.thread", | |||
"is_falling_back": True, | |||
"m.in_reply_to": { | |||
"event_id": "$parent_event_id", | |||
}, | |||
} | |||
}, | |||
{ | |||
"m.in_reply_to": { | |||
"event_id": "$parent_event_id", | |||
"type": "m.room.message", | |||
"sender": "@other_user:test", | |||
"room_id": "!room:test", | |||
"content.msgtype": "m.text", | |||
"content.body": "Original message", | |||
"im.vector.is_falling_back": "", | |||
}, | |||
"m.thread": { | |||
"event_id": "$parent_event_id", | |||
"type": "m.room.message", | |||
"sender": "@other_user:test", | |||
"room_id": "!room:test", | |||
"content.msgtype": "m.text", | |||
"content.body": "Original message", | |||
}, | |||
}, | |||
) | |||
self.assertTrue( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@other_user:test", | |||
"include_fallbacks": True, | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@other_user:test", | |||
"include_fallbacks": False, | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@other_user:test", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
def test_related_event_match_no_related_event(self): | |||
evaluator = self._get_evaluator( | |||
{"msgtype": "m.text", "body": "Message without related event"} | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
"pattern": "@other_user:test", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"key": "sender", | |||
"rel_type": "m.in_reply_to", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
self.assertFalse( | |||
evaluator.matches( | |||
{ | |||
"kind": "im.nheko.msc3664.related_event_match", | |||
"rel_type": "m.in_reply_to", | |||
}, | |||
"@user:test", | |||
"display_name", | |||
) | |||
) | |||
class TestBulkPushRuleEvaluator(unittest.HomeserverTestCase): | |||
"""Tests for the bulk push rule evaluator""" | |||