tinyrelayfile
tinyrelay/internal/daemon/rooms_browse.go/

internal/daemon/rooms_browse.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "time"
9
10 "github.com/FelineStateMachine/tinyrelay/internal/community"
11 "github.com/FelineStateMachine/tinyrelay/internal/event"
12 "github.com/FelineStateMachine/tinyrelay/internal/storage"
13 webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
14)
15
16var (
17 errRoomNotFound = errors.New("not found: room")
18 errRoomAuth = errors.New("auth-required: this room is members-only")
19 errRoomRestricted = errors.New("restricted: this room is members-only")
20)
21
22type roomBrowseRequest struct {
23 ID string `json:"id"`
24 Event string `json:"event"`
25 Cursor string `json:"cursor"`
26 Limit int `json:"limit"`
27}
28
29type roomSummary struct {
30 community.Room
31 Members int `json:"members"`
32 LastMessageAt int64 `json:"last_message_at"`
33 Role string `json:"role"`
34}
35
36// roomMemberView is a room member with the relay-level marker a page needs:
37// whether the member is an agent identity.
38type roomMemberView struct {
39 community.RoomMember
40 Agent bool `json:"agent,omitempty"`
41}
42
43func roomBrowseMethod(method string) bool {
44 return method == "browserooms" || method == "browseroom" || method == "browsethread"
45}
46
47// executeRoomBrowse serves the room reads used by the web pages and agents.
48// Access follows the tenant read rule and then the room's own access.
49func (t *Tenant) executeRoomBrowse(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) {
50 ctx, finish := t.app.telemetry.Start(ctx, "rooms")
51 defer func() {
52 if err != nil {
53 finish("error")
54 } else {
55 finish("success")
56 }
57 }()
58 if err := t.browseRead(ctx, actor); err != nil {
59 return nil, err
60 }
61 q := roomBrowseRequest{}
62 if len(params) > 0 {
63 if err := json.Unmarshal(params[0], &q); err != nil {
64 return nil, fmt.Errorf("invalid: room parameters: %w", err)
65 }
66 }
67 if q.Limit <= 0 || q.Limit > 100 {
68 q.Limit = 100
69 }
70 switch method {
71 case "browserooms":
72 return t.browseRooms(ctx, actor, q)
73 case "browseroom":
74 return t.browseRoom(ctx, actor, q)
75 case "browsethread":
76 return t.browseThread(ctx, actor, q)
77 }
78 return nil, errors.New("unsupported: room operation")
79}
80
81func (t *Tenant) browseRooms(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
82 list, err := t.listRoomsCore(ctx, actor, q.Cursor, q.Limit)
83 if err != nil {
84 return nil, err
85 }
86 items := make([]roomSummary, 0, len(list.Rooms))
87 for _, summary := range list.Rooms {
88 items = append(items, roomSummaryFromWebUI(summary))
89 }
90 return map[string]any{"items": items, "next_cursor": list.NextCursor}, nil
91}
92
93func (t *Tenant) roomSummary(ctx context.Context, actor string, room community.Room, role string) (roomSummary, error) {
94 count, err := t.community.RoomMemberCount(ctx, room.ID)
95 if err != nil {
96 return roomSummary{}, err
97 }
98 summary := roomSummary{Room: room, Members: count, Role: role}
99 latest, err := t.store.Query(ctx, event.Filter{Kinds: []int{event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY, event.KIND_RICH_CONTENT}, Tags: map[string][]string{"h": {room.ID}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: browseSession(t, actor).PubKeys}, Limit: 1})
100 if err != nil {
101 return roomSummary{}, err
102 }
103 if len(latest.Events) > 0 {
104 summary.LastMessageAt = latest.Events[0].CreatedAt
105 }
106 return summary, nil
107}
108
109func roomSummaryFromWebUI(summary webui.RoomSummary) roomSummary {
110 return roomSummary{Room: community.Room{ID: summary.ID, Name: summary.Name, About: summary.About, Picture: summary.Picture, Access: summary.Access, CreatedBy: summary.CreatedBy, CreatedAt: summary.CreatedAt, EventID: summary.EventID, DeletedAt: summary.DeletedAt}, Members: summary.Members, LastMessageAt: summary.LastMessageAt, Role: summary.Role}
111}
112
113func wireRoomPage(page webui.RoomPage, thread bool) map[string]any {
114 room := roomSummaryFromWebUI(page.Room)
115 members := make([]roomMemberView, 0, len(page.Members))
116 for _, member := range page.Members {
117 members = append(members, roomMemberView{RoomMember: community.RoomMember{PubKey: member.PubKey, Role: member.Role, AddedAt: member.AddedAt}, Agent: member.Agent})
118 }
119 if thread {
120 return map[string]any{"room": room, "root": page.Root, "replies": page.Replies, "edits": page.Edits, "next_cursor": page.NextCursor}
121 }
122 return map[string]any{"room": room, "members": members, "messages": page.Messages, "edits": page.Edits, "next_cursor": page.NextCursor}
123}
124
125func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
126 // The MCP browser contract exposes raw room events for agents. The typed
127 // web reader folds replies into top-level pages for the UI.
128 page, err := t.readRoomCore(ctx, actor, q.ID, q.Cursor, q.Limit, false)
129 if err != nil {
130 return nil, err
131 }
132 return wireRoomPage(page, false), nil
133}
134
135func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
136 page, err := t.readThreadCore(ctx, actor, q.ID, q.Event, q.Cursor, q.Limit)
137 if err != nil {
138 return nil, err
139 }
140 return wireRoomPage(page, true), nil
141}
142
143// roomMessages pages a room filter newest first through the read gate. The
144// cursor is the created_at and id of the last event on the previous page.
145func (t *Tenant) roomMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) {
146 session := browseSession(t, actor)
147 if _, err := t.gate.Read(ctx, []event.Filter{filter}, session); err != nil {
148 return nil, "", err
149 }
150 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})
151 if err != nil {
152 return nil, "", err
153 }
154 rows := make([]event.Event, 0, len(page.Events))
155 for _, row := range page.Events {
156 if t.gate.CanSee(ctx, row, session, nil) {
157 rows = append(rows, row)
158 }
159 }
160 sortEventsNewestFirst(rows)
161 next := ""
162 if page.More && len(page.Events) > 0 {
163 last := page.Events[len(page.Events)-1]
164 next = collaborationCursor(collaborationItemFrom(last))
165 }
166 return rows, next, nil
167}
168
169// roomEdits returns the newest author-owned edit for each visible message.
170// It deliberately queries independently of the timeline cursor: an edit is
171// a replacement for its target and may have been published after the page
172// containing that target was read. A thread page has at most 100 replies
173// plus its root; each target contributes at most one replacement.
174func (t *Tenant) roomEdits(ctx context.Context, actor, roomID string, targets []event.Event) ([]event.Event, error) {
175 if len(targets) == 0 {
176 return []event.Event{}, nil
177 }
178 validTargets := make([]event.Event, 0, len(targets))
179 for _, target := range targets {
180 if len(target.ID) != 64 || !hexLower(target.ID) || target.PubKey == "" ||
181 (target.Kind != event.KIND_CHAT && target.Kind != event.KIND_THREAD && target.Kind != event.KIND_THREAD_REPLY && target.Kind != event.KIND_RICH_CONTENT) {
182 continue
183 }
184 validTargets = append(validTargets, target)
185 if len(validTargets) == 101 {
186 break
187 }
188 }
189 if len(validTargets) == 0 {
190 return []event.Event{}, nil
191 }
192 newest := make(map[string]event.Event, len(validTargets))
193 for _, target := range validTargets {
194 since := target.CreatedAt
195 filter := event.Filter{Kinds: []int{event.KIND_CONTENT_EDIT}, Authors: []string{target.PubKey}, Tags: map[string][]string{"h": {roomID}, "e": {target.ID}}, Since: &since}
196 var cursor *storage.EventCursor
197 for {
198 edits, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)
199 if err != nil {
200 return nil, err
201 }
202 for _, edit := range edits {
203 if lastRoomEditTarget(edit) == target.ID {
204 newest[target.ID] = edit
205 break
206 }
207 }
208 if _, found := newest[target.ID]; found || next == "" {
209 break
210 }
211 cursor, err = parseCollaborationCursor(next)
212 if err != nil {
213 return nil, err
214 }
215 }
216 }
217 result := make([]event.Event, 0, len(newest))
218 for _, edit := range newest {
219 result = append(result, edit)
220 }
221 sortEventsNewestFirst(result)
222 return result, nil
223}
224
225func lastRoomEditTarget(e event.Event) string {
226 target := ""
227 for _, tag := range e.Tags {
228 if len(tag) >= 2 && tag[0] == "e" {
229 target = tag[1]
230 }
231 }
232 return target
233}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs