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>
252 lines
7.7 KiB
Go
252 lines
7.7 KiB
Go
// Package pushserver turns events into notifications: it keeps each user's
|
|
// push rules and pushers, evaluates every event against the rules of every
|
|
// local member of its room, records what notified whom with per-room unread
|
|
// counts, and pushes to the push gateways of the users' devices.
|
|
//
|
|
// It is PLAN.md Phase 8. The rule language is internal/pushrules; this
|
|
// package is everything around it that needs storage, rooms or a network.
|
|
//
|
|
// It hangs off the roomserver's emit stage like the federation queue does,
|
|
// and like the queue it does no work there: the emit stage runs with the
|
|
// room's write lock held, and evaluating an event needs the room's members,
|
|
// their display names, the power levels and a ruleset per member. So an
|
|
// event is put on a list and a worker of this package's own takes it from
|
|
// there. What that costs is a moment in which a sync can show a message
|
|
// whose notification has not been counted yet; the counts are a stream of
|
|
// their own in the sync token (see storage.PushStore), so the sync that
|
|
// follows the worker's write carries the corrected count.
|
|
package pushserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/pushrules"
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Errors callers have to tell apart. The rule-language sentinels are
|
|
// re-exported so that a handler needs only this package.
|
|
var (
|
|
ErrNoSuchRule = pushrules.ErrNoSuchRule
|
|
ErrNoSuchAnchor = pushrules.ErrNoSuchAnchor
|
|
ErrInvalidRule = pushrules.ErrInvalidRule
|
|
|
|
// ErrInvalidPusher is a pusher a client may not register.
|
|
ErrInvalidPusher = errors.New("invalid pusher")
|
|
|
|
// ErrMissingPusherField is a pusher missing a field the spec requires.
|
|
ErrMissingPusherField = errors.New("missing pusher field")
|
|
)
|
|
|
|
// Accounts is the part of the userapi this package needs: push rules are
|
|
// the m.push_rules account data. *userapi.API satisfies it.
|
|
type Accounts interface {
|
|
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
|
|
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
|
|
}
|
|
|
|
// Rooms is the part of the roomserver this package reads. *roomserver.API
|
|
// satisfies it.
|
|
type Rooms interface {
|
|
PushRoomFor(ctx context.Context, roomID string) (roomserver.PushRoom, error)
|
|
Event(ctx context.Context, roomID, userID, eventID string) (roomserver.ClientEvent, error)
|
|
EventPosition(ctx context.Context, roomID, userID, eventID string) (int64, error)
|
|
}
|
|
|
|
// Notifier wakes a user's blocked syncs. *syncapi.Notifier satisfies it.
|
|
type Notifier interface {
|
|
NotifyUser(userID string)
|
|
NotifyUsers(userIDs []string)
|
|
}
|
|
|
|
// API is the push server.
|
|
type API struct {
|
|
serverName string
|
|
store storage.PushStore
|
|
accounts Accounts
|
|
rooms Rooms
|
|
notifier Notifier
|
|
gateway *gateway
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
// pushEnabled sends pushes to gateways. Notifications are counted
|
|
// either way: the counts are what a client shows, and are no less
|
|
// right on a server that does not push.
|
|
pushEnabled bool
|
|
|
|
// retention is how long a read notification is kept for GET
|
|
// /notifications before it is pruned.
|
|
retention time.Duration
|
|
|
|
// rules caches each user's merged ruleset. Every event is evaluated
|
|
// against the rules of every local member of its room, and reading
|
|
// and merging them from account data each time would put a query per
|
|
// member in front of every message. Every write goes through this
|
|
// package, which is what makes the cache safe in one process.
|
|
rulesMu sync.Mutex
|
|
rulesCache map[string]pushrules.Ruleset
|
|
writeLocks sync.Map // localpart -> *sync.Mutex
|
|
|
|
// The event list the emit stage appends to and the worker drains.
|
|
mu sync.Mutex
|
|
incoming []roomserver.Notification
|
|
busy bool
|
|
wake chan struct{}
|
|
idle *sync.Cond
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// Options configure the component.
|
|
type Options struct {
|
|
ServerName string
|
|
Store storage.PushStore
|
|
Accounts Accounts
|
|
Rooms Rooms
|
|
|
|
// Notifier wakes the syncs a count change concerns. Nil wakes
|
|
// nobody, which only a test wants.
|
|
Notifier Notifier
|
|
|
|
// PushEnabled sends pushes to the pushers' gateways.
|
|
PushEnabled bool
|
|
|
|
// IncludeContent puts the event's content into a push. Off, a push
|
|
// carries only who sent what kind of event where, for operators who
|
|
// would rather not have message text pass through a third-party push
|
|
// service.
|
|
IncludeContent bool
|
|
|
|
// GatewayTimeout bounds one request to a push gateway.
|
|
GatewayTimeout time.Duration
|
|
|
|
// HTTPClient sends pushes; nil builds one. Tests pass their own.
|
|
HTTPClient *http.Client
|
|
|
|
// NotificationRetention is how long a read notification is kept.
|
|
NotificationRetention time.Duration
|
|
|
|
Logger *slog.Logger
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Defaults.
|
|
const (
|
|
DefaultGatewayTimeout = 10 * time.Second
|
|
DefaultNotificationRetention = 30 * 24 * time.Hour
|
|
|
|
// pruneInterval is how often read notifications past their retention
|
|
// are deleted.
|
|
pruneInterval = time.Hour
|
|
)
|
|
|
|
// New builds the component and starts its worker. Close stops it.
|
|
func New(opts Options) (*API, error) {
|
|
switch {
|
|
case opts.ServerName == "":
|
|
return nil, errors.New("pushserver: ServerName must not be empty")
|
|
case opts.Store == nil:
|
|
return nil, errors.New("pushserver: Store must not be nil")
|
|
case opts.Accounts == nil:
|
|
return nil, errors.New("pushserver: Accounts must not be nil")
|
|
case opts.Rooms == nil:
|
|
return nil, errors.New("pushserver: Rooms must not be nil")
|
|
}
|
|
if opts.Logger == nil {
|
|
opts.Logger = slog.Default()
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
if opts.GatewayTimeout <= 0 {
|
|
opts.GatewayTimeout = DefaultGatewayTimeout
|
|
}
|
|
if opts.NotificationRetention <= 0 {
|
|
opts.NotificationRetention = DefaultNotificationRetention
|
|
}
|
|
if opts.HTTPClient == nil {
|
|
opts.HTTPClient = &http.Client{Timeout: opts.GatewayTimeout}
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
a := &API{
|
|
serverName: opts.ServerName,
|
|
store: opts.Store,
|
|
accounts: opts.Accounts,
|
|
rooms: opts.Rooms,
|
|
notifier: opts.Notifier,
|
|
log: opts.Logger,
|
|
now: opts.Now,
|
|
pushEnabled: opts.PushEnabled,
|
|
retention: opts.NotificationRetention,
|
|
rulesCache: map[string]pushrules.Ruleset{},
|
|
wake: make(chan struct{}, 1),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
a.idle = sync.NewCond(&a.mu)
|
|
a.gateway = newGateway(gatewayOptions{
|
|
client: opts.HTTPClient,
|
|
includeContent: opts.IncludeContent,
|
|
rejected: a.pushKeyRejected,
|
|
log: opts.Logger,
|
|
})
|
|
|
|
a.wg.Add(2)
|
|
go a.work()
|
|
go a.prune()
|
|
a.gateway.start(ctx)
|
|
return a, nil
|
|
}
|
|
|
|
// Close stops the worker and the gateway senders. Events not yet
|
|
// evaluated are dropped: the emit stage is not durable, and a
|
|
// notification is not worth holding up a shutdown for.
|
|
func (a *API) Close() {
|
|
a.cancel()
|
|
a.mu.Lock()
|
|
a.idle.Broadcast()
|
|
a.mu.Unlock()
|
|
a.wg.Wait()
|
|
a.gateway.wait()
|
|
}
|
|
|
|
// notifyUsers wakes syncs, if there is anybody to wake them.
|
|
func (a *API) notifyUsers(userIDs ...string) {
|
|
if a.notifier == nil || len(userIDs) == 0 {
|
|
return
|
|
}
|
|
a.notifier.NotifyUsers(userIDs)
|
|
}
|
|
|
|
// prune deletes read notifications past their retention, hourly.
|
|
func (a *API) prune() {
|
|
defer a.wg.Done()
|
|
ticker := time.NewTicker(pruneInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-a.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
cutoff := a.now().Add(-a.retention).UnixMilli()
|
|
n, err := a.store.PruneNotifications(a.ctx, cutoff)
|
|
switch {
|
|
case err != nil && a.ctx.Err() == nil:
|
|
a.log.Warn("pruning read notifications failed", "error", err)
|
|
case n > 0:
|
|
a.log.Debug("pruned read notifications", "count", n)
|
|
}
|
|
}
|
|
}
|