| 1 | package 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 | |
| 8 | import ( |
| 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 | |
| 22 | const ( |
| 23 | notificationBroadcast = "notification-broadcast" |
| 24 | notificationDelivery = "notification-delivery" |
| 25 | notificationPush = "notification-push" |
| 26 | ) |
| 27 | |
| 28 | type 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. |
| 35 | func (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. |
| 59 | func (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 | |
| 74 | func (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 | |
| 85 | func (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 | |
| 98 | func (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 | |
| 145 | func (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 | |
| 195 | func validRelayURL(raw string) bool { |
| 196 | return strings.HasPrefix(raw, "ws://" ) || strings.HasPrefix(raw, "wss://" ) |
| 197 | } |
| 198 | |
| 199 | func sameRelay(a, b string) bool { return strings.TrimRight(a, "/" ) == strings.TrimRight(b, "/" ) } |