| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/FelineStateMachine/tinyrelay/internal/community" |
| 9 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 10 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 11 | webui "github.com/FelineStateMachine/tinyrelay/tinyclient" |
| 12 | ) |
| 13 | |
| 14 | var _ webui.RoomsReader = (*Tenant)(nil) |
| 15 | var _ webui.RoomsReader = backend{} |
| 16 | |
| 17 | func (b backend) ListRooms(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) { |
| 18 | return b.tenant.ListRooms(ctx, actor, cursor, limit) |
| 19 | } |
| 20 | |
| 21 | func (b backend) ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) { |
| 22 | return b.tenant.ReadRoom(ctx, actor, roomID, cursor, limit) |
| 23 | } |
| 24 | |
| 25 | func (b backend) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) { |
| 26 | return b.tenant.ReadThread(ctx, actor, roomID, rootID, cursor, limit) |
| 27 | } |
| 28 | |
| 29 | func (t *Tenant) ListRooms(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) { |
| 30 | ctx, done, err := t.beginOperation(ctx) |
| 31 | if err != nil { |
| 32 | return webui.RoomList{}, err |
| 33 | } |
| 34 | defer done() |
| 35 | return t.listRoomsCore(ctx, actor, cursor, limit) |
| 36 | } |
| 37 | |
| 38 | func (t *Tenant) listRoomsCore(ctx context.Context, actor, cursor string, limit int) (webui.RoomList, error) { |
| 39 | if err := t.browseRead(ctx, actor); err != nil { |
| 40 | return webui.RoomList{}, err |
| 41 | } |
| 42 | if limit <= 0 || limit > 100 { |
| 43 | limit = 100 |
| 44 | } |
| 45 | rooms, err := t.community.Rooms(ctx) |
| 46 | if err != nil { |
| 47 | return webui.RoomList{}, err |
| 48 | } |
| 49 | result := webui.RoomList{} |
| 50 | for _, room := range rooms { |
| 51 | if room.ID <= cursor { |
| 52 | continue |
| 53 | } |
| 54 | _, role, roleErr := t.roomFor(ctx, actor, room.ID) |
| 55 | if roleErr != nil { |
| 56 | continue |
| 57 | } |
| 58 | if len(result.Rooms) == limit { |
| 59 | result.NextCursor = result.Rooms[len(result.Rooms)-1].ID |
| 60 | break |
| 61 | } |
| 62 | summary, summaryErr := t.roomSummary(ctx, actor, room, role) |
| 63 | if summaryErr != nil { |
| 64 | return webui.RoomList{}, summaryErr |
| 65 | } |
| 66 | result.Rooms = append(result.Rooms, webuiRoomSummary(summary)) |
| 67 | } |
| 68 | return result, nil |
| 69 | } |
| 70 | |
| 71 | func (t *Tenant) ReadRoom(ctx context.Context, actor, roomID, cursor string, limit int) (webui.RoomPage, error) { |
| 72 | ctx, done, err := t.beginOperation(ctx) |
| 73 | if err != nil { |
| 74 | return webui.RoomPage{}, err |
| 75 | } |
| 76 | defer done() |
| 77 | return t.readRoomCore(ctx, actor, roomID, cursor, limit, true) |
| 78 | } |
| 79 | |
| 80 | func (t *Tenant) readRoomCore(ctx context.Context, actor, roomID, cursor string, limit int, foldReplies bool) (webui.RoomPage, error) { |
| 81 | if err := t.browseRead(ctx, actor); err != nil { |
| 82 | return webui.RoomPage{}, err |
| 83 | } |
| 84 | room, role, err := t.roomFor(ctx, actor, roomID) |
| 85 | if err != nil { |
| 86 | return webui.RoomPage{}, err |
| 87 | } |
| 88 | position, err := parseCollaborationCursor(cursor) |
| 89 | if err != nil { |
| 90 | return webui.RoomPage{}, errors.New("invalid: room cursor" ) |
| 91 | } |
| 92 | summary, err := t.roomSummary(ctx, actor, room, role) |
| 93 | if err != nil { |
| 94 | return webui.RoomPage{}, err |
| 95 | } |
| 96 | members, err := t.webuiRoomMembers(ctx, room.ID, role) |
| 97 | if err != nil { |
| 98 | return webui.RoomPage{}, err |
| 99 | } |
| 100 | readMessages := t.roomMessages |
| 101 | if foldReplies { |
| 102 | readMessages = t.roomTimelineMessages |
| 103 | } |
| 104 | messageFilter := event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h" : {room.ID}}} |
| 105 | messages, next, err := readMessages(ctx, actor, messageFilter, position, normalizeRoomLimit(limit)) |
| 106 | if err != nil { |
| 107 | return webui.RoomPage{}, err |
| 108 | } |
| 109 | edits, err := t.roomEdits(ctx, actor, room.ID, messages) |
| 110 | if err != nil { |
| 111 | return webui.RoomPage{}, err |
| 112 | } |
| 113 | page := webui.RoomPage{Room: webuiRoomSummary(summary), Members: members, Messages: messages, Edits: edits, NextCursor: next} |
| 114 | if foldReplies { |
| 115 | page.ReplyCounts, page.ReactionCounts, err = t.roomInteractionSummaries(ctx, actor, room.ID, messages) |
| 116 | if err != nil { |
| 117 | return webui.RoomPage{}, err |
| 118 | } |
| 119 | } |
| 120 | return page, nil |
| 121 | } |
| 122 | |
| 123 | // roomTimelineMessages pages by visible top-level messages rather than raw |
| 124 | // events. Folded replies and reactions are summarized separately so a burst |
| 125 | // of replies cannot make the page empty or consume unbounded response memory. |
| 126 | func (t *Tenant) roomTimelineMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) { |
| 127 | if limit <= 0 { |
| 128 | limit = 100 |
| 129 | } |
| 130 | position := cursor |
| 131 | rows := make([]event.Event, 0, limit) |
| 132 | for { |
| 133 | page, next, err := t.roomMessages(ctx, actor, filter, position, 100) |
| 134 | if err != nil { |
| 135 | return nil, "" , err |
| 136 | } |
| 137 | for i, row := range page { |
| 138 | if roomTimelineTopLevel(row) || (row.Kind == event.KIND_ROOM_MEMBER_ADDED || row.Kind == event.KIND_ROOM_MEMBER_REMOVED) && event.Tag(row, "p" ) != "" { |
| 139 | rows = append(rows, row) |
| 140 | } |
| 141 | if len(rows) < limit { |
| 142 | continue |
| 143 | } |
| 144 | if next == "" && i == len(page)-1 { |
| 145 | return rows, "" , nil |
| 146 | } |
| 147 | return rows, collaborationCursor(collaborationItemFrom(row)), nil |
| 148 | } |
| 149 | if next == "" { |
| 150 | return rows, "" , nil |
| 151 | } |
| 152 | position, err = parseCollaborationCursor(next) |
| 153 | if err != nil { |
| 154 | return nil, "" , err |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func (t *Tenant) roomInteractionSummaries(ctx context.Context, actor, roomID string, roots []event.Event) (map[string]int, map[string]map[string]int, error) { |
| 160 | counts := make(map[string]int) |
| 161 | reactions := make(map[string]map[string]int) |
| 162 | for _, root := range roots { |
| 163 | if !roomTimelineTopLevel(root) { |
| 164 | continue |
| 165 | } |
| 166 | replies, err := t.roomThreadReplies(ctx, actor, roomID, root.ID) |
| 167 | if errors.Is(err, errRoomThreadSizeLimit) || errors.Is(err, errRoomThreadDepthLimit) { |
| 168 | // A bounded thread view must not prevent reading the rest of the |
| 169 | // room. Omit its count instead of presenting a partial total. |
| 170 | continue |
| 171 | } |
| 172 | if err != nil { |
| 173 | return nil, nil, err |
| 174 | } |
| 175 | counts[root.ID] = len(replies) |
| 176 | } |
| 177 | ids := make([]string, 0, len(roots)) |
| 178 | selected := make(map[string]bool, len(roots)) |
| 179 | for _, root := range roots { |
| 180 | if roomTimelineTopLevel(root) { |
| 181 | ids = append(ids, root.ID) |
| 182 | selected[root.ID] = true |
| 183 | } |
| 184 | } |
| 185 | if len(ids) == 0 { |
| 186 | return counts, reactions, nil |
| 187 | } |
| 188 | filter := event.Filter{Kinds: []int{7}, Tags: map[string][]string{"h" : {roomID}, "e" : ids}} |
| 189 | var cursor *storage.EventCursor |
| 190 | for { |
| 191 | rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100) |
| 192 | if err != nil { |
| 193 | return nil, nil, err |
| 194 | } |
| 195 | for _, row := range rows { |
| 196 | targets := event.TagValues(row, "e" ) |
| 197 | if len(targets) == 0 { |
| 198 | continue |
| 199 | } |
| 200 | target := targets[len(targets)-1] |
| 201 | if !selected[target] { |
| 202 | continue |
| 203 | } |
| 204 | content := strings.TrimSpace(row.Content) |
| 205 | if content == "" || content == "+" { |
| 206 | content = "+1" |
| 207 | } |
| 208 | if reactions[target] == nil { |
| 209 | reactions[target] = make(map[string]int) |
| 210 | } |
| 211 | reactions[target][content]++ |
| 212 | } |
| 213 | if next == "" { |
| 214 | return counts, reactions, nil |
| 215 | } |
| 216 | cursor, err = parseCollaborationCursor(next) |
| 217 | if err != nil { |
| 218 | return nil, nil, err |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func roomTimelineTopLevel(row event.Event) bool { |
| 224 | switch row.Kind { |
| 225 | case event.KIND_CHAT, event.KIND_THREAD, event.KIND_RICH_CONTENT: |
| 226 | return event.RoomReplyRoot(row) == "" |
| 227 | default: |
| 228 | return false |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func (t *Tenant) ReadThread(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) { |
| 233 | ctx, done, err := t.beginOperation(ctx) |
| 234 | if err != nil { |
| 235 | return webui.RoomPage{}, err |
| 236 | } |
| 237 | defer done() |
| 238 | return t.readThreadCore(ctx, actor, roomID, rootID, cursor, limit) |
| 239 | } |
| 240 | |
| 241 | func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, cursor string, limit int) (webui.RoomPage, error) { |
| 242 | if err := t.browseRead(ctx, actor); err != nil { |
| 243 | return webui.RoomPage{}, err |
| 244 | } |
| 245 | room, role, err := t.roomFor(ctx, actor, roomID) |
| 246 | if err != nil { |
| 247 | return webui.RoomPage{}, err |
| 248 | } |
| 249 | if len(rootID) != 64 || !hexLower(rootID) { |
| 250 | return webui.RoomPage{}, errors.New("invalid: thread root" ) |
| 251 | } |
| 252 | position, err := parseCollaborationCursor(cursor) |
| 253 | if err != nil { |
| 254 | return webui.RoomPage{}, errors.New("invalid: thread cursor" ) |
| 255 | } |
| 256 | root, err := t.roomThreadRoot(ctx, actor, room.ID, rootID) |
| 257 | if err != nil { |
| 258 | return webui.RoomPage{}, err |
| 259 | } |
| 260 | all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID) |
| 261 | if err != nil { |
| 262 | return webui.RoomPage{}, err |
| 263 | } |
| 264 | replies, next := roomThreadPage(all, position, normalizeRoomLimit(limit)) |
| 265 | targets := append([]event.Event{root}, replies...) |
| 266 | edits, err := t.roomEdits(ctx, actor, room.ID, targets) |
| 267 | if err != nil { |
| 268 | return webui.RoomPage{}, err |
| 269 | } |
| 270 | members, err := t.webuiRoomMembers(ctx, room.ID, role) |
| 271 | if err != nil { |
| 272 | return webui.RoomPage{}, err |
| 273 | } |
| 274 | return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, ReplyCounts: map[string]int{root.ID: len(all)}, Edits: edits, NextCursor: next}, nil |
| 275 | } |
| 276 | |
| 277 | func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) { |
| 278 | if role == "" && !t.Policy().DirectoryPublic { |
| 279 | return nil, nil |
| 280 | } |
| 281 | rows, err := t.community.RoomMembers(ctx, roomID) |
| 282 | if err != nil { |
| 283 | return nil, err |
| 284 | } |
| 285 | members := make([]webui.RoomMember, 0, len(rows)) |
| 286 | for _, row := range rows { |
| 287 | tenantRole, err := t.community.Role(ctx, row.PubKey) |
| 288 | if err != nil { |
| 289 | return nil, err |
| 290 | } |
| 291 | member := webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent" } |
| 292 | if member.Agent { |
| 293 | grant, found, grantErr := t.community.AgentGrant(ctx, row.PubKey) |
| 294 | if grantErr != nil { |
| 295 | return nil, grantErr |
| 296 | } |
| 297 | if found { |
| 298 | member.Operator = grant.Owner |
| 299 | } |
| 300 | } |
| 301 | members = append(members, member) |
| 302 | } |
| 303 | return members, nil |
| 304 | } |
| 305 | |
| 306 | func webuiRoomSummary(summary roomSummary) webui.RoomSummary { |
| 307 | room := webuiRoomSummaryFromRoom(summary.Room) |
| 308 | room.Members, room.LastMessageAt, room.Role = summary.Members, summary.LastMessageAt, summary.Role |
| 309 | return room |
| 310 | } |
| 311 | |
| 312 | func webuiRoomSummaryFromRoom(room community.Room) webui.RoomSummary { |
| 313 | return webui.RoomSummary{ID: room.ID, Name: room.Name, About: room.About, Picture: room.Picture, Access: room.Access, CreatedBy: room.CreatedBy, CreatedAt: room.CreatedAt, EventID: room.EventID, DeletedAt: room.DeletedAt} |
| 314 | } |
| 315 | |
| 316 | func normalizeRoomLimit(limit int) int { |
| 317 | if limit <= 0 || limit > 100 { |
| 318 | return 100 |
| 319 | } |
| 320 | return limit |
| 321 | } |