Skip to content

Commit 61b0553

Browse files
committed
Implement MSC3873 and match Synapse behavior in push rules
Synapse's current behavior isn't specced, but MSC3873 adds the current behavior as a backwards compat method and defines some sensible escaping. matrix-org/matrix-spec-proposals#3873
1 parent 2c1a476 commit 61b0553

File tree

2 files changed

+144
-11
lines changed

2 files changed

+144
-11
lines changed

pushrules/condition.go

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2020 Tulir Asokan
1+
// Copyright (c) 2022 Tulir Asokan
22
//
33
// This Source Code Form is subject to the terms of the Mozilla Public
44
// License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -7,6 +7,7 @@
77
package pushrules
88

99
import (
10+
"fmt"
1011
"regexp"
1112
"strconv"
1213
"strings"
@@ -62,14 +63,59 @@ func (cond *PushCondition) Match(room Room, evt *event.Event) bool {
6263
}
6364
}
6465

65-
func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool {
66-
index := strings.IndexRune(cond.Key, '.')
67-
key := cond.Key
68-
subkey := ""
69-
if index > 0 {
70-
subkey = key[index+1:]
71-
key = key[0:index]
66+
func splitWithEscaping(s string, separator, escape byte) []string {
67+
var token []byte
68+
var tokens []string
69+
for i := 0; i < len(s); i++ {
70+
if s[i] == separator {
71+
tokens = append(tokens, string(token))
72+
token = token[:0]
73+
} else if s[i] == escape && i+1 < len(s) {
74+
i++
75+
token = append(token, s[i])
76+
} else {
77+
token = append(token, s[i])
78+
}
79+
}
80+
tokens = append(tokens, string(token))
81+
return tokens
82+
}
83+
84+
func hackyNestedGet(data map[string]interface{}, path []string) (interface{}, bool) {
85+
val, ok := data[path[0]]
86+
if len(path) == 1 {
87+
// We don't have any more path parts, return the value regardless of whether it exists or not.
88+
return val, ok
89+
} else if ok {
90+
if mapVal, ok := val.(map[string]interface{}); ok {
91+
val, ok = hackyNestedGet(mapVal, path[1:])
92+
if ok {
93+
return val, true
94+
}
95+
}
7296
}
97+
// If we don't find the key, try to combine the first two parts.
98+
// e.g. if the key is content.m.relates_to.rel_type, we'll first try data["m"], which will fail,
99+
// then combine m and relates_to to get data["m.relates_to"], which should succeed.
100+
path[1] = path[0] + "." + path[1]
101+
return hackyNestedGet(data, path[1:])
102+
}
103+
104+
func stringifyForPushCondition(val interface{}) string {
105+
switch typedVal := val.(type) {
106+
case string:
107+
return typedVal
108+
case float64:
109+
// Floats aren't allowed in Matrix events, but the JSON parser always stores numbers as floats,
110+
// so just handle that and convert to int
111+
return strconv.FormatInt(int64(typedVal), 10)
112+
default:
113+
return fmt.Sprint(val)
114+
}
115+
}
116+
117+
func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool {
118+
key, subkey, _ := strings.Cut(cond.Key, ".")
73119

74120
pattern, err := glob.Compile(cond.Pattern)
75121
if err != nil {
@@ -89,8 +135,14 @@ func (cond *PushCondition) matchValue(room Room, evt *event.Event) bool {
89135
}
90136
return pattern.MatchString(*evt.StateKey)
91137
case "content":
92-
val, _ := evt.Content.Raw[subkey].(string)
93-
return pattern.MatchString(val)
138+
// Split the match key with escaping to implement https://github.com/matrix-org/matrix-spec-proposals/pull/3873
139+
splitKey := splitWithEscaping(subkey, '.', '\\')
140+
// Then do a hacky nested get that supports combining parts for the backwards-compat part of MSC3873
141+
val, ok := hackyNestedGet(evt.Content.Raw, splitKey)
142+
if !ok {
143+
return cond.Pattern == ""
144+
}
145+
return pattern.MatchString(stringifyForPushCondition(val))
94146
default:
95147
return false
96148
}

pushrules/rule_test.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2020 Tulir Asokan
1+
// Copyright (c) 2022 Tulir Asokan
22
//
33
// This Source Code Form is subject to the terms of the Mozilla Public
44
// License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -31,6 +31,87 @@ func TestPushRule_Match_Conditions(t *testing.T) {
3131
assert.True(t, rule.Match(blankTestRoom, evt))
3232
}
3333

34+
func TestPushRule_Match_Conditions_NestedKey(t *testing.T) {
35+
cond1 := newMatchPushCondition("content.m.relates_to.rel_type", "m.replace")
36+
rule := &pushrules.PushRule{
37+
Type: pushrules.OverrideRule,
38+
Enabled: true,
39+
Conditions: []*pushrules.PushCondition{cond1},
40+
}
41+
42+
evt := newFakeEvent(event.EventMessage, &event.MessageEventContent{
43+
MsgType: event.MsgEmote,
44+
Body: "is testing pushrules",
45+
RelatesTo: &event.RelatesTo{
46+
Type: event.RelReplace,
47+
EventID: "$meow",
48+
},
49+
})
50+
assert.True(t, rule.Match(blankTestRoom, evt))
51+
52+
evt = newFakeEvent(event.EventMessage, &event.MessageEventContent{
53+
MsgType: event.MsgEmote,
54+
Body: "is testing pushrules",
55+
})
56+
assert.False(t, rule.Match(blankTestRoom, evt))
57+
}
58+
59+
func TestPushRule_Match_Conditions_NestedKey_Boolean(t *testing.T) {
60+
cond1 := newMatchPushCondition("content.fi.mau.will_auto_accept", "true")
61+
rule := &pushrules.PushRule{
62+
Type: pushrules.OverrideRule,
63+
Enabled: true,
64+
Conditions: []*pushrules.PushCondition{cond1},
65+
}
66+
67+
evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{
68+
Membership: "invite",
69+
})
70+
assert.False(t, rule.Match(blankTestRoom, evt))
71+
evt.Content.Raw["fi.mau.will_auto_accept"] = true
72+
assert.True(t, rule.Match(blankTestRoom, evt))
73+
delete(evt.Content.Raw, "fi.mau.will_auto_accept")
74+
assert.False(t, rule.Match(blankTestRoom, evt))
75+
evt.Content.Raw["fi.mau"] = map[string]interface{}{
76+
"will_auto_accept": true,
77+
}
78+
assert.True(t, rule.Match(blankTestRoom, evt))
79+
}
80+
81+
func TestPushRule_Match_Conditions_EscapedKey(t *testing.T) {
82+
cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true")
83+
rule := &pushrules.PushRule{
84+
Type: pushrules.OverrideRule,
85+
Enabled: true,
86+
Conditions: []*pushrules.PushCondition{cond1},
87+
}
88+
89+
evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{
90+
Membership: "invite",
91+
})
92+
assert.False(t, rule.Match(blankTestRoom, evt))
93+
evt.Content.Raw["fi.mau.will_auto_accept"] = true
94+
assert.True(t, rule.Match(blankTestRoom, evt))
95+
}
96+
97+
func TestPushRule_Match_Conditions_EscapedKey_NoNesting(t *testing.T) {
98+
cond1 := newMatchPushCondition("content.fi\\.mau\\.will_auto_accept", "true")
99+
rule := &pushrules.PushRule{
100+
Type: pushrules.OverrideRule,
101+
Enabled: true,
102+
Conditions: []*pushrules.PushCondition{cond1},
103+
}
104+
105+
evt := newFakeEvent(event.EventMessage, &event.MemberEventContent{
106+
Membership: "invite",
107+
})
108+
assert.False(t, rule.Match(blankTestRoom, evt))
109+
evt.Content.Raw["fi.mau"] = map[string]interface{}{
110+
"will_auto_accept": true,
111+
}
112+
assert.False(t, rule.Match(blankTestRoom, evt))
113+
}
114+
34115
func TestPushRule_Match_Conditions_Disabled(t *testing.T) {
35116
cond1 := newMatchPushCondition("content.msgtype", "m.emote")
36117
cond2 := newMatchPushCondition("content.body", "*pushrules")

0 commit comments

Comments
 (0)