package roomserver import ( "context" "encoding/json" "fmt" "slices" "github.com/dariuszjaniak/warp/server/internal/matrix/event" "github.com/dariuszjaniak/warp/server/internal/matrix/roomversion" "github.com/dariuszjaniak/warp/server/internal/storage" ) // Relations: threads, edits, reactions and references. // // An event relates to another through content.m.relates_to, and nothing // about that is checked by the authorisation rules — a relation is a // client convention the server indexes, not part of the room's DAG. The // server's part is three things: recording which event points at which // (relationOf, written with the event), listing an event's children // (/relations and /threads), and summarising them into the parent's // unsigned.m.relations wherever the parent is served (the bundled // aggregations), so that a client rendering a timeline knows a message // has a thread under it or has been edited without asking about every // event. // relationOf reads content.m.relates_to. Both fields have to be strings, // and an m.in_reply_to alone is not a relation in this sense: a reply // points at an event without being one of its children. func relationOf(content json.RawMessage) (eventID, relType string) { var probe struct { RelatesTo *struct { EventID any `json:"event_id"` RelType any `json:"rel_type"` } `json:"m.relates_to"` } if err := json.Unmarshal(content, &probe); err != nil || probe.RelatesTo == nil { return "", "" } id, ok1 := probe.RelatesTo.EventID.(string) rel, ok2 := probe.RelatesTo.RelType.(string) if !ok1 || !ok2 || id == "" || rel == "" { return "", "" } return id, rel } // ThreadOf is the thread an event is in: the root its m.relates_to names // when the relation is m.thread, or "" for an event on the main timeline. // The push server files each notification under it. func ThreadOf(e *event.Event) string { if root, rel := relationOf(e.Content()); rel == storage.RelThread { return root } return "" } // searchableText is what the search index holds for an event: the body of // a message, the name of a room or its topic, as the spec's three search // keys. An encrypted message has no body the server can read and is never // found, which is what end-to-end encryption means. func searchableText(e *event.Event) (key, text string) { var field, k string switch e.Type() { case TypeMessage: field, k = "body", "content.body" case TypeName: field, k = "name", "content.name" case TypeTopic: field, k = "topic", "content.topic" default: return "", "" } var content map[string]any if err := json.Unmarshal(e.Content(), &content); err != nil { return "", "" } value, ok := content[field].(string) if !ok || value == "" { return "", "" } return k, value } // maxRecursionDepth is how far a recursive /relations walks: the spec's // own suggestion, which covers a reaction to an edit in a thread. const maxRecursionDepth = 3 // maxReferenceChunk caps the event IDs in an m.reference summary. const maxReferenceChunk = 50 // RelationsRequest is one page of an event's children. type RelationsRequest struct { RoomID string EventID string // User is who is asking. The parent has to be visible to them, and // so does every child. User string // RelType and EventType narrow the children, when set. RelType string EventType string // Low, High and Direction are a TimelineRequest's. Low int64 High int64 Direction storage.TimelineDirection Limit int // Recurse includes the children's children, down to // maxRecursionDepth levels. Recurse bool } // RelationsPage is one page of an event's children. type RelationsPage struct { Timeline // Depth is how many levels the read went down: 1 without recursion. Depth int } // Relations reads one page of an event's children. func (r *API) Relations(ctx context.Context, req RelationsRequest) (RelationsPage, error) { _, rules, err := r.roomAndRules(ctx, req.RoomID) if err != nil { return RelationsPage{}, err } visible, err := r.requireReadable(ctx, req.RoomID, req.User) if err != nil { return RelationsPage{}, err } parent, err := r.store.Event(ctx, req.RoomID, req.EventID) if err != nil { return RelationsPage{}, err } if parent.Outlier || !visible.Contains(parent.StreamOrdering) { return RelationsPage{}, fmt.Errorf("event %q: %w", req.EventID, ErrNoSuchEvent) } // The parents to read the children of: the event, and with recursion // every descendant down to the depth limit. The filters apply at every // level, as the spec has it, so a walk for m.thread children only // follows threads. // // The children of one level are the next level, so the walk adds a // level to the parents only while it is above the limit: the events at // the limit are read as children and are nobody's parents. depth is // the deepest level that had anything in it. parents := []string{req.EventID} depth := 1 if req.Recurse { depth = 0 level := parents seen := map[string]bool{req.EventID: true} for depth < maxRecursionDepth { children, err := r.store.ChildEventIDs(ctx, req.RoomID, level, req.RelType, req.EventType) if err != nil { return RelationsPage{}, err } // A relation loop cannot be built — an event ID is a hash of // content naming its parent — but a repeat across levels can. children = slices.DeleteFunc(children, func(id string) bool { if seen[id] { return true } seen[id] = true return false }) if len(children) == 0 { break } depth++ if depth < maxRecursionDepth { parents = append(parents, children...) } level = children } depth = max(depth, 1) } empty := Timeline{Start: req.Low, End: req.Low} if req.Direction == storage.DirectionBackward && req.High != 0 { empty = Timeline{Start: req.High, End: req.High} } low, high, any := visible.Clamp(req.Low, req.High) if !any || req.Limit <= 0 { return RelationsPage{Timeline: empty, Depth: depth}, nil } stored, err := r.store.Relations(ctx, storage.RelationsRequest{ RoomID: req.RoomID, ParentIDs: parents, RelType: req.RelType, EventType: req.EventType, Low: low, High: high, Direction: req.Direction, Limit: req.Limit + 1, }) if err != nil { return RelationsPage{}, err } out := RelationsPage{Timeline: empty, Depth: depth} if len(stored) > req.Limit { out.Limited = true stored = stored[:req.Limit] } if len(stored) == 0 { return out, nil } if req.Direction == storage.DirectionBackward { slices.Reverse(stored) } if out.Events, err = r.clientEvents(stored, rules, req.RoomID); err != nil { return RelationsPage{}, err } if err := r.bundleAggregations(ctx, req.RoomID, rules, req.User, visible, stored, out.Events); err != nil { return RelationsPage{}, err } out.Start = storage.PositionBefore(stored[0].StreamOrdering) out.End = stored[len(stored)-1].StreamOrdering return out, nil } // ThreadsRequest is one page of a room's threads. type ThreadsRequest struct { RoomID string User string // Participated keeps only the threads the user started or replied in. Participated bool // Before is where the previous page ended, zero for the first. Before int64 Limit int } // ThreadsPage is one page of a room's threads: their root events, newest // activity first, each with its m.thread summary. type ThreadsPage struct { Roots []ClientEvent // Next is the Before of the next page, zero at the end. Next int64 } // Threads reads one page of a room's threads. func (r *API) Threads(ctx context.Context, req ThreadsRequest) (ThreadsPage, error) { _, rules, err := r.roomAndRules(ctx, req.RoomID) if err != nil { return ThreadsPage{}, err } visible, err := r.requireReadable(ctx, req.RoomID, req.User) if err != nil { return ThreadsPage{}, err } if req.Limit <= 0 { return ThreadsPage{}, nil } participant := "" if req.Participated { participant = req.User } threads, err := r.store.Threads(ctx, storage.ThreadsRequest{ RoomID: req.RoomID, ParticipatedBy: participant, Before: req.Before, High: visible.To, Limit: req.Limit + 1, }) if err != nil { return ThreadsPage{}, err } var out ThreadsPage if len(threads) > req.Limit { threads = threads[:req.Limit] out.Next = threads[len(threads)-1].LatestOrdering } ids := make([]string, 0, len(threads)) for _, t := range threads { ids = append(ids, t.RootID) } stored, err := r.store.EventsByID(ctx, req.RoomID, ids) if err != nil { return ThreadsPage{}, err } byID := make(map[string]storage.StoredEvent, len(stored)) for _, s := range stored { byID[s.EventID] = s } // In the order the store ranked them, leaving out any root the user may // not see: a thread started before a `joined` user's join is not // theirs to read, even if it is still going. roots := make([]storage.StoredEvent, 0, len(threads)) for _, t := range threads { s, ok := byID[t.RootID] if !ok || s.Outlier || !visible.Contains(s.StreamOrdering) { continue } roots = append(roots, s) } if out.Roots, err = r.clientEvents(roots, rules, req.RoomID); err != nil { return ThreadsPage{}, err } if err := r.bundleAggregations(ctx, req.RoomID, rules, req.User, visible, roots, out.Roots); err != nil { return ThreadsPage{}, err } return out, nil } // threadSummary is unsigned.m.relations.m.thread. type threadSummary struct { LatestEvent ClientEvent `json:"latest_event"` Count int `json:"count"` CurrentUserParticipated bool `json:"current_user_participated"` } // referenceSummary is unsigned.m.relations.m.reference. type referenceSummary struct { Chunk []referenceEntry `json:"chunk"` } type referenceEntry struct { EventID string `json:"event_id"` } // bundleAggregations adds unsigned.m.relations to the events of a read: // the thread under each of them, its latest edit, and what references it. // // stored and events are the same events, in the same order — the rows and // their client shape — because the summaries need what only the rows have // (whether an event was redacted) and are written into the client shape. // Annotations are not summarised: the spec dropped their aggregation, and a // client counts reactions from the events themselves. func (r *API) bundleAggregations(ctx context.Context, roomID string, rules roomversion.Rules, userID string, visible VisibleRange, stored []storage.StoredEvent, events []ClientEvent) error { if len(stored) == 0 { return nil } ids := make([]string, 0, len(stored)) parents := make(map[string]storage.StoredEvent, len(stored)) for _, s := range stored { ids = append(ids, s.EventID) parents[s.EventID] = s } aggregates, err := r.store.RelationAggregates(ctx, roomID, ids, visible.To) if err != nil { return err } if len(aggregates) == 0 { return nil } type thread struct { count int latest int64 latestID string participated bool } threads := map[string]*thread{} edits := map[string]storage.RelationAggregate{} referenced := map[string]bool{} for _, a := range aggregates { parent := parents[a.ParentID] switch a.RelType { case storage.RelThread: t := threads[a.ParentID] if t == nil { t = &thread{participated: parent.Sender == userID} threads[a.ParentID] = t } t.count += a.Count if a.LatestOrdering > t.latest { t.latest, t.latestID = a.LatestOrdering, a.LatestEventID } if a.Sender == userID { t.participated = true } case storage.RelReplace: // Only the original's sender may edit it, a state event cannot // be edited, and a redacted event's edits are moot: what they // replaced is gone. if a.Sender != parent.Sender || parent.IsState() || parent.RedactedBy != "" { continue } edits[a.ParentID] = a case storage.RelReference: referenced[a.ParentID] = true } } // The latest thread replies and edits, read in one go. latestIDs := make([]string, 0, len(threads)+len(edits)) for _, t := range threads { latestIDs = append(latestIDs, t.latestID) } for _, a := range edits { latestIDs = append(latestIDs, a.LatestEventID) } latest, err := r.clientEventsByID(ctx, roomID, rules, latestIDs) if err != nil { return err } latestByID := make(map[string]ClientEvent, len(latest)) for _, e := range latest { latestByID[e.EventID] = e } var references map[string][]string if len(referenced) > 0 { refParents := make([]string, 0, len(referenced)) for id := range referenced { refParents = append(refParents, id) } if references, err = r.store.RelationEventIDs(ctx, roomID, refParents, storage.RelReference, visible.To, maxReferenceChunk); err != nil { return err } } for i := range events { id := events[i].EventID relations := map[string]any{} if t, ok := threads[id]; ok { if e, ok := latestByID[t.latestID]; ok { relations[storage.RelThread] = threadSummary{ LatestEvent: e, Count: t.count, CurrentUserParticipated: t.participated, } } } if a, ok := edits[id]; ok { // An edit has to keep the type of what it edits; one that does // not is not a replacement the client should render. if e, ok := latestByID[a.LatestEventID]; ok && e.Type == events[i].Type { relations[storage.RelReplace] = e } } if refs := references[id]; len(refs) > 0 { summary := referenceSummary{Chunk: make([]referenceEntry, 0, len(refs))} for _, ref := range refs { summary.Chunk = append(summary.Chunk, referenceEntry{EventID: ref}) } relations[storage.RelReference] = summary } if len(relations) == 0 { continue } events[i].Unsigned = r.withUnsigned(events[i].Unsigned, "m.relations", relations) } return nil } // withUnsigned sets one key of an event's unsigned block. func (r *API) withUnsigned(unsigned json.RawMessage, key string, value any) json.RawMessage { block := map[string]json.RawMessage{} if len(unsigned) > 0 { if err := json.Unmarshal(unsigned, &block); err != nil { r.log.Warn("an unsigned block this server built is unreadable", "error", err) block = map[string]json.RawMessage{} } } block[key] = mustMarshal(value) return mustMarshal(block) }