Files
Warp/server/internal/pushrules/evaluate.go
T
tajniak81andClaude Opus 5.5 b507753172 Phase 8: push and notifications
Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:36 +02:00

361 lines
8.9 KiB
Go

package pushrules
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
)
// Event is an event as the push rules see it: a tree of JSON values that
// dotted keys are looked up in.
type Event struct {
tree map[string]any
// hasMentions is whether content carries m.mentions at all, which
// switches the legacy mention rules off.
hasMentions bool
}
// NewEvent parses an event. Any shape of event works — the federation
// form or the client one — because the keys rules look at (type, sender,
// state_key, room_id, content) are the same in both.
func NewEvent(raw []byte) (*Event, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
// Numbers stay as written, so that an integer compares as one: a float
// round trip would make 9007199254740993 equal to its neighbour.
dec.UseNumber()
var tree map[string]any
if err := dec.Decode(&tree); err != nil {
return nil, fmt.Errorf("pushrules: event is not a JSON object: %w", err)
}
ev := &Event{tree: tree}
if content, ok := tree["content"].(map[string]any); ok {
_, ev.hasMentions = content["m.mentions"]
}
return ev, nil
}
// lookup follows a dotted key into the event.
func (e *Event) lookup(key string) (any, bool) {
var cur any = e.tree
for _, part := range SplitKey(key) {
obj, ok := cur.(map[string]any)
if !ok {
return nil, false
}
if cur, ok = obj[part]; !ok {
return nil, false
}
}
return cur, true
}
// str is the string at a key, if there is one.
func (e *Event) str(key string) (string, bool) {
v, ok := e.lookup(key)
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
// SplitKey splits a dotted key into its parts. A dot preceded by a
// backslash is part of a name rather than a separator, which is how a key
// reaches into `m.mentions`; a backslash preceded by one is a literal
// backslash. Any other backslash is kept as it is.
func SplitKey(key string) []string {
var parts []string
var cur strings.Builder
for i := 0; i < len(key); i++ {
c := key[i]
switch {
case c == '\\' && i+1 < len(key) && (key[i+1] == '.' || key[i+1] == '\\'):
cur.WriteByte(key[i+1])
i++
case c == '.':
parts = append(parts, cur.String())
cur.Reset()
default:
cur.WriteByte(c)
}
}
return append(parts, cur.String())
}
// Context is what an evaluation needs to know beyond the event itself.
type Context struct {
// UserID is the user the rules belong to.
UserID string
// DisplayName is the user's display name in the room, or empty.
DisplayName string
// MemberCount is how many users are joined to the room.
MemberCount int
// SenderMayNotify reports whether the event's sender has the power a
// notification key — "room", for @room — requires. Nil means nobody
// has.
SenderMayNotify func(key string) bool
}
// Evaluate runs the ruleset against an event and returns the first
// enabled rule that matches it, kind by kind in priority order.
func (rs *Ruleset) Evaluate(ev *Event, ctx Context) (Rule, Kind, bool) {
for _, kind := range Kinds {
for _, rule := range rs.Rules(kind) {
if !rule.Enabled {
continue
}
if rule.Default && ev.hasMentions && legacyMentionRules[rule.RuleID] {
continue
}
if ruleMatches(kind, rule, ev, ctx) {
return rule, kind, true
}
}
}
return Rule{}, "", false
}
func ruleMatches(kind Kind, rule Rule, ev *Event, ctx Context) bool {
switch kind {
case KindOverride, KindUnderride:
for _, cond := range rule.Conditions {
if !conditionMatches(cond, ev, ctx) {
return false
}
}
return true
case KindContent:
if rule.Pattern == nil {
return false
}
body, ok := ev.str("content.body")
return ok && globMatches(*rule.Pattern, body, true)
case KindRoom:
roomID, ok := ev.str("room_id")
return ok && roomID == rule.RuleID
case KindSender:
sender, ok := ev.str("sender")
return ok && sender == rule.RuleID
}
return false
}
func conditionMatches(cond Condition, ev *Event, ctx Context) bool {
switch cond.Kind {
case CondEventMatch:
if cond.Pattern == nil {
return false
}
// event_match is about strings: a number or a boolean at the key
// is not a match for any pattern, "*" included.
value, ok := ev.str(cond.Key)
return ok && globMatches(*cond.Pattern, value, cond.Key == "content.body")
case CondEventPropertyIs:
want, ok := scalar(cond.Value)
if !ok {
return false
}
got, ok := ev.lookup(cond.Key)
return ok && scalarEqual(got, want)
case CondEventPropertyContains:
want, ok := scalar(cond.Value)
if !ok {
return false
}
got, ok := ev.lookup(cond.Key)
if !ok {
return false
}
list, ok := got.([]any)
if !ok {
return false
}
for _, item := range list {
if scalarEqual(item, want) {
return true
}
}
return false
case CondContainsDisplayName:
if ctx.DisplayName == "" {
return false
}
body, ok := ev.str("content.body")
return ok && literalMatches(ctx.DisplayName, body)
case CondRoomMemberCount:
return memberCountMatches(cond.Is, ctx.MemberCount)
case CondSenderNotificationPermission:
return cond.Key != "" && ctx.SenderMayNotify != nil && ctx.SenderMayNotify(cond.Key)
}
return false
}
// scalar decodes a condition's value. Only the canonical JSON scalars may
// be compared: a string, an integer, a boolean or null.
func scalar(raw json.RawMessage) (any, bool) {
if len(raw) == 0 {
return nil, false
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, false
}
switch v.(type) {
case string, bool, nil, json.Number:
return v, true
}
return nil, false
}
// scalarEqual compares an event value with a condition value. Numbers are
// compared as integers, which is all canonical JSON allows.
func scalarEqual(got, want any) bool {
switch w := want.(type) {
case nil:
return got == nil
case string:
g, ok := got.(string)
return ok && g == w
case bool:
g, ok := got.(bool)
return ok && g == w
case json.Number:
g, ok := got.(json.Number)
if !ok {
return false
}
gi, err1 := g.Int64()
wi, err2 := w.Int64()
return err1 == nil && err2 == nil && gi == wi
}
return false
}
// memberCountMatches evaluates room_member_count's "is": an optional
// comparison operator, then an integer. No operator means equality.
func memberCountMatches(is string, count int) bool {
op := "=="
for _, candidate := range []string{"==", "<=", ">=", "<", ">"} {
if rest, ok := strings.CutPrefix(is, candidate); ok {
op, is = candidate, rest
break
}
}
n, err := strconv.Atoi(is)
if err != nil {
return false
}
switch op {
case "==":
return count == n
case "<":
return count < n
case ">":
return count > n
case "<=":
return count <= n
case ">=":
return count >= n
}
return false
}
// Glob matching.
//
// A pattern matches case-insensitively, with `*` for any run of characters
// and `?` for one. It normally has to match the whole value. Against
// content.body it instead has to match a run of whole words somewhere in
// it: a body is prose, and the rule that looks for "alice" should fire on
// "hi alice!" and not on "malice".
// wordChar is the class a word boundary is the edge of: letters and digits
// in any script, and the underscore, as Python's \w — and so Synapse —
// has it.
const wordChar = `\p{L}\p{N}_`
var (
globCacheMu sync.Mutex
globCache = map[string]*regexp.Regexp{}
)
// maxGlobCache bounds the compiled pattern cache. Patterns come from
// users, so an unbounded cache would be a way to fill the server's memory;
// past the bound it starts again, which costs a compile per pattern.
const maxGlobCache = 4096
func globMatches(pattern, value string, words bool) bool {
re := compileGlob(pattern, words)
return re != nil && re.MatchString(value)
}
func compileGlob(pattern string, words bool) *regexp.Regexp {
key := "e\x00" + pattern
if words {
key = "w\x00" + pattern
}
globCacheMu.Lock()
re, ok := globCache[key]
globCacheMu.Unlock()
if ok {
return re
}
var b strings.Builder
for _, r := range pattern {
switch r {
case '*':
b.WriteString(".*?")
case '?':
b.WriteString(".")
default:
b.WriteString(regexp.QuoteMeta(string(r)))
}
}
re = boundaryRegexp(b.String(), words)
globCacheMu.Lock()
if len(globCache) >= maxGlobCache {
globCache = map[string]*regexp.Regexp{}
}
globCache[key] = re
globCacheMu.Unlock()
return re
}
// literalMatches is a word match of a literal string, which is what
// contains_display_name asks: a display name is not a pattern, and one
// called "*" must not match every message.
func literalMatches(literal, value string) bool {
re := boundaryRegexp(regexp.QuoteMeta(literal), true)
return re != nil && re.MatchString(value)
}
func boundaryRegexp(expr string, words bool) *regexp.Regexp {
if words {
expr = `(?:^|[^` + wordChar + `])(?:` + expr + `)(?:[^` + wordChar + `]|$)`
} else {
expr = `^(?:` + expr + `)$`
}
re, err := regexp.Compile(`(?is)` + expr)
if err != nil {
return nil
}
return re
}