diff --git a/pushrules/condition.go b/pushrules/condition.go index f809f8e..435178f 100644 --- a/pushrules/condition.go +++ b/pushrules/condition.go @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Tulir Asokan +// Copyright (c) 2023 Tulir Asokan // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this @@ -38,9 +38,11 @@ type PushCondKind string // The allowed push condition kinds as specified in https://spec.matrix.org/v1.2/client-server-api/#conditions-1 const ( - KindEventMatch PushCondKind = "event_match" - KindContainsDisplayName PushCondKind = "contains_display_name" - KindRoomMemberCount PushCondKind = "room_member_count" + KindEventMatch PushCondKind = "event_match" + KindContainsDisplayName PushCondKind = "contains_display_name" + KindRoomMemberCount PushCondKind = "room_member_count" + KindEventPropertyIs PushCondKind = "event_property_is" + KindEventPropertyContains PushCondKind = "event_property_contains" // MSC3664: https://github.com/matrix-org/matrix-spec-proposals/pull/3664 @@ -56,6 +58,8 @@ type PushCondition struct { Key string `json:"key,omitempty"` // The glob-style pattern to match the field against. Only applicable if kind is EventMatch. Pattern string `json:"pattern,omitempty"` + // The exact value to match the field against. Only applicable if kind is EventPropertyIs or EventPropertyContains. + Value any `json:"value,omitempty"` // The condition that needs to be fulfilled for RoomMemberCount-type conditions. // A decimal integer optionally prefixed by ==, <, >, >= or <=. Prefix "==" is assumed if no prefix found. MemberCountCondition string `json:"is,omitempty"` @@ -70,8 +74,8 @@ var MemberCountFilterRegex = regexp.MustCompile("^(==|[<>]=?)?([0-9]+)$") // Match checks if this condition is fulfilled for the given event in the given room. func (cond *PushCondition) Match(room Room, evt *event.Event) bool { switch cond.Kind { - case KindEventMatch: - return cond.matchValue(room, evt) + case KindEventMatch, KindEventPropertyIs, KindEventPropertyContains: + return cond.matchValue(evt) case KindRelatedEventMatch, KindUnstableRelatedEventMatch: return cond.matchRelatedEvent(room, evt) case KindContainsDisplayName: @@ -101,13 +105,13 @@ func splitWithEscaping(s string, separator, escape byte) []string { return tokens } -func hackyNestedGet(data map[string]interface{}, path []string) (interface{}, bool) { +func hackyNestedGet(data map[string]any, path []string) (any, bool) { val, ok := data[path[0]] if len(path) == 1 { // We don't have any more path parts, return the value regardless of whether it exists or not. return val, ok } else if ok { - if mapVal, ok := val.(map[string]interface{}); ok { + if mapVal, ok := val.(map[string]any); ok { val, ok = hackyNestedGet(mapVal, path[1:]) if ok { return val, true @@ -138,37 +142,103 @@ func stringifyForPushCondition(val interface{}) string { } } -func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool { +func (cond *PushCondition) getValue(evt *event.Event) (any, bool) { key, subkey, _ := strings.Cut(cond.Key, ".") - pattern, err := glob.Compile(cond.Pattern) - if err != nil { - return false - } - switch key { case "type": - return pattern.MatchString(evt.Type.String()) + return evt.Type.Type, true case "sender": - return pattern.MatchString(string(evt.Sender)) + return evt.Sender.String(), true case "room_id": - return pattern.MatchString(string(evt.RoomID)) + return evt.RoomID.String(), true case "state_key": if evt.StateKey == nil { - return false + return nil, false } - return pattern.MatchString(*evt.StateKey) + return *evt.StateKey, true case "content": // Split the match key with escaping to implement https://github.com/matrix-org/matrix-spec-proposals/pull/3873 splitKey := splitWithEscaping(subkey, '.', '\\') // Then do a hacky nested get that supports combining parts for the backwards-compat part of MSC3873 - val, ok := hackyNestedGet(evt.Content.Raw, splitKey) - if !ok { - return cond.Pattern == "" + return hackyNestedGet(evt.Content.Raw, splitKey) + default: + return nil, false + } +} + +func numberToInt64(a any) int64 { + switch typed := a.(type) { + case float64: + return int64(typed) + case float32: + return int64(typed) + case int: + return int64(typed) + case int8: + return int64(typed) + case int16: + return int64(typed) + case int32: + return int64(typed) + case int64: + return typed + case uint: + return int64(typed) + case uint8: + return int64(typed) + case uint16: + return int64(typed) + case uint32: + return int64(typed) + case uint64: + return int64(typed) + default: + return 0 + } +} + +func valueEquals(a, b any) bool { + // Convert floats to ints when comparing numbers (the JSON parser generates floats, but Matrix only allows integers) + // Also allow other numeric types in case something generates events manually without json + switch a.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + switch b.(type) { + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return numberToInt64(a) == numberToInt64(b) + } + } + return a == b +} + +func (cond *PushCondition) matchValue(evt *event.Event) bool { + val, ok := cond.getValue(evt) + if !ok { + return false + } + + switch cond.Kind { + case KindEventMatch, KindRelatedEventMatch, KindUnstableRelatedEventMatch: + pattern, err := glob.Compile(cond.Pattern) + if err != nil { + return false } return pattern.MatchString(stringifyForPushCondition(val)) - default: + case KindEventPropertyIs: + return valueEquals(val, cond.Value) + case KindEventPropertyContains: + valArr, ok := val.([]any) + if !ok { + return false + } + for _, item := range valArr { + if valueEquals(item, cond.Value) { + return true + } + } return false + default: + panic(fmt.Errorf("matchValue called for unknown condition kind %s", cond.Kind)) } } @@ -209,7 +279,7 @@ func (cond *PushCondition) matchRelatedEvent(room Room, evt *event.Event) bool { } else if evt = eventfulRoom.GetEvent(relatesTo.EventID); evt == nil { return false } else { - return cond.matchValue(room, evt) + return cond.matchValue(evt) } } diff --git a/pushrules/condition_eventpropertyis_test.go b/pushrules/condition_eventpropertyis_test.go new file mode 100644 index 0000000..1d26a9d --- /dev/null +++ b/pushrules/condition_eventpropertyis_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2023 Tulir Asokan +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package pushrules_test + +import ( + "maunium.net/go/mautrix/event" + + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPushCondition_Match_KindEventPropertyIs_MsgType(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgEmote, + Body: "tests gomuks pushconditions", + }) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_MsgType_Fail(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.msgtype", "m.emote") + + evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "I'm testing gomuks pushconditions", + }) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Integer(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", 5) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": 5}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Integer_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", 0) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "NaN"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_String(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", "foo") + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo"}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_String_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", "foo") + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "foo!"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Null(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", nil) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": nil}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Null_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", nil) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": "a"}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Bool(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", false) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) + assert.True(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", true) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) + assert.True(t, condition.Match(blankTestRoom, evt)) +} + +func TestPushCondition_Match_KindEventPropertyIs_Bool_NoMatch(t *testing.T) { + condition := newEventPropertyIsPushCondition("content.meow", false) + evt := newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": true}) + assert.False(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", true) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": false}) + assert.False(t, condition.Match(blankTestRoom, evt)) + condition = newEventPropertyIsPushCondition("content.meow", false) + evt = newFakeEvent(event.NewEventType("m.room.foo"), map[string]any{"meow": ""}) + assert.False(t, condition.Match(blankTestRoom, evt)) +} diff --git a/pushrules/condition_test.go b/pushrules/condition_test.go index c0925f7..0d3eaf7 100644 --- a/pushrules/condition_test.go +++ b/pushrules/condition_test.go @@ -94,6 +94,22 @@ func newMatchPushCondition(key, pattern string) *pushrules.PushCondition { } } +func newEventPropertyIsPushCondition(key string, value any) *pushrules.PushCondition { + return &pushrules.PushCondition{ + Kind: pushrules.KindEventPropertyIs, + Key: key, + Value: value, + } +} + +func newEventPropertyContainsPushCondition(key string, value any) *pushrules.PushCondition { + return &pushrules.PushCondition{ + Kind: pushrules.KindEventPropertyContains, + Key: key, + Value: value, + } +} + func TestPushCondition_Match_InvalidKind(t *testing.T) { condition := &pushrules.PushCondition{ Kind: pushrules.PushCondKind("invalid"), diff --git a/pushrules/rule.go b/pushrules/rule.go index ad8b33e..0f7436f 100644 --- a/pushrules/rule.go +++ b/pushrules/rule.go @@ -134,6 +134,12 @@ func (rule *PushRule) Match(room Room, evt *event.Event) bool { if rule == nil || !rule.Enabled { return false } + if rule.RuleID == ".m.rule.contains_display_name" || rule.RuleID == ".m.rule.contains_user_name" || rule.RuleID == ".m.rule.roomnotif" { + if _, containsMentions := evt.Content.Raw["m.mentions"]; containsMentions { + // Disable legacy mention push rules when the event contains the new mentions key + return false + } + } switch rule.Type { case OverrideRule, UnderrideRule: return rule.matchConditions(room, evt)