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>
161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
package pushrules
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// The action strings. dont_notify and coalesce are legacy: the spec has
|
|
// servers accept them and treat them as doing nothing, which for
|
|
// dont_notify is exactly what an empty action list means.
|
|
const (
|
|
ActionNotify = "notify"
|
|
ActionDontNotify = "dont_notify"
|
|
ActionCoalesce = "coalesce"
|
|
)
|
|
|
|
// The tweaks the server itself reads. Every other tweak goes to the push
|
|
// gateway as it was written.
|
|
const (
|
|
TweakHighlight = "highlight"
|
|
TweakSound = "sound"
|
|
)
|
|
|
|
// Outcome is what a matched rule's actions ask for.
|
|
type Outcome struct {
|
|
Notify bool
|
|
Highlight bool
|
|
|
|
// Tweaks are every set_tweak of the actions, by name, as the push
|
|
// gateway is to be sent them. A highlight with no value is written
|
|
// out as true.
|
|
Tweaks map[string]json.RawMessage
|
|
}
|
|
|
|
// ParseActions reads a rule's actions.
|
|
//
|
|
// A highlight tweak with no value means true, as the spec says. A
|
|
// highlight on a rule that does not notify is ignored: a count of
|
|
// highlighted notifications that included events that are not
|
|
// notifications at all would be no use to anybody.
|
|
func ParseActions(actions []json.RawMessage) Outcome {
|
|
out := Outcome{Tweaks: map[string]json.RawMessage{}}
|
|
for _, raw := range actions {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
if s == ActionNotify {
|
|
out.Notify = true
|
|
}
|
|
continue
|
|
}
|
|
var tweak struct {
|
|
SetTweak string `json:"set_tweak"`
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if json.Unmarshal(raw, &tweak) != nil || tweak.SetTweak == "" {
|
|
continue
|
|
}
|
|
value := tweak.Value
|
|
if tweak.SetTweak == TweakHighlight {
|
|
on := len(value) == 0 || bytes.Equal(bytes.TrimSpace(value), []byte("true"))
|
|
out.Highlight = on
|
|
value = json.RawMessage(fmt.Sprint(on))
|
|
}
|
|
if len(value) == 0 {
|
|
continue
|
|
}
|
|
out.Tweaks[tweak.SetTweak] = value
|
|
}
|
|
if !out.Notify {
|
|
out.Highlight = false
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ErrInvalidRule is a rule or an action list a client may not store.
|
|
var ErrInvalidRule = errors.New("invalid push rule")
|
|
|
|
func invalid(format string, args ...any) error {
|
|
return fmt.Errorf("%w: %s", ErrInvalidRule, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
// ValidateActions checks an action list a client sent.
|
|
func ValidateActions(actions []json.RawMessage) error {
|
|
if actions == nil {
|
|
return invalid("actions are required")
|
|
}
|
|
for _, raw := range actions {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
switch s {
|
|
case ActionNotify, ActionDontNotify, ActionCoalesce:
|
|
continue
|
|
}
|
|
return invalid("%q is not an action", s)
|
|
}
|
|
var tweak map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &tweak) != nil {
|
|
return invalid("an action must be a string or an object")
|
|
}
|
|
var name string
|
|
if json.Unmarshal(tweak["set_tweak"], &name) != nil || name == "" {
|
|
return invalid("an object action must name a tweak in set_tweak")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewRule is a rule a client asked to create or replace, before it has
|
|
// been given its kind's shape.
|
|
type NewRule struct {
|
|
Actions []json.RawMessage `json:"actions"`
|
|
Conditions *[]Condition `json:"conditions"`
|
|
Pattern *string `json:"pattern"`
|
|
}
|
|
|
|
// Build checks a client's rule against its kind and returns the rule as
|
|
// stored. It is enabled: the spec has a new rule start that way.
|
|
func (n NewRule) Build(kind Kind, ruleID string) (Rule, error) {
|
|
switch {
|
|
case ruleID == "":
|
|
return Rule{}, invalid("a rule ID is required")
|
|
case IsReserved(ruleID):
|
|
return Rule{}, invalid("rule IDs starting with '.' are reserved for the server's own rules")
|
|
case strings.ContainsAny(ruleID, "/\\"):
|
|
return Rule{}, invalid("a rule ID may not contain a slash or a backslash")
|
|
}
|
|
if err := ValidateActions(n.Actions); err != nil {
|
|
return Rule{}, err
|
|
}
|
|
|
|
rule := Rule{RuleID: ruleID, Enabled: true, Actions: n.Actions}
|
|
switch kind {
|
|
case KindOverride, KindUnderride:
|
|
if n.Conditions == nil {
|
|
return Rule{}, invalid("an %s rule needs conditions", kind)
|
|
}
|
|
for _, c := range *n.Conditions {
|
|
if c.Kind == "" {
|
|
return Rule{}, invalid("every condition needs a kind")
|
|
}
|
|
}
|
|
rule.Conditions = *n.Conditions
|
|
if rule.Conditions == nil {
|
|
rule.Conditions = []Condition{}
|
|
}
|
|
case KindContent:
|
|
if n.Pattern == nil {
|
|
return Rule{}, invalid("a content rule needs a pattern")
|
|
}
|
|
rule.Pattern = n.Pattern
|
|
case KindRoom, KindSender:
|
|
// The rule ID is the condition.
|
|
default:
|
|
return Rule{}, invalid("%q is not a kind of push rule", kind)
|
|
}
|
|
return rule, nil
|
|
}
|