| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 16 | var ( |
| 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 | |
| 22 | type roomBrowseRequest struct { |
| 23 | ID string `json:"id"` |
| 24 | Event string `json:"event"` |
| 25 | Cursor string `json:"cursor"` |
| 26 | Limit int `json:"limit"` |
| 27 | } |
| 28 | |
| 29 | type 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. |
| 38 | type roomMemberView struct { |
| 39 | community.RoomMember |
| 40 | Agent bool `json:"agent,omitempty"` |
| 41 | } |
| 42 | |
| 43 | func 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. |
| 49 | func (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 | |
| 81 | func (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 | |
| 93 | func (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 | |
| 109 | func 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 | |
| 113 | func 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 | |
| 125 | func (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 | |
| 135 | func (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. |
| 145 | func (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. |
| 174 | func (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 | |
| 225 | func 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 | } |