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>
163 lines
5.1 KiB
Go
163 lines
5.1 KiB
Go
package pushserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/roomserver"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Receipt types that move the counts. A private receipt is still the
|
|
// user saying they have read up to here; it is private from other users,
|
|
// not from the user's own badge.
|
|
const (
|
|
receiptRead = "m.read"
|
|
receiptReadPrivate = "m.read.private"
|
|
|
|
// threadMain is the thread ID of a receipt for the main timeline.
|
|
threadMain = "main"
|
|
)
|
|
|
|
// ReceiptSet is told about a read receipt a local user set, and clears
|
|
// their notifications in the room up to the event it names.
|
|
//
|
|
// Notifications are not tracked per thread (threads are Phase 10), so a
|
|
// receipt counts when it is for the room as a whole or for its main
|
|
// timeline, and a receipt inside a thread is left alone: clearing the
|
|
// room on it would mark the main timeline read when the user only looked
|
|
// at one thread. Thread notifications are cleared when the main receipt
|
|
// moves past them.
|
|
func (a *API) ReceiptSet(ctx context.Context, userID, roomID, receiptType, threadID, eventID string) {
|
|
if receiptType != receiptRead && receiptType != receiptReadPrivate {
|
|
return
|
|
}
|
|
if threadID != "" && threadID != threadMain {
|
|
return
|
|
}
|
|
pos, err := a.rooms.EventPosition(ctx, roomID, userID, eventID)
|
|
if err != nil {
|
|
// A receipt for an event this server does not have — or one the
|
|
// user cannot see — reads nothing.
|
|
if !errors.Is(err, roomserver.ErrNoSuchEvent) && !errors.Is(err, roomserver.ErrForbidden) {
|
|
a.log.Warn("locating a receipt's event failed",
|
|
"user_id", userID, "room_id", roomID, "event_id", eventID, "error", err)
|
|
}
|
|
return
|
|
}
|
|
if err := a.markRead(ctx, userID, roomID, pos); err != nil {
|
|
a.log.Warn("clearing notifications on a receipt failed",
|
|
"user_id", userID, "room_id", roomID, "error", err)
|
|
}
|
|
}
|
|
|
|
// markRead clears a user's notifications in a room up to a position, and
|
|
// wakes their syncs if the counts moved.
|
|
func (a *API) markRead(ctx context.Context, userID, roomID string, upTo int64) error {
|
|
_, moved, err := a.store.MarkNotificationsRead(ctx, userID, roomID, upTo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if moved {
|
|
a.notifyUsers(userID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NotificationCounts reads a user's unread counts in the given rooms.
|
|
func (a *API) NotificationCounts(ctx context.Context, userID string, roomIDs []string) (map[string]storage.NotificationCounts, error) {
|
|
return a.store.NotificationCounts(ctx, userID, roomIDs)
|
|
}
|
|
|
|
// NotificationCountChanges lists the rooms whose counts for a user moved
|
|
// in a window of the notification stream.
|
|
func (a *API) NotificationCountChanges(ctx context.Context, userID string, from, to int64) ([]string, error) {
|
|
return a.store.NotificationCountChanges(ctx, userID, from, to)
|
|
}
|
|
|
|
// Notification is one entry of GET /notifications.
|
|
type Notification struct {
|
|
Actions []json.RawMessage `json:"actions"`
|
|
Event roomserver.ClientEvent `json:"event"`
|
|
ProfileTag string `json:"profile_tag,omitempty"`
|
|
Read bool `json:"read"`
|
|
RoomID string `json:"room_id"`
|
|
TS int64 `json:"ts"`
|
|
}
|
|
|
|
// NotificationsRequest is GET /notifications.
|
|
type NotificationsRequest struct {
|
|
UserID string
|
|
|
|
// From is the next_token of an earlier page, or empty for the newest.
|
|
From string
|
|
|
|
Limit int
|
|
OnlyHighlight bool
|
|
}
|
|
|
|
// Limits on a page of notifications.
|
|
const (
|
|
DefaultNotificationsLimit = 20
|
|
MaxNotificationsLimit = 100
|
|
)
|
|
|
|
// ErrInvalidFrom is a from token that is not one this server handed out.
|
|
var ErrInvalidFrom = errors.New("invalid pagination token")
|
|
|
|
// Notifications pages through a user's notifications, newest first. It
|
|
// returns the page and the token for the next one, which is empty at the
|
|
// end.
|
|
func (a *API) Notifications(ctx context.Context, req NotificationsRequest) ([]Notification, string, error) {
|
|
var before int64
|
|
if req.From != "" {
|
|
var err error
|
|
if before, err = strconv.ParseInt(req.From, 10, 64); err != nil || before <= 0 {
|
|
return nil, "", ErrInvalidFrom
|
|
}
|
|
}
|
|
limit := req.Limit
|
|
if limit <= 0 {
|
|
limit = DefaultNotificationsLimit
|
|
}
|
|
limit = min(limit, MaxNotificationsLimit)
|
|
|
|
stored, err := a.store.Notifications(ctx, req.UserID, before, limit, req.OnlyHighlight)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
out := make([]Notification, 0, len(stored))
|
|
for _, n := range stored {
|
|
ev, err := a.rooms.Event(ctx, n.RoomID, req.UserID, n.EventID)
|
|
switch {
|
|
case errors.Is(err, roomserver.ErrNoSuchEvent), errors.Is(err, roomserver.ErrNoSuchRoom),
|
|
errors.Is(err, roomserver.ErrForbidden):
|
|
// Purged, or no longer the user's to see: the notification
|
|
// has nothing left to show.
|
|
continue
|
|
case err != nil:
|
|
return nil, "", err
|
|
}
|
|
var actions []json.RawMessage
|
|
if err := json.Unmarshal(n.Actions, &actions); err != nil {
|
|
actions = []json.RawMessage{}
|
|
}
|
|
out = append(out, Notification{
|
|
Actions: actions,
|
|
Event: ev,
|
|
Read: n.Read,
|
|
RoomID: n.RoomID,
|
|
TS: n.TimestampMS,
|
|
})
|
|
}
|
|
|
|
var next string
|
|
if len(stored) == limit {
|
|
next = strconv.FormatInt(stored[len(stored)-1].ID, 10)
|
|
}
|
|
return out, next, nil
|
|
}
|