Files
Warp/server/internal/syncapi/receipts.go
T
tajniak81andClaude Opus 5.5 b507753172 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>
2026-10-17 11:43:36 +02:00

277 lines
8.7 KiB
Go

package syncapi
import (
"cmp"
"context"
"encoding/json"
"fmt"
"slices"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The receipt types the Client-Server API defines.
const (
// ReceiptRead is public: everybody in the room sees it.
ReceiptRead = "m.read"
// ReceiptReadPrivate is the user's own, and is reported only back to
// them. It exists so that a user can mark a room read without telling
// the room they read it.
ReceiptReadPrivate = "m.read.private"
// ReceiptFullyRead is not a receipt at all: it is the read marker, and
// is stored as the room account data type m.fully_read. It is named
// here because POST /read_markers takes all three in one body.
ReceiptFullyRead = "m.fully_read"
)
// requireJoined refuses a caller who is not in the room.
//
// Every write in this file is a claim about a room — "I am typing in it",
// "I have read this far" — rather than data belonging to the caller, so
// being in the room is the condition for all of them.
func (s *API) requireJoined(ctx context.Context, roomID, userID string) error {
membership, err := s.rooms.Membership(ctx, roomID, userID)
if err != nil {
return err
}
if membership != event.MembershipJoin {
return errForbiddenf("%s is not joined to %s", userID, roomID)
}
return nil
}
// ReceiptRequest is POST /rooms/{roomID}/receipt/{receiptType}/{eventID}.
type ReceiptRequest struct {
RoomID string
// User is the full user ID sending the receipt.
User string
ReceiptType string
EventID string
// ThreadID is the thread the receipt is for, or empty for the room's
// main timeline. Threads are PLAN.md phase 10; the value is stored so
// that a client that sends one gets it back, rather than having its
// threaded receipt silently collapse onto the main timeline's.
ThreadID string
}
// SetReceipt records a read receipt.
func (s *API) SetReceipt(ctx context.Context, req ReceiptRequest) error {
switch req.ReceiptType {
case ReceiptRead, ReceiptReadPrivate, ReceiptFullyRead:
default:
return errInvalidParamf("%q is not a receipt type this server accepts", req.ReceiptType)
}
if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil {
return err
}
// The spec routes m.fully_read through this endpoint too, and it is
// account data rather than a receipt — but it is still a claim about a
// room the caller has to be in, which is why the check above comes
// first.
if req.ReceiptType == ReceiptFullyRead {
return s.setFullyRead(ctx, req.RoomID, req.User, req.EventID)
}
_, moved, err := s.store.SetReceipt(ctx, storage.Receipt{
RoomID: req.RoomID,
UserID: req.User,
ReceiptType: req.ReceiptType,
ThreadID: req.ThreadID,
EventID: req.EventID,
TimestampMS: s.nowMS(),
})
if err != nil {
return err
}
// Nothing moved means the user had already read further, and there is
// nothing for anybody to be woken about.
if !moved {
return nil
}
// A receipt is also the user saying how far they have read, which is
// what clears their unread notifications.
if s.push != nil && s.isLocal(req.User) {
s.push.ReceiptSet(ctx, req.User, req.RoomID, req.ReceiptType, req.ThreadID, req.EventID)
}
// A private receipt concerns only its sender; a public one is part of
// the room's ephemeral stream and everybody in it wants it.
if req.ReceiptType == ReceiptReadPrivate {
s.notifier.NotifyUser(req.User)
return nil
}
s.notifier.NotifyRoom(req.RoomID)
// A private receipt never leaves the server, which is the point of it.
if s.outbound != nil && s.isLocal(req.User) {
s.outbound.Receipt(ctx, req.RoomID, req.User, req.ReceiptType, req.EventID, req.ThreadID, s.nowMS())
}
return nil
}
// ReadMarkersRequest is POST /rooms/{roomID}/read_markers.
type ReadMarkersRequest struct {
RoomID string
User string
// FullyRead is the event up to which the user has read, stored as the
// room account data m.fully_read. It is where a client draws the
// "unread from here" line, which is a different thing from a receipt:
// the line stays where the user left it even as they scroll past.
FullyRead string
// Read and ReadPrivate are ordinary receipts, sent in the same request
// because a client moving the marker almost always moves both.
Read string
ReadPrivate string
}
// SetReadMarkers implements the read markers endpoint: up to three
// independent writes from one request.
//
// They are not atomic, and cannot usefully be: the marker is account data
// and the receipts are the receipt stream, which are two streams with two
// positions. A failure partway through leaves the earlier writes in place,
// which is the same state a client would reach by sending them separately
// and losing its connection — and which the next call corrects.
func (s *API) SetReadMarkers(ctx context.Context, req ReadMarkersRequest) error {
if err := s.requireJoined(ctx, req.RoomID, req.User); err != nil {
return err
}
if req.FullyRead != "" {
if err := s.setFullyRead(ctx, req.RoomID, req.User, req.FullyRead); err != nil {
return err
}
}
for _, receipt := range []struct {
receiptType string
eventID string
}{
{ReceiptRead, req.Read},
{ReceiptReadPrivate, req.ReadPrivate},
} {
if receipt.eventID == "" {
continue
}
if err := s.SetReceipt(ctx, ReceiptRequest{
RoomID: req.RoomID,
User: req.User,
ReceiptType: receipt.receiptType,
EventID: receipt.eventID,
}); err != nil {
return err
}
}
return nil
}
// setFullyRead writes the read marker as room account data.
func (s *API) setFullyRead(ctx context.Context, roomID, userID, eventID string) error {
localpart, ok := s.localpartOf(userID)
if !ok {
return errForbiddenf("%s is not a user of this server", userID)
}
content, err := json.Marshal(map[string]string{"event_id": eventID})
if err != nil {
return fmt.Errorf("marshalling the read marker: %w", err)
}
if err := s.accounts.SetAccountData(ctx, localpart, roomID, ReceiptFullyRead, content); err != nil {
return err
}
s.notifier.NotifyUser(userID)
return nil
}
// receiptsEvent builds the m.receipt EDU of one room.
//
// The shape is the spec's, and it is an awkward one: a map of event ID to
// receipt type to user ID to the receipt's data. It is nested that way
// because a client's job is to answer "who has read up to this event",
// which this shape makes a lookup and a flat list would make a scan.
func receiptsEvent(receipts []storage.Receipt) *EphemeralEvent {
if len(receipts) == 0 {
return nil
}
type receiptData struct {
TS int64 `json:"ts"`
ThreadID string `json:"thread_id,omitempty"`
}
content := map[string]map[string]map[string]receiptData{}
for _, r := range receipts {
byType, ok := content[r.EventID]
if !ok {
byType = map[string]map[string]receiptData{}
content[r.EventID] = byType
}
byUser, ok := byType[r.ReceiptType]
if !ok {
byUser = map[string]receiptData{}
byType[r.ReceiptType] = byUser
}
byUser[r.UserID] = receiptData{TS: r.TimestampMS, ThreadID: r.ThreadID}
}
body, err := json.Marshal(content)
if err != nil {
// Every value here is a string or an integer, so this cannot
// happen; dropping the EDU beats failing the sync if it does.
return nil
}
return &EphemeralEvent{Type: "m.receipt", Content: body}
}
// roomReceipts reads the receipts a user is entitled to see in a set of
// rooms, between two positions.
//
// Private receipts are filtered here rather than in SQL: the query is the
// same one the public receipts come from, and a WHERE clause that had to
// name the asking user would turn one query per sync into two.
func (s *API) roomReceipts(ctx context.Context, userID string, roomIDs []string, from, to int64) (map[string][]storage.Receipt, error) {
if len(roomIDs) == 0 {
return nil, nil
}
receipts, err := s.store.Receipts(ctx, roomIDs, from, to)
if err != nil {
return nil, err
}
// An initial sync also wants the user's own receipts however old they
// are: a client that has just logged in has to know where it had read
// up to, and that receipt was set long before this sync's window.
if from == 0 {
own, err := s.store.ReceiptsForUser(ctx, userID, roomIDs)
if err != nil {
return nil, err
}
receipts = append(receipts, own...)
}
out := map[string][]storage.Receipt{}
seen := map[storage.Receipt]struct{}{}
for _, r := range receipts {
if r.ReceiptType == ReceiptReadPrivate && r.UserID != userID {
continue
}
if _, dup := seen[r]; dup {
// The user's own receipts can arrive from both reads above.
continue
}
seen[r] = struct{}{}
out[r.RoomID] = append(out[r.RoomID], r)
}
for roomID := range out {
slices.SortFunc(out[roomID], func(a, b storage.Receipt) int {
return cmp.Compare(a.StreamPos, b.StreamPos)
})
}
return out, nil
}