Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the current spec for event match conditions #6457

Merged
merged 10 commits into from
Feb 6, 2023
1 change: 1 addition & 0 deletions changelog.d/6457.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix that replies to @roomba would be highlighted as a room ping. Contributed by Nico.
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ package org.matrix.android.sdk.api.session.pushrules

import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.di.MoshiProvider
import org.matrix.android.sdk.internal.util.caseInsensitiveFind
import org.matrix.android.sdk.internal.util.hasSpecialGlobChar
import org.matrix.android.sdk.internal.util.simpleGlobToRegExp
import timber.log.Timber

Expand All @@ -31,40 +29,36 @@ class EventMatchCondition(
* The glob-style pattern to match against. Patterns with no special glob characters should
* be treated as having asterisks prepended and appended when testing the condition.
*/
val pattern: String,
/**
* true to match only words. In this case pattern will not be considered as a glob
*/
val wordsOnly: Boolean
val pattern: String
) : Condition {

override fun isSatisfied(event: Event, conditionResolver: ConditionResolver): Boolean {
return conditionResolver.resolveEventMatchCondition(event, this)
}

override fun technicalDescription() = "'$key' matches '$pattern', words only '$wordsOnly'"
override fun technicalDescription() = "'$key' matches '$pattern'"

fun isSatisfied(event: Event): Boolean {
// TODO encrypted events?
val rawJson = MoshiProvider.providesMoshi().adapter(Event::class.java).toJsonValue(event) as? Map<*, *>
?: return false
val value = extractField(rawJson, key) ?: return false

// Patterns with no special glob characters should be treated as having asterisks prepended
// and appended when testing the condition.
// The match is performed case-insensitively, and must match the entire value of
// the event field given by `key` (though see below regarding `content.body`). The
// exact meaning of "case insensitive" is defined by the implementation of the
// homeserver.
//
// As a special case, if `key` is `content.body`, then `pattern` must instead
// match any substring of the value of the property which starts and ends at a
// word boundary.
return try {
if (wordsOnly) {
value.caseInsensitiveFind(pattern)
} else {
val modPattern = if (pattern.hasSpecialGlobChar()) {
// Regex.containsMatchIn() is way faster without leading and trailing
// stars, that don't make any difference for the evaluation result
pattern.removePrefix("*").removeSuffix("*").simpleGlobToRegExp()
} else {
pattern.simpleGlobToRegExp()
}
val regex = Regex(modPattern, RegexOption.DOT_MATCHES_ALL)
if (key == "content.body") {
val regex = Regex("(\\W|^)" + pattern.simpleGlobToRegExp() + "(\\W|$)", setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How different from caseInsensitiveFind is this? can it reuse it too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

caseInsesitiveFind doesn't evaluate globs (since it escapes the string, from what I can tell), otherwise it is the same.

regex.containsMatchIn(value)
} else {
val regex = Regex(pattern.simpleGlobToRegExp(), setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't you keep the existing optimisation to remove * prefix/suffix? as per #5008

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in theory we could, but in this branch we are not matching the content.body field, but other fields of the event, which usually don't contain a long text. If someone explicitly writes a rule to match *a* on the sender field, we could in theory match 255 characters. It would also need to be extended to not do a containsMatchIn, since if there was no * at the start of the string, we need to match the start of the string.

The important part here is in the following line, which does matches instead of containsMatchIn. This would need to change to:

if (pattern.startsWith("*") && pattern.endsWith("*")) {
    val regex = Regex(pattern.removePrefix("*").removeSuffix("*").simpleGlobToRegExp(), RegexOption.DOT_MATCHES_ALL)
    regex.containsMatchIn(value)
} else if (pattern.startsWith("*")) {
  // match if value endsWith pattern
} else if (pattern.endsWith("*")) {
  // match if value startsWith pattern
} else {
  // match if complete value matches pattern
}

The last 3 branches could probably be collapsed just into one, but considering that fields apart from content.body are usually short, patterns usually never start AND end with a * and this would mostly affect the user themselves if they find a way to write such a rule that actually is slow to evaluate, I would say it is not worth it in this case.

Now on the other hand in the half that matches on content.body, we need to check for word boundaries at the start and end for most matches. Now I don't think this matters if we have a glob on both sides, so I think keeping the optimization can actually be useful there. But for context, the old optimization was because Element Android did *@room* to match the body, which this PR explicitly removes, since that matches the wrong stuff. So this only optimizes some badly written user rules and tbh probably should be implemented upstream in the regex engine... Anyway, I'll add the optimization back to the content.body branch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the optimization back in the body branch, not in the other branch since I don't really see a need for it, but if you think I should, I can do that as well.

regex.matches(value)
}
} catch (e: Throwable) {
// e.g PatternSyntaxException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import org.matrix.android.sdk.api.session.pushrules.ContainsDisplayNameCondition
import org.matrix.android.sdk.api.session.pushrules.EventMatchCondition
import org.matrix.android.sdk.api.session.pushrules.Kind
import org.matrix.android.sdk.api.session.pushrules.RoomMemberCountCondition
import org.matrix.android.sdk.api.session.pushrules.RuleIds
import org.matrix.android.sdk.api.session.pushrules.SenderNotificationPermissionCondition
import timber.log.Timber

Expand Down Expand Up @@ -59,11 +58,11 @@ data class PushCondition(
val iz: String? = null
) {

fun asExecutableCondition(rule: PushRule): Condition? {
fun asExecutableCondition(): Condition? {
return when (Kind.fromString(kind)) {
Kind.EventMatch -> {
if (key != null && pattern != null) {
EventMatchCondition(key, pattern, rule.ruleId == RuleIds.RULE_ID_CONTAIN_USER_NAME)
EventMatchCondition(key, pattern)
} else {
Timber.e("Malformed Event match condition")
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class PushRuleFinder @Inject constructor(
return rules.firstOrNull { rule ->
// All conditions must hold true for an event in order to apply the action for the event.
rule.enabled && rule.conditions?.all {
it.asExecutableCondition(rule)?.isSatisfied(event, conditionResolver) ?: false
it.asExecutableCondition()?.isSatisfied(event, conditionResolver) ?: false
} ?: false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class PushRulesConditionTest : MatrixTest {

@Test
fun test_eventmatch_type_condition() {
val condition = EventMatchCondition("type", "m.room.message", false)
val condition = EventMatchCondition("type", "m.room.message")

val simpleTextEvent = createSimpleTextEvent("Yo wtf?")

Expand All @@ -67,12 +67,12 @@ class PushRulesConditionTest : MatrixTest {
)

assert(condition.isSatisfied(simpleTextEvent))
assert(!condition.isSatisfied(simpleRoomMemberEvent))
assertFalse(condition.isSatisfied(simpleRoomMemberEvent))
}

@Test
fun test_eventmatch_path_condition() {
val condition = EventMatchCondition("content.msgtype", "m.text", false)
val condition = EventMatchCondition("content.msgtype", "m.text")

val simpleTextEvent = createSimpleTextEvent("Yo wtf?")

Expand All @@ -89,28 +89,29 @@ class PushRulesConditionTest : MatrixTest {
).toContent(),
originServerTs = 0
).apply {
assert(EventMatchCondition("content.membership", "invite", false).isSatisfied(this))
assert(EventMatchCondition("content.membership", "invite").isSatisfied(this))
}
}

@Test
fun test_eventmatch_cake_condition() {
val condition = EventMatchCondition("content.body", "cake", false)
val condition = EventMatchCondition("content.body", "cake")

assert(condition.isSatisfied(createSimpleTextEvent("How was the cake?")))
assert(condition.isSatisfied(createSimpleTextEvent("Howwasthecake?")))
assertFalse(condition.isSatisfied(createSimpleTextEvent("Howwasthecake?")))
}

@Test
fun test_eventmatch_cakelie_condition() {
val condition = EventMatchCondition("content.body", "cake*lie", false)
val condition = EventMatchCondition("content.body", "cake*lie")

assert(condition.isSatisfied(createSimpleTextEvent("How was the cakeisalie?")))
assertFalse(condition.isSatisfied(createSimpleTextEvent("How was the notcakeisalie?")))
}

@Test
fun test_eventmatch_words_only_condition() {
val condition = EventMatchCondition("content.body", "ben", true)
val condition = EventMatchCondition("content.body", "ben")

assertFalse(condition.isSatisfied(createSimpleTextEvent("benoit")))
assertFalse(condition.isSatisfied(createSimpleTextEvent("Hello benoit")))
Expand All @@ -124,9 +125,24 @@ class PushRulesConditionTest : MatrixTest {
assert(condition.isSatisfied(createSimpleTextEvent("BEN")))
}

@Test
fun test_eventmatch_at_room_condition() {
val condition = EventMatchCondition("content.body", "@room")

assertFalse(condition.isSatisfied(createSimpleTextEvent("@roomba")))
assertFalse(condition.isSatisfied(createSimpleTextEvent("room benoit")))
assertFalse(condition.isSatisfied(createSimpleTextEvent("abc@roomba")))

assert(condition.isSatisfied(createSimpleTextEvent("@room")))
assert(condition.isSatisfied(createSimpleTextEvent("@room, ben")))
assert(condition.isSatisfied(createSimpleTextEvent("@ROOM")))
assert(condition.isSatisfied(createSimpleTextEvent("Use:@room")))
assert(condition.isSatisfied(createSimpleTextEvent("Don't ping @room!")))
}

@Test
fun test_notice_condition() {
val conditionEqual = EventMatchCondition("content.msgtype", "m.notice", false)
val conditionEqual = EventMatchCondition("content.msgtype", "m.notice")

Event(
type = "m.room.message",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ abstract class PushRuleItem : VectorEpoxyModel<PushRuleItem.Holder>(R.layout.ite
pushRule.conditions?.forEachIndexed { i, condition ->
if (i > 0) description.append("\n")
description.append(
condition.asExecutableCondition(pushRule)?.technicalDescription()
condition.asExecutableCondition()?.technicalDescription()
?: "UNSUPPORTED"
)
}
Expand Down