tinyrelayfile
tinyrelay/internal/daemon/notifications.go/

internal/daemon/notifications.go raw
1package daemon
2
3// Notification delivery is deliberately separate from ordinary replication.
4// A relay-generated gift wrap is private data: it is stored locally for audit,
5// broadcast to current local subscribers through a fenced work item, and sent
6// only to the recipient's NIP-17 inbox relay list (kind 10050).
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "strings"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/event"
17 "github.com/FelineStateMachine/tinyrelay/internal/replication"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19 "github.com/FelineStateMachine/tinyrelay/internal/work"
20)
21
22const (
23 notificationBroadcast = "notification-broadcast"
24 notificationDelivery = "notification-delivery"
25 notificationPush = "notification-push"
26)
27
28type notificationPayload struct {
29 Recipient string `json:"recipient"`
30}
31
32// deliverNotification is suitable for records.Config.DeliverNotification.
33// Save persists the wrap and both fenced intents in one transaction. It does
34// not synchronously fan out while the relay publish fence is held.
35func (t *Tenant) deliverNotification(ctx context.Context, wrap event.Event, recipient string) error {
36 if wrap.Kind != event.KIND_WRAP || recipient == "" || event.Tag(wrap, "p") != recipient {
37 return errors.New("notification: invalid recipient or gift wrap")
38 }
39 payload, err := json.Marshal(notificationPayload{Recipient: recipient})
40 if err != nil {
41 return err
42 }
43 now := time.Now().Unix()
44 _, err = t.store.Save(ctx, wrap, storage.SaveOptions{
45 Now: now,
46 SearchMode: "off",
47 Intents: []storage.Intent{
48 {Kind: notificationBroadcast, EventID: wrap.ID, Target: t.meta.ID, Payload: string(payload)},
49 {Kind: notificationDelivery, EventID: wrap.ID, Target: recipient, Payload: string(payload)},
50 },
51 })
52 return err
53}
54
55// notificationHandlers returns handlers to merge into the tenant worker's
56// ordinary handler map. The broadcast handler is local-only; the delivery
57// handler uses the recipient's kind-10050 relay list and never ordinary NIP-65
58// read/write discovery or the tenant's replication outbox.
59func (t *Tenant) notificationHandlers() map[string]work.Handler {
60 transport := &replication.NostrTransport{Dialer: replication.WebsocketDialer{AllowPrivate: t.app != nil && t.app.cfg.AllowPrivateRelays, MaxMessageBytes: func() int64 {
61 if t.app != nil {
62 return t.app.cfg.MaxMessageBytes
63 }
64 return 0
65 }()}}
66 return map[string]work.Handler{
67 notificationBroadcast: t.handleNotificationBroadcast,
68 notificationDelivery: func(ctx context.Context, intent work.Intent) error {
69 return t.handleNotificationDelivery(ctx, intent, transport)
70 },
71 }
72}
73
74func (t *Tenant) loadNotification(ctx context.Context, id string) (event.Event, error) {
75 result, err := t.store.Query(ctx, event.Filter{IDs: []string{id}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
76 if err != nil {
77 return event.Event{}, err
78 }
79 if len(result.Events) == 0 {
80 return event.Event{}, nil
81 }
82 return result.Events[0], nil
83}
84
85func (t *Tenant) handleNotificationBroadcast(ctx context.Context, intent work.Intent) error {
86 e, err := t.loadNotification(ctx, intent.EventID)
87 if err != nil || e.ID == "" {
88 return err
89 }
90 if e.Kind != event.KIND_WRAP || event.Tag(e, "p") == "" {
91 return errors.New("notification: stored event is not a recipient gift wrap")
92 }
93 // BroadcastGenerated is already outside Router.Publish's write fence.
94 t.router.BroadcastGenerated(e)
95 return nil
96}
97
98func (t *Tenant) handleNotificationDelivery(ctx context.Context, intent work.Intent, transport *replication.NostrTransport) error {
99 var payload notificationPayload
100 if intent.Payload != "" {
101 if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {
102 return fmt.Errorf("notification: payload: %w", err)
103 }
104 }
105 recipient := intent.Target
106 if payload.Recipient != "" && payload.Recipient != recipient {
107 return errors.New("notification: recipient target mismatch")
108 }
109 e, err := t.loadNotification(ctx, intent.EventID)
110 if err != nil {
111 return err
112 }
113 if e.ID == "" {
114 return nil
115 }
116 if e.Kind != event.KIND_WRAP || event.Tag(e, "p") != recipient {
117 return errors.New("notification: recipient check failed")
118 }
119 targets, lookupErr := t.notificationInboxRelays(ctx, recipient)
120 if lookupErr != nil {
121 return lookupErr
122 }
123 if len(targets) == 0 {
124 return errors.New("notification: recipient has no kind-10050 inbox relays")
125 }
126 for _, target := range targets {
127 if sameRelay(target, t.RelayURL()) {
128 continue
129 }
130 result, sendErr := transport.Send(ctx, target, e)
131 if sendErr != nil {
132 return sendErr
133 }
134 if result.Accepted || strings.HasPrefix(result.Message, "duplicate:") {
135 continue
136 }
137 if result.Message == "" {
138 result.Message = "recipient relay rejected notification"
139 }
140 return errors.New(result.Message)
141 }
142 return nil
143}
144
145func (t *Tenant) notificationInboxRelays(ctx context.Context, recipient string) ([]string, error) {
146 result, err := t.store.Query(ctx, event.Filter{Kinds: []int{10050}, Authors: []string{recipient}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1})
147 if err != nil {
148 return nil, err
149 }
150 if len(result.Events) == 0 && t.app != nil {
151 dialer := replication.WebsocketDialer{AllowPrivate: t.app.cfg.AllowPrivateRelays, MaxMessageBytes: t.app.cfg.MaxMessageBytes}
152 transport := &replication.NostrTransport{Dialer: dialer}
153 var newest event.Event
154 var queryErr error
155 limit := 20
156 for _, seed := range t.app.cfg.DiscoveryRelays {
157 if !validRelayURL(seed) || sameRelay(seed, t.RelayURL()) {
158 continue
159 }
160 events, qerr := transport.Query(ctx, seed, event.Filter{Kinds: []int{10050}, Authors: []string{recipient}, Tags: map[string][]string{}, Limit: &limit})
161 if qerr != nil {
162 queryErr = qerr
163 continue
164 }
165 for _, candidate := range events {
166 if candidate.Kind == 10050 && candidate.PubKey == recipient && (newest.ID == "" || candidate.CreatedAt > newest.CreatedAt) {
167 newest = candidate
168 }
169 }
170 }
171 if newest.ID != "" {
172 if _, saveErr := t.store.Save(ctx, newest, storage.SaveOptions{Now: time.Now().Unix(), SearchMode: "off"}); saveErr != nil && !errors.Is(saveErr, storage.ErrDuplicate) {
173 return nil, saveErr
174 }
175 result.Events = []event.Event{newest}
176 } else if queryErr != nil {
177 return nil, fmt.Errorf("notification: inbox discovery: %w", queryErr)
178 }
179 }
180 if len(result.Events) == 0 {
181 return nil, nil
182 }
183 seen := make(map[string]bool)
184 var out []string
185 for _, tag := range result.Events[0].Tags {
186 if len(tag) < 2 || tag[0] != "relay" || !validRelayURL(tag[1]) || seen[tag[1]] {
187 continue
188 }
189 seen[tag[1]] = true
190 out = append(out, tag[1])
191 }
192 return out, nil
193}
194
195func validRelayURL(raw string) bool {
196 return strings.HasPrefix(raw, "ws://") || strings.HasPrefix(raw, "wss://")
197}
198
199func sameRelay(a, b string) bool { return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") }

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs