clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / daemon / rooms_browse.go
rooms_browse.go 8.1K
1package daemon23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "time"910 "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)1516var (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)2122type roomBrowseRequest struct {23 ID string `json:"id"`24 Event string `json:"event"`25 Cursor string `json:"cursor"`26 Limit int `json:"limit"`27}2829type roomSummary struct {30 community.Room31 Members int `json:"members"`32 LastMessageAt int64 `json:"last_message_at"`33 Role string `json:"role"`34}3536// 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.RoomMember40 Agent bool `json:"agent,omitempty"`41}4243func roomBrowseMethod(method string) bool {44 return method == "browserooms" || method == "browseroom" || method == "browsethread"45}4647// 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, err60 }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 = 10069 }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}8081func (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, err85 }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}, nil91}9293func (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{}, err97 }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{}, err102 }103 if len(latest.Events) > 0 {104 summary.LastMessageAt = latest.Events[0].CreatedAt105 }106 return summary, nil107}108109func 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}112113func 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}124125func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {126 // The MCP browser contract exposes raw room events for agents. The typed127 // 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, err131 }132 return wireRoomPage(page, false), nil133}134135func (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, err139 }140 return wireRoomPage(page, true), nil141}142143// roomMessages pages a room filter newest first through the read gate. The144// 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, "", err149 }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, "", err153 }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, nil167}168169// roomEdits returns the newest author-owned edit for each visible message.170// It deliberately queries independently of the timeline cursor: an edit is171// a replacement for its target and may have been published after the page172// containing that target was read. A thread page has at most 100 replies173// 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{}, nil177 }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 continue183 }184 validTargets = append(validTargets, target)185 if len(validTargets) == 101 {186 break187 }188 }189 if len(validTargets) == 0 {190 return []event.Event{}, nil191 }192 newest := make(map[string]event.Event, len(validTargets))193 for _, target := range validTargets {194 since := target.CreatedAt195 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.EventCursor197 for {198 edits, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)199 if err != nil {200 return nil, err201 }202 for _, edit := range edits {203 if lastRoomEditTarget(edit) == target.ID {204 newest[target.ID] = edit205 break206 }207 }208 if _, found := newest[target.ID]; found || next == "" {209 break210 }211 cursor, err = parseCollaborationCursor(next)212 if err != nil {213 return nil, err214 }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, nil223}224225func 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 target233}