Files
Warp/server/internal/pushrules/rules.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

274 lines
8.2 KiB
Go

// Package pushrules is the push rule language of the Client-Server API:
// the rule model, the server-default ruleset, and the evaluation that
// decides whether an event notifies a user.
//
// It is pure library code, like internal/matrix: no storage, no HTTP, no
// rooms. The caller supplies the event and the handful of facts about the
// room an evaluation needs — the user's display name in it, how many
// members it has, how much power the sender holds — and gets back the rule
// that matched. Where those facts come from, and what is done with the
// answer, is internal/pushserver's business.
package pushrules
import (
"encoding/json"
"fmt"
"slices"
"strings"
)
// Kind is one of the five kinds of push rule, which are also the order
// they are evaluated in.
type Kind string
// The kinds, as the spec spells them in paths and in a ruleset.
const (
KindOverride Kind = "override"
KindContent Kind = "content"
KindRoom Kind = "room"
KindSender Kind = "sender"
KindUnderride Kind = "underride"
)
// Kinds lists the kinds in evaluation order.
var Kinds = []Kind{KindOverride, KindContent, KindRoom, KindSender, KindUnderride}
// ParseKind reads a kind from a path segment.
func ParseKind(s string) (Kind, bool) {
k := Kind(s)
return k, slices.Contains(Kinds, k)
}
// Rule is one push rule, in the shape the Client-Server API reads and
// writes it.
type Rule struct {
RuleID string `json:"rule_id"`
Default bool `json:"default"`
Enabled bool `json:"enabled"`
// Actions are kept as the JSON they arrived as. The spec has the
// server pass unknown tweaks through to the push gateway untouched,
// and a typed model would quietly drop them.
Actions []json.RawMessage `json:"actions"`
// Conditions are only on override and underride rules. A room or
// sender rule's condition is its ID, and a content rule's is its
// pattern.
Conditions []Condition `json:"conditions,omitempty"`
// Pattern is only on a content rule.
Pattern *string `json:"pattern,omitempty"`
}
// MarshalJSON writes conditions whenever the rule has a list of them, an
// empty one included: .m.rule.master's "conditions": [] is how a client
// tells an override rule that always matches from a malformed one.
func (r Rule) MarshalJSON() ([]byte, error) {
type plain Rule
if r.Conditions == nil || len(r.Conditions) > 0 {
return json.Marshal(plain(r))
}
return json.Marshal(struct {
plain
Conditions []Condition `json:"conditions"`
}{plain: plain(r), Conditions: []Condition{}})
}
// Condition is one condition of an override or underride rule.
type Condition struct {
Kind string `json:"kind"`
// Key is the dotted path into the event for event_match,
// event_property_is and event_property_contains, and the notification
// key for sender_notification_permission.
Key string `json:"key,omitempty"`
// Pattern is event_match's glob.
Pattern *string `json:"pattern,omitempty"`
// Is is room_member_count's comparison, e.g. "2" or ">=10".
Is string `json:"is,omitempty"`
// Value is what event_property_is and event_property_contains compare
// against. It is raw so that `null` is a value rather than an absence.
Value json.RawMessage `json:"value,omitempty"`
}
// The condition kinds this server evaluates. A rule with any other kind of
// condition never matches, which is what the spec asks of a server that
// meets a condition it does not understand.
const (
CondEventMatch = "event_match"
CondEventPropertyIs = "event_property_is"
CondEventPropertyContains = "event_property_contains"
CondContainsDisplayName = "contains_display_name"
CondRoomMemberCount = "room_member_count"
CondSenderNotificationPermission = "sender_notification_permission"
)
// Ruleset is a user's rules, one list per kind, each in priority order.
type Ruleset struct {
Override []Rule `json:"override"`
Content []Rule `json:"content"`
Room []Rule `json:"room"`
Sender []Rule `json:"sender"`
Underride []Rule `json:"underride"`
}
// Rules returns the list of one kind.
func (rs *Ruleset) Rules(kind Kind) []Rule {
switch kind {
case KindOverride:
return rs.Override
case KindContent:
return rs.Content
case KindRoom:
return rs.Room
case KindSender:
return rs.Sender
case KindUnderride:
return rs.Underride
}
return nil
}
// SetRules replaces the list of one kind.
func (rs *Ruleset) SetRules(kind Kind, rules []Rule) {
switch kind {
case KindOverride:
rs.Override = rules
case KindContent:
rs.Content = rules
case KindRoom:
rs.Room = rules
case KindSender:
rs.Sender = rules
case KindUnderride:
rs.Underride = rules
}
}
// Find returns a rule by kind and ID.
func (rs *Ruleset) Find(kind Kind, ruleID string) (Rule, bool) {
for _, r := range rs.Rules(kind) {
if r.RuleID == ruleID {
return r, true
}
}
return Rule{}, false
}
// normalise replaces nil lists with empty ones, so that a ruleset always
// marshals with all five keys as arrays: a client reading `null` where it
// expects a list of rules is a client that crashes.
func (rs *Ruleset) normalise() {
for _, kind := range Kinds {
rules := rs.Rules(kind)
if rules == nil {
rules = []Rule{}
}
for i := range rules {
if rules[i].Actions == nil {
rules[i].Actions = []json.RawMessage{}
}
}
rs.SetRules(kind, rules)
}
}
// MarshalJSON writes the ruleset with every kind present.
func (rs Ruleset) MarshalJSON() ([]byte, error) {
rs.normalise()
type plain Ruleset
return json.Marshal(plain(rs))
}
// Clone returns a deep enough copy that editing one list of it leaves the
// original alone.
func (rs Ruleset) Clone() Ruleset {
var out Ruleset
for _, kind := range Kinds {
out.SetRules(kind, slices.Clone(rs.Rules(kind)))
}
return out
}
// IsReserved reports whether a rule ID is in the namespace the spec keeps
// for server-default rules. A client may change the actions of such a rule
// and switch it on and off, but may not create, replace or delete one.
func IsReserved(ruleID string) bool { return strings.HasPrefix(ruleID, ".") }
// Merge lays a user's stored rules over the server defaults.
//
// What a user owns is the rules they made, and the enabled flag and
// actions of each default rule; the default rules themselves — their
// conditions, and which of them exist — are the server's, and come from
// defaults. Storing the whole ruleset and re-reading only those parts is
// what lets a server upgrade bring in a default rule a newer spec version
// added, without a migration rewriting every user's rules.
//
// The order within a kind is the spec's: user rules outrank the defaults
// of their kind, except that .m.rule.master stays ahead of everything so
// that switching it on still silences all.
func Merge(stored, defaults Ruleset) Ruleset {
var out Ruleset
for _, kind := range Kinds {
overrides := map[string]Rule{}
var user []Rule
for _, r := range stored.Rules(kind) {
if r.Default {
overrides[r.RuleID] = r
continue
}
if IsReserved(r.RuleID) {
// A user rule cannot have a reserved ID; one that does was
// not written by this server and is ignored rather than
// allowed to shadow a default.
continue
}
user = append(user, r)
}
var prepend, appended []Rule
for _, d := range defaults.Rules(kind) {
if o, ok := overrides[d.RuleID]; ok {
d.Enabled = o.Enabled
d.Actions = o.Actions
}
if d.RuleID == RuleMaster {
prepend = append(prepend, d)
continue
}
appended = append(appended, d)
}
rules := make([]Rule, 0, len(prepend)+len(user)+len(appended))
rules = append(rules, prepend...)
rules = append(rules, user...)
rules = append(rules, appended...)
out.SetRules(kind, rules)
}
out.normalise()
return out
}
// Document is the content of the m.push_rules account data: the ruleset
// under the one scope Matrix still has.
type Document struct {
Global Ruleset `json:"global"`
}
// ParseDocument reads stored m.push_rules content.
func ParseDocument(raw []byte) (Ruleset, error) {
var doc Document
if err := json.Unmarshal(raw, &doc); err != nil {
return Ruleset{}, fmt.Errorf("pushrules: stored ruleset is not readable: %w", err)
}
return doc.Global, nil
}
// MarshalDocument renders a ruleset as m.push_rules content.
func MarshalDocument(rs Ruleset) ([]byte, error) {
return json.Marshal(Document{Global: rs})
}