tinyrelaycommit

Commit da03ad396f62

browse files at this commit | history

1diff --git a/internal/community/rooms.go b/internal/community/rooms.go
2index fc51964..5f2400b 100644
3--- a/internal/community/rooms.go
4+++ b/internal/community/rooms.go
5@@ -97,7 +97,9 @@ func RoomAdminKind(kind int) bool {
6 }
7
8 // RoomNoticeKind reports whether kind is a relay-signed member notice.
9-func RoomNoticeKind(kind int) bool { return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED }
10+func RoomNoticeKind(kind int) bool {
11+ return kind == event.KIND_ROOM_MEMBER_ADDED || kind == event.KIND_ROOM_MEMBER_REMOVED
12+}
13
14 // RoomAccessFor maps the tenant read rule onto the slug room's access.
15 func RoomAccessFor(reads string) string {
16diff --git a/internal/daemon/http.go b/internal/daemon/http.go
17index 16209c9..11e90df 100644
18--- a/internal/daemon/http.go
19+++ b/internal/daemon/http.go
20@@ -48,6 +48,10 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
21 t.router.HandleHTTP(w, r)
22 return
23 }
24+ if isRoomStreamPath(r.URL.Path) {
25+ t.roomStreamHTTP(w, r)
26+ return
27+ }
28 if r.URL.Path != "/backups/restore" && r.URL.Path != "/manage/jobs/status" {
29 ctx, done, err := t.beginOperation(r.Context())
30 if err != nil {
31diff --git a/internal/daemon/maintenance.go b/internal/daemon/maintenance.go
32index 5ba812e..e607310 100644
33--- a/internal/daemon/maintenance.go
34+++ b/internal/daemon/maintenance.go
35@@ -163,6 +163,24 @@ func (g *maintenanceGate) endMaintenance() func() {
36 }
37 }
38
39+// watch returns a channel that closes on the next gate change, so a
40+// long-lived reader can stop when maintenance or shutdown begins.
41+func (g *maintenanceGate) watch() <-chan struct{} {
42+ g.mu.Lock()
43+ defer g.mu.Unlock()
44+ if g.changed == nil {
45+ g.changed = make(chan struct{})
46+ }
47+ return g.changed
48+}
49+
50+// blocked reports whether new operations are being refused.
51+func (g *maintenanceGate) blocked() bool {
52+ g.mu.Lock()
53+ defer g.mu.Unlock()
54+ return g.maintenance || g.closing
55+}
56+
57 func (g *maintenanceGate) signalLocked() {
58 if g.changed != nil {
59 close(g.changed)
60diff --git a/internal/daemon/management.go b/internal/daemon/management.go
61index bc6f79d..c631370 100644
62--- a/internal/daemon/management.go
63+++ b/internal/daemon/management.go
64@@ -37,6 +37,10 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa
65 result, err = t.executeBrowse(ctx, actor, method, params)
66 return result, true, err
67 }
68+ if roomBrowseMethod(method) {
69+ result, err = t.executeRoomBrowse(ctx, actor, method, params)
70+ return result, true, err
71+ }
72 if publicManagementMethod(method) {
73 return t.executePublicManagement(ctx, actor, method, params)
74 }
75diff --git a/internal/daemon/rooms.go b/internal/daemon/rooms.go
76new file mode 100644
77index 0000000..556621c
78--- /dev/null
79+++ b/internal/daemon/rooms.go
80@@ -0,0 +1,64 @@
81+package daemon
82+
83+import (
84+ "context"
85+ "time"
86+
87+ "github.com/FelineStateMachine/tinyrelay/internal/community"
88+ "github.com/FelineStateMachine/tinyrelay/internal/event"
89+)
90+
91+// roomMessageKinds are the events a room timeline shows: chat, threads and
92+// their replies, reactions, Buzz rich content and edits, and the relay's
93+// member notices.
94+var roomMessageKinds = []int{event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY, 7, event.KIND_RICH_CONTENT, event.KIND_CONTENT_EDIT, event.KIND_ROOM_MEMBER_ADDED, event.KIND_ROOM_MEMBER_REMOVED}
95+
96+// projectRoom refreshes the relay's records for a room after a durable
97+// change. The slug room's 39000 to 39002 records come from the tenant
98+// projection; here it only receives member notices. A room that has been
99+// deleted since the change was queued has its records retired instead.
100+func (t *Tenant) projectRoom(ctx context.Context, roomID, pubkey string, removed, deleted bool) error {
101+ now := time.Now().Unix()
102+ room, err := t.community.Room(ctx, roomID)
103+ if err != nil {
104+ return err
105+ }
106+ if deleted || !room.Live() {
107+ return t.records.RetireRoom(ctx, roomID)
108+ }
109+ if roomID != t.meta.Name {
110+ if _, err := t.records.PublishRoom(ctx, roomID, now); err != nil {
111+ return err
112+ }
113+ }
114+ if pubkey == "" {
115+ return nil
116+ }
117+ _, err = t.records.RoomNotice(ctx, roomID, pubkey, !removed, now)
118+ return err
119+}
120+
121+// roomFor loads a live room the actor may see, or explains why not.
122+func (t *Tenant) roomFor(ctx context.Context, actor, id string) (community.Room, string, error) {
123+ if !community.ValidRoomID(id) {
124+ return community.Room{}, "", errRoomNotFound
125+ }
126+ room, err := t.community.Room(ctx, id)
127+ if err != nil {
128+ return community.Room{}, "", err
129+ }
130+ if !room.Live() {
131+ return community.Room{}, "", errRoomNotFound
132+ }
133+ role, err := t.community.RoomRole(ctx, id, actor)
134+ if err != nil {
135+ return community.Room{}, "", err
136+ }
137+ if room.Access == community.RoomMembers && role == "" {
138+ if actor == "" {
139+ return community.Room{}, "", errRoomAuth
140+ }
141+ return community.Room{}, "", errRoomRestricted
142+ }
143+ return room, role, nil
144+}
145diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go
146new file mode 100644
147index 0000000..1a057ba
148--- /dev/null
149+++ b/internal/daemon/rooms_browse.go
150@@ -0,0 +1,194 @@
151+package daemon
152+
153+import (
154+ "context"
155+ "encoding/json"
156+ "errors"
157+ "fmt"
158+ "time"
159+
160+ "github.com/FelineStateMachine/tinyrelay/internal/community"
161+ "github.com/FelineStateMachine/tinyrelay/internal/event"
162+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
163+)
164+
165+var (
166+ errRoomNotFound = errors.New("not found: room")
167+ errRoomAuth = errors.New("auth-required: this room is members-only")
168+ errRoomRestricted = errors.New("restricted: this room is members-only")
169+)
170+
171+type roomBrowseRequest struct {
172+ ID string `json:"id"`
173+ Event string `json:"event"`
174+ Cursor string `json:"cursor"`
175+ Limit int `json:"limit"`
176+}
177+
178+type roomSummary struct {
179+ community.Room
180+ Members int `json:"members"`
181+ LastMessageAt int64 `json:"last_message_at"`
182+ Role string `json:"role"`
183+}
184+
185+func roomBrowseMethod(method string) bool {
186+ return method == "browserooms" || method == "browseroom" || method == "browsethread"
187+}
188+
189+// executeRoomBrowse serves the room reads used by the web pages and agents.
190+// Access follows the tenant read rule and then the room's own access.
191+func (t *Tenant) executeRoomBrowse(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) {
192+ ctx, finish := t.app.telemetry.Start(ctx, "rooms")
193+ defer func() {
194+ if err != nil {
195+ finish("error")
196+ } else {
197+ finish("success")
198+ }
199+ }()
200+ if err := t.browseRead(ctx, actor); err != nil {
201+ return nil, err
202+ }
203+ q := roomBrowseRequest{}
204+ if len(params) > 0 {
205+ if err := json.Unmarshal(params[0], &q); err != nil {
206+ return nil, fmt.Errorf("invalid: room parameters: %w", err)
207+ }
208+ }
209+ if q.Limit <= 0 || q.Limit > 100 {
210+ q.Limit = 100
211+ }
212+ switch method {
213+ case "browserooms":
214+ return t.browseRooms(ctx, actor, q)
215+ case "browseroom":
216+ return t.browseRoom(ctx, actor, q)
217+ case "browsethread":
218+ return t.browseThread(ctx, actor, q)
219+ }
220+ return nil, errors.New("unsupported: room operation")
221+}
222+
223+func (t *Tenant) browseRooms(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
224+ rooms, err := t.community.Rooms(ctx)
225+ if err != nil {
226+ return nil, err
227+ }
228+ items := []roomSummary{}
229+ next := ""
230+ for _, room := range rooms {
231+ if room.ID <= q.Cursor {
232+ continue
233+ }
234+ _, role, err := t.roomFor(ctx, actor, room.ID)
235+ if err != nil {
236+ continue
237+ }
238+ if len(items) == q.Limit {
239+ next = items[len(items)-1].ID
240+ break
241+ }
242+ summary, err := t.roomSummary(ctx, actor, room, role)
243+ if err != nil {
244+ return nil, err
245+ }
246+ items = append(items, summary)
247+ }
248+ return map[string]any{"items": items, "next_cursor": next}, nil
249+}
250+
251+func (t *Tenant) roomSummary(ctx context.Context, actor string, room community.Room, role string) (roomSummary, error) {
252+ count, err := t.community.RoomMemberCount(ctx, room.ID)
253+ if err != nil {
254+ return roomSummary{}, err
255+ }
256+ summary := roomSummary{Room: room, Members: count, Role: role}
257+ 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})
258+ if err != nil {
259+ return roomSummary{}, err
260+ }
261+ if len(latest.Events) > 0 {
262+ summary.LastMessageAt = latest.Events[0].CreatedAt
263+ }
264+ return summary, nil
265+}
266+
267+func (t *Tenant) browseRoom(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
268+ room, role, err := t.roomFor(ctx, actor, q.ID)
269+ if err != nil {
270+ return nil, err
271+ }
272+ cursor, err := parseCollaborationCursor(q.Cursor)
273+ if err != nil {
274+ return nil, errors.New("invalid: room cursor")
275+ }
276+ summary, err := t.roomSummary(ctx, actor, room, role)
277+ if err != nil {
278+ return nil, err
279+ }
280+ members := []community.RoomMember{}
281+ if role != "" || t.Policy().DirectoryPublic {
282+ members, err = t.community.RoomMembers(ctx, room.ID)
283+ if err != nil {
284+ return nil, err
285+ }
286+ }
287+ messages, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: roomMessageKinds, Tags: map[string][]string{"h": {room.ID}}}, cursor, q.Limit)
288+ if err != nil {
289+ return nil, err
290+ }
291+ return map[string]any{"room": summary, "members": members, "messages": messages, "next_cursor": next}, nil
292+}
293+
294+func (t *Tenant) browseThread(ctx context.Context, actor string, q roomBrowseRequest) (any, error) {
295+ room, _, err := t.roomFor(ctx, actor, q.ID)
296+ if err != nil {
297+ return nil, err
298+ }
299+ if len(q.Event) != 64 || !hexLower(q.Event) {
300+ return nil, errors.New("invalid: thread root")
301+ }
302+ cursor, err := parseCollaborationCursor(q.Cursor)
303+ if err != nil {
304+ return nil, errors.New("invalid: thread cursor")
305+ }
306+ roots, err := t.Query(ctx, []event.Filter{{IDs: []string{q.Event}, Limit: intPtr(1)}}, browseSession(t, actor))
307+ if err != nil {
308+ return nil, err
309+ }
310+ if len(roots) != 1 || event.Tag(roots[0], "h") != room.ID || (roots[0].Kind != event.KIND_THREAD && roots[0].Kind != event.KIND_CHAT && roots[0].Kind != event.KIND_RICH_CONTENT) {
311+ return nil, errors.New("not found: thread")
312+ }
313+ replies, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: []int{event.KIND_THREAD_REPLY}, Tags: map[string][]string{"h": {room.ID}, "e": {q.Event}}}, cursor, q.Limit)
314+ if err != nil {
315+ return nil, err
316+ }
317+ return map[string]any{"room": room, "root": roots[0], "replies": replies, "next_cursor": next}, nil
318+}
319+
320+// roomMessages pages a room filter newest first through the read gate. The
321+// cursor is the created_at and id of the last event on the previous page.
322+func (t *Tenant) roomMessages(ctx context.Context, actor string, filter event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, string, error) {
323+ session := browseSession(t, actor)
324+ if _, err := t.gate.Read(ctx, []event.Filter{filter}, session); err != nil {
325+ return nil, "", err
326+ }
327+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})
328+ if err != nil {
329+ return nil, "", err
330+ }
331+ rows := make([]event.Event, 0, len(page.Events))
332+ for _, row := range page.Events {
333+ if t.gate.CanSee(ctx, row, session, nil) {
334+ rows = append(rows, row)
335+ }
336+ }
337+ sortEventsNewestFirst(rows)
338+ next := ""
339+ if page.More && len(page.Events) > 0 {
340+ last := page.Events[len(page.Events)-1]
341+ next = collaborationCursor(collaborationItemFrom(last))
342+ }
343+ return rows, next, nil
344+}
345diff --git a/internal/daemon/rooms_stream.go b/internal/daemon/rooms_stream.go
346new file mode 100644
347index 0000000..afe4e60
348--- /dev/null
349+++ b/internal/daemon/rooms_stream.go
350@@ -0,0 +1,151 @@
351+package daemon
352+
353+import (
354+ "encoding/json"
355+ "net/http"
356+ "net/url"
357+ "regexp"
358+ "strings"
359+ "time"
360+
361+ "github.com/FelineStateMachine/tinyrelay/internal/event"
362+)
363+
364+const (
365+ roomStreamKeepAlive = 25 * time.Second
366+ roomStreamBuffer = 64
367+)
368+
369+var roomStreamPath = regexp.MustCompile(`^/rooms/([a-z0-9_-]{1,64})/stream$`)
370+
371+func isRoomStreamPath(path string) bool { return roomStreamPath.MatchString(path) }
372+
373+// roomStreamHTTP serves GET /rooms/<id>/stream as server-sent events. Each
374+// event the viewer may see in the room arrives as "event: message" with the
375+// JSON event as data; a comment keeps the connection alive. The viewer is
376+// identified by the browser session cookie or a NIP-98 signature.
377+func (t *Tenant) roomStreamHTTP(w http.ResponseWriter, r *http.Request) {
378+ ctx, finish := t.app.telemetry.Start(r.Context(), "rooms")
379+ outcome := "error"
380+ defer func() { finish(outcome) }()
381+ if r.Method != http.MethodGet {
382+ w.Header().Set("Allow", "GET")
383+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
384+ outcome = "invalid"
385+ return
386+ }
387+ id := roomStreamPath.FindStringSubmatch(r.URL.Path)[1]
388+ if origin := r.Header.Get("Origin"); origin != "" && r.Header.Get("Authorization") == "" {
389+ base, err := url.Parse(t.requestURL(r))
390+ if err != nil || origin != base.Scheme+"://"+base.Host {
391+ http.Error(w, "auth-required: sign this request", http.StatusUnauthorized)
392+ outcome = "unauthorized"
393+ return
394+ }
395+ }
396+ actor, err := t.resolveUIActor(r)
397+ if err != nil {
398+ browseHTTPError(w, err)
399+ outcome = "unauthorized"
400+ return
401+ }
402+ // Hold an operation only while checking access. A stream may stay open
403+ // for hours and must not keep maintenance waiting; it ends instead when
404+ // maintenance or shutdown begins.
405+ opCtx, done, err := t.beginOperation(ctx)
406+ if err != nil {
407+ http.Error(w, err.Error(), http.StatusServiceUnavailable)
408+ outcome = "busy"
409+ return
410+ }
411+ err = t.browseRead(opCtx, actor)
412+ if err == nil {
413+ _, _, err = t.roomFor(opCtx, actor, id)
414+ }
415+ done()
416+ if err != nil {
417+ browseHTTPError(w, err)
418+ outcome = "unauthorized"
419+ return
420+ }
421+ flusher, ok := w.(http.Flusher)
422+ if !ok {
423+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
424+ return
425+ }
426+ session := browseSession(t, actor)
427+ events := make(chan event.Event, roomStreamBuffer)
428+ overflow := make(chan struct{}, 1)
429+ stop := t.router.Listen(func(e event.Event) {
430+ if e.Kind == event.KIND_MARMOT_GROUP || event.Tag(e, "h") != id {
431+ return
432+ }
433+ select {
434+ case events <- e:
435+ default:
436+ select {
437+ case overflow <- struct{}{}:
438+ default:
439+ }
440+ }
441+ })
442+ defer stop()
443+ w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
444+ w.Header().Set("Cache-Control", "no-store, private")
445+ w.Header().Set("X-Accel-Buffering", "no")
446+ w.WriteHeader(http.StatusOK)
447+ if _, err := w.Write([]byte(": room " + id + "\n\n")); err != nil {
448+ return
449+ }
450+ flusher.Flush()
451+ keepAlive := time.NewTicker(roomStreamKeepAlive)
452+ defer keepAlive.Stop()
453+ delivered := 0
454+ defer func() {
455+ t.app.telemetry.Logger().Debug("room stream closed", "events", delivered, "outcome", outcome)
456+ }()
457+ paused := t.maintenance.watch()
458+ for {
459+ select {
460+ case <-r.Context().Done():
461+ outcome = "closed"
462+ return
463+ case <-t.workCtx.Done():
464+ outcome = "closed"
465+ return
466+ case <-paused:
467+ if t.maintenance.blocked() {
468+ outcome = "closed"
469+ return
470+ }
471+ paused = t.maintenance.watch()
472+ case <-overflow:
473+ outcome = "busy"
474+ return
475+ case <-keepAlive.C:
476+ if _, err := w.Write([]byte(": keep-alive\n\n")); err != nil {
477+ outcome = "closed"
478+ return
479+ }
480+ flusher.Flush()
481+ case e := <-events:
482+ if !t.gate.CanSee(r.Context(), e, session, nil) {
483+ continue
484+ }
485+ raw, err := json.Marshal(e)
486+ if err != nil {
487+ continue
488+ }
489+ var frame strings.Builder
490+ frame.WriteString("event: message\ndata: ")
491+ frame.Write(raw)
492+ frame.WriteString("\n\n")
493+ if _, err := w.Write([]byte(frame.String())); err != nil {
494+ outcome = "closed"
495+ return
496+ }
497+ flusher.Flush()
498+ delivered++
499+ }
500+ }
501+}
502diff --git a/internal/daemon/services.go b/internal/daemon/services.go
503index e3df7ae..9d7af96 100644
504--- a/internal/daemon/services.go
505+++ b/internal/daemon/services.go
506@@ -18,6 +18,7 @@ import (
507
508 "github.com/FelineStateMachine/tinyrelay/internal/auth"
509 "github.com/FelineStateMachine/tinyrelay/internal/blob"
510+ "github.com/FelineStateMachine/tinyrelay/internal/community"
511 "github.com/FelineStateMachine/tinyrelay/internal/configport"
512 "github.com/FelineStateMachine/tinyrelay/internal/event"
513 "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
514@@ -475,13 +476,19 @@ func (t *Tenant) workHandlers() map[string]work.Handler {
515 },
516 "records-projection": func(ctx context.Context, intent work.Intent) error {
517 var payload struct {
518+ Action string `json:"action"`
519+ Room string `json:"room"`
520 PubKey string `json:"pubkey"`
521 Role string `json:"role"`
522 Removed int `json:"removed"`
523+ Deleted int `json:"deleted"`
524 }
525 if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {
526 return err
527 }
528+ if payload.Action == "room" {
529+ return t.projectRoom(ctx, payload.Room, payload.PubKey, payload.Removed != 0, payload.Deleted != 0)
530+ }
531 var changes []records.MembershipChange
532 if payload.PubKey != "" && (payload.Role != "" || payload.Removed != 0) {
533 added := payload.Removed == 0
534@@ -578,6 +585,11 @@ func (t *Tenant) replacePolicy(p policy.Policy) {
535 previous := t.policy
536 t.policy = p
537 t.mu.Unlock()
538+ if community.RoomAccessFor(previous.Reads) != community.RoomAccessFor(p.Reads) {
539+ if err := t.community.EnsureSlugRoom(context.Background(), t.meta.Name, community.RoomAccessFor(p.Reads)); err != nil {
540+ t.app.telemetry.Logger().Error("refresh main room", "tenant", t.meta.Name, "error", err)
541+ }
542+ }
543 if err := t.reconcileAutomaticInbox(context.Background(), previous, p); err != nil {
544 t.app.telemetry.Logger().Error("reconcile automatic inbox job", "tenant", t.meta.Name, "error", err)
545 }
546diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
547index 56e8a6c..979b8ed 100644
548--- a/internal/daemon/tenant.go
549+++ b/internal/daemon/tenant.go
550@@ -95,6 +95,9 @@ func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) {
551 if err != nil {
552 return nil, err
553 }
554+ if err := t.community.EnsureSlugRoom(ctx, t.meta.Name, community.RoomAccessFor(p.Reads)); err != nil {
555+ return nil, err
556+ }
557 t.gate, err = gates.New(gates.Config{Store: t.store, Community: t.community, Policy: t.Policy, Slug: t.meta.Name})
558 if err != nil {
559 return nil, err
560@@ -227,7 +230,7 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
561 }
562 var metadataNext policy.Policy
563 metadataChanged := false
564- if e.Kind == event.KIND_EDIT_METADATA || e.Kind == event.KIND_PINS {
565+ if (e.Kind == event.KIND_EDIT_METADATA || e.Kind == event.KIND_PINS) && t.roomScope(e) == "" {
566 role, roleErr := t.community.Role(ctx, e.PubKey)
567 if roleErr != nil {
568 return "", roleErr
569@@ -354,10 +357,15 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
570 _, saveErr := storage.SaveTx(ctx, tx, e, opts)
571 return saveErr
572 }
573- switch e.Kind {
574- case event.KIND_JOIN, event.KIND_LEAVE, event.KIND_NIP43_JOIN, event.KIND_NIP43_LEAVE:
575+ room := t.roomScope(e)
576+ switch {
577+ case room != "" && community.RoomAdminKind(e.Kind):
578+ _, err = t.community.HandleRoomEventTx(ctx, e, persist)
579+ case room != "" && !event.IsEphemeral(e.Kind):
580+ err = t.community.HandleRoomMessageTx(ctx, e, persist)
581+ case e.Kind == event.KIND_JOIN, e.Kind == event.KIND_LEAVE, e.Kind == event.KIND_NIP43_JOIN, e.Kind == event.KIND_NIP43_LEAVE:
582 _, err = t.community.HandleMembershipEventTx(ctx, e, persist)
583- case event.KIND_PUT_USER, event.KIND_REMOVE_USER, event.KIND_DELETE_EVENT, event.KIND_CREATE_INVITE:
584+ case e.Kind == event.KIND_PUT_USER, e.Kind == event.KIND_REMOVE_USER, e.Kind == event.KIND_DELETE_EVENT, e.Kind == event.KIND_CREATE_INVITE:
585 _, err = t.community.HandleModerationEventTx(ctx, e, persist)
586 default:
587 _, err = t.store.Save(ctx, e, opts)
588@@ -412,6 +420,18 @@ func (t *Tenant) agentBeforeCommit(e event.Event, now int64, before func(context
589 }
590 }
591
592+// roomScope names the room an event is addressed to when that room is not
593+// the tenant's own group, which keeps its established handling.
594+func (t *Tenant) roomScope(e event.Event) string {
595+ if e.Kind == event.KIND_MARMOT_GROUP {
596+ return ""
597+ }
598+ if id := event.Tag(e, "h"); id != "" && id != t.meta.Name {
599+ return id
600+ }
601+ return ""
602+}
603+
604 func (t *Tenant) vanish(ctx context.Context, e event.Event) error {
605 for _, tag := range e.Tags {
606 if len(tag) > 1 && tag[0] == "relay" && (tag[1] == "ALL_RELAYS" || strings.TrimSuffix(tag[1], "/") == strings.TrimSuffix(t.RelayURL(), "/")) {
607diff --git a/internal/daemon/webpush_triggers.go b/internal/daemon/webpush_triggers.go
608index 1c77cb7..cf99037 100644
609--- a/internal/daemon/webpush_triggers.go
610+++ b/internal/daemon/webpush_triggers.go
611@@ -111,6 +111,28 @@ func (t *Tenant) pushNotices(ctx context.Context, e event.Event) []pushNotice {
612 }
613 notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/e/" + e.ID})
614 }
615+ case event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY:
616+ // Room messages name the room so the summary reads as a conversation.
617+ roomID := event.Tag(e, "h")
618+ if roomID == "" || e.Kind == event.KIND_MARMOT_GROUP {
619+ return nil
620+ }
621+ room, err := t.community.Room(ctx, roomID)
622+ if err != nil || !room.Live() {
623+ return nil
624+ }
625+ name := room.Name
626+ if name == "" {
627+ name = room.ID
628+ }
629+ authored := t.referencedAuthors(ctx, e)
630+ for _, recipient := range pushRecipients(e) {
631+ category, body := pushMentions, "Mentioned you in "+name+": "
632+ if authored[recipient] {
633+ category, body = pushReplies, "Replied to you in "+name+": "
634+ }
635+ notices = append(notices, pushNotice{recipient: recipient, category: category, body: body + excerpt(e.Content), url: base + "/rooms/" + room.ID})
636+ }
637 }
638 return notices
639 }
640@@ -199,7 +221,7 @@ func (t *Tenant) inboxUnread(ctx context.Context, pubkey string) int {
641 since := t.inboxSeen(ctx, pubkey)
642 // Count conversation kinds only, so relay records addressed to the
643 // owner do not inflate the badge.
644- filter := event.Filter{Kinds: []int{1, 4, 1111, event.KIND_WRAP, event.KIND_GIT_ISSUE, event.KIND_GIT_PR, 30023}, Tags: map[string][]string{"p": {pubkey}}}
645+ filter := event.Filter{Kinds: []int{1, 4, event.KIND_CHAT, event.KIND_THREAD, event.KIND_THREAD_REPLY, 1111, event.KIND_WRAP, event.KIND_GIT_ISSUE, event.KIND_GIT_PR, 30023}, Tags: map[string][]string{"p": {pubkey}}}
646 if since > 0 {
647 filter.Since = &since
648 }
649diff --git a/internal/relay/relay.go b/internal/relay/relay.go
650index 53f66af..95e5a50 100644
651--- a/internal/relay/relay.go
652+++ b/internal/relay/relay.go
653@@ -95,18 +95,38 @@ func (c Config) withDefaults() Config {
654 }
655
656 type Router struct {
657- backend Backend
658- cfg Config
659- mu sync.RWMutex // publish is exclusive with REQ registration + query
660- clientsMu sync.Mutex
661- clients map[*client]struct{}
662- closed chan struct{}
663- closeOnce sync.Once
664- clientsWG sync.WaitGroup
665+ backend Backend
666+ cfg Config
667+ mu sync.RWMutex // publish is exclusive with REQ registration + query
668+ clientsMu sync.Mutex
669+ clients map[*client]struct{}
670+ listenersMu sync.Mutex
671+ listeners map[*listener]struct{}
672+ closed chan struct{}
673+ closeOnce sync.Once
674+ clientsWG sync.WaitGroup
675 }
676
677+type listener struct{ fn func(event.Event) }
678+
679 func New(backend Backend, cfg Config) *Router {
680- return &Router{backend: backend, cfg: cfg.withDefaults(), clients: make(map[*client]struct{}), closed: make(chan struct{})}
681+ return &Router{backend: backend, cfg: cfg.withDefaults(), clients: make(map[*client]struct{}), listeners: make(map[*listener]struct{}), closed: make(chan struct{})}
682+}
683+
684+// Listen registers an in-process observer of live fan-out. It sees every
685+// event delivered to websocket subscribers, before the reader's own access
686+// check. The callback runs under the publish fence and must not block; the
687+// returned function removes it.
688+func (r *Router) Listen(fn func(event.Event)) func() {
689+ l := &listener{fn: fn}
690+ r.listenersMu.Lock()
691+ r.listeners[l] = struct{}{}
692+ r.listenersMu.Unlock()
693+ return func() {
694+ r.listenersMu.Lock()
695+ delete(r.listeners, l)
696+ r.listenersMu.Unlock()
697+ }
698 }
699
700 func (r *Router) HandleHTTP(w http.ResponseWriter, req *http.Request) {
701@@ -689,6 +709,15 @@ func (r *Router) fanout(e event.Event) {
702 }
703 }
704 }
705+ r.listenersMu.Lock()
706+ ls := make([]*listener, 0, len(r.listeners))
707+ for l := range r.listeners {
708+ ls = append(ls, l)
709+ }
710+ r.listenersMu.Unlock()
711+ for _, l := range ls {
712+ l.fn(e)
713+ }
714 }
715
716 func (c *client) canRead(e event.Event, f *event.Filter) bool {
717@@ -885,7 +914,7 @@ func accessEvent(e event.Event) bool {
718 switch e.Kind {
719 case event.KIND_REPORT, event.KIND_VANISH, event.KIND_JOIN, event.KIND_LEAVE,
720 event.KIND_NIP43_JOIN, event.KIND_NIP43_LEAVE, event.KIND_PUT_USER,
721- event.KIND_REMOVE_USER, event.KIND_DELETE_EVENT:
722+ event.KIND_REMOVE_USER, event.KIND_DELETE_EVENT, event.KIND_DELETE_GROUP:
723 return true
724 default:
725 return false
726diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go
727index 4f14bae..85d12b1 100644
728--- a/internal/webui/webmcp.go
729+++ b/internal/webui/webmcp.go
730@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
731 return
732 }
733 }
734- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true}
735+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true, "browsewiki": true, "browsewikipage": true, "browsewikimerge": true, "browserooms": true, "browseroom": true, "browsethread": true}
736 if !allowed[method] {
737 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})
738 return

About

ownerc01503e8035f
visibilitypublic
headda03ad396f62

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs