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>
274 lines
8.2 KiB
Go
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})
|
|
}
|