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>
This commit is contained in:
1 parent
74f7bc8805
commit
b507753172
47 files changed
+5136
-50
No files matched your search
@@ -97,6 +97,12 @@ type JoinedRoom struct {
|
||||
Timeline Timeline `json:"timeline"`
|
||||
Ephemeral EphemeralEventList `json:"ephemeral"`
|
||||
AccountData AccountDataList `json:"account_data"`
|
||||
|
||||
// UnreadNotifications is the user's unread notification counts in the
|
||||
// room. It is a pointer so that a server without a push server leaves
|
||||
// it out rather than claiming zero: an absent count says nothing,
|
||||
// where a zero says "you have read everything".
|
||||
UnreadNotifications *UnreadNotifications `json:"unread_notifications,omitempty"`
|
||||
}
|
||||
|
||||
// InvitedRoom is a room the user has been invited to. It carries no
|
||||
@@ -327,8 +333,11 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
|
||||
if err := s.buildRooms(ctx, req, filter, since, to, rooms, &resp); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
if err := s.unreadNotifications(ctx, req.User, &resp); err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
|
||||
accountData, err := s.globalAccountData(ctx, req.Localpart, since.AccountData, to.AccountData, filter)
|
||||
accountData, err := s.globalAccountData(ctx, req, since.AccountData, to.AccountData, filter)
|
||||
if err != nil {
|
||||
return Response{}, false, err
|
||||
}
|
||||
@@ -537,6 +546,22 @@ func (s *API) changedRooms(ctx context.Context, req SyncRequest, filter Filter,
|
||||
}
|
||||
}
|
||||
|
||||
// A room whose unread counts moved without an event in this window —
|
||||
// read on another device, or counted after the event was already sent
|
||||
// to this client — is reported so that the client hears the new
|
||||
// counts.
|
||||
if s.push != nil && to.Notification > since.Notification {
|
||||
changed, err := s.push.NotificationCountChanges(ctx, req.User, since.Notification, to.Notification)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, roomID := range changed {
|
||||
if slices.Contains(joined, roomID) {
|
||||
out[roomID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, room := range rooms {
|
||||
if room.Position > since.PDU && room.Position <= to.PDU {
|
||||
out[room.RoomID] = struct{}{}
|
||||
@@ -640,12 +665,20 @@ func (s *API) roomEphemeral(ctx context.Context, userID, roomID string, window r
|
||||
}
|
||||
|
||||
// globalAccountData reads the account data that is not about a room.
|
||||
func (s *API) globalAccountData(ctx context.Context, localpart string, from, to int64, filter Filter) ([]AccountDataEvent, error) {
|
||||
entries, err := s.accountDataEntries(ctx, localpart, from, to)
|
||||
func (s *API) globalAccountData(ctx context.Context, req SyncRequest, from, to int64, filter Filter) ([]AccountDataEvent, error) {
|
||||
entries, err := s.accountDataEntries(ctx, req.Localpart, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.accountDataEvents(ctx, localpart, entries, "", filter.AccountDataFilter())
|
||||
// Every user has push rules, written or not: a user who has never
|
||||
// changed one has the server defaults, and a client reads its rules
|
||||
// from the account data of its first sync whichever it is.
|
||||
if from == 0 && s.push != nil && !slices.ContainsFunc(entries, func(e storage.AccountDataEntry) bool {
|
||||
return e.RoomID == "" && e.DataType == pushRulesType
|
||||
}) {
|
||||
entries = append(entries, storage.AccountDataEntry{DataType: pushRulesType})
|
||||
}
|
||||
return s.accountDataEvents(ctx, req, entries, "", filter.AccountDataFilter())
|
||||
}
|
||||
|
||||
// roomAccountData reads one room's account data.
|
||||
@@ -655,7 +688,7 @@ func (s *API) roomAccountData(ctx context.Context, localpart, roomID string, win
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.accountDataEvents(ctx, localpart, entries, roomID, filter.RoomAccountDataFilter())
|
||||
return s.accountDataEvents(ctx, SyncRequest{Localpart: localpart}, entries, roomID, filter.RoomAccountDataFilter())
|
||||
}
|
||||
|
||||
// accountDataEntries lists which of a user's account data changed in a
|
||||
@@ -669,7 +702,7 @@ func (s *API) accountDataEntries(ctx context.Context, localpart string, from, to
|
||||
|
||||
// accountDataEvents turns the entries of one scope — global, or one room —
|
||||
// into the events a sync reports.
|
||||
func (s *API) accountDataEvents(ctx context.Context, localpart string, entries []storage.AccountDataEntry, roomID string, filter storage.TimelineFilter) ([]AccountDataEvent, error) {
|
||||
func (s *API) accountDataEvents(ctx context.Context, req SyncRequest, entries []storage.AccountDataEntry, roomID string, filter storage.TimelineFilter) ([]AccountDataEvent, error) {
|
||||
out := []AccountDataEvent{}
|
||||
for _, entry := range entries {
|
||||
if entry.RoomID != roomID {
|
||||
@@ -678,7 +711,15 @@ func (s *API) accountDataEvents(ctx context.Context, localpart string, entries [
|
||||
if !matchesEventFilter(entry.DataType, "", filter) {
|
||||
continue
|
||||
}
|
||||
content, err := s.accounts.AccountData(ctx, localpart, entry.RoomID, entry.DataType)
|
||||
if entry.RoomID == "" && entry.DataType == pushRulesType && s.push != nil {
|
||||
content, err := s.push.Document(ctx, req.Localpart, req.User)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AccountDataEvent{Type: entry.DataType, Content: content})
|
||||
continue
|
||||
}
|
||||
content, err := s.accounts.AccountData(ctx, req.Localpart, entry.RoomID, entry.DataType)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchAccountData):
|
||||
// Deleted between the two reads: nothing to report.
|
||||
|
||||
Reference in new issue
Block a user