main.go raw
1 // event-generator generates properly signed Nostr events for negentropy testing.
2 // Creates events of various kinds with realistic content for sync testing.
3 // Sends events via a single WebSocket connection using gorilla/websocket.
4 package main
5
6 import (
7 "encoding/json"
8 "flag"
9 "fmt"
10 "net/url"
11 "os"
12 "time"
13
14 "github.com/gorilla/websocket"
15
16 "next.orly.dev/pkg/nostr/encoders/event"
17 "next.orly.dev/pkg/nostr/encoders/hex"
18 "next.orly.dev/pkg/nostr/encoders/kind"
19 "next.orly.dev/pkg/nostr/encoders/tag"
20 "next.orly.dev/pkg/nostr/interfaces/signer/p8k"
21 )
22
23 // Test key pairs (deterministic for reproducible tests)
24 var testKeys = []struct {
25 Name string
26 PrivKey string
27 }{
28 {
29 Name: "alice",
30 PrivKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
31 },
32 {
33 Name: "bob",
34 PrivKey: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
35 },
36 {
37 Name: "carol",
38 PrivKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
39 },
40 }
41
42 // Pre-create signers so we don't recreate them per event
43 var signers []*p8k.Signer
44
45 func init() {
46 signers = make([]*p8k.Signer, len(testKeys))
47 for i, key := range testKeys {
48 s, err := p8k.New()
49 if err != nil {
50 fmt.Fprintf(os.Stderr, "Failed to create signer for %s: %v\n", key.Name, err)
51 os.Exit(1)
52 }
53 secretKey, err := hex.Dec(key.PrivKey)
54 if err != nil {
55 fmt.Fprintf(os.Stderr, "Failed to decode key for %s: %v\n", key.Name, err)
56 os.Exit(1)
57 }
58 if err := s.InitSec(secretKey); err != nil {
59 fmt.Fprintf(os.Stderr, "Failed to init signer for %s: %v\n", key.Name, err)
60 os.Exit(1)
61 }
62 signers[i] = s
63 }
64 }
65
66 // EventKind represents a Nostr event kind with sample content
67 type EventKind struct {
68 Kind *kind.K
69 Name string
70 Content func(author, index int) string
71 }
72
73 var eventKinds = []EventKind{
74 {
75 Kind: kind.ProfileMetadata,
76 Name: "metadata",
77 Content: func(author, index int) string {
78 metadata := map[string]string{
79 "name": fmt.Sprintf("TestUser%d_%d", author, index),
80 "about": fmt.Sprintf("Test user %d, event %d for negentropy testing", author, index),
81 "picture": fmt.Sprintf("https://example.com/avatar%d.png", index),
82 "nip05": fmt.Sprintf("user%d@example.com", index),
83 "displayName": fmt.Sprintf("Test Display %d", index),
84 }
85 b, _ := json.Marshal(metadata)
86 return string(b)
87 },
88 },
89 {
90 Kind: kind.TextNote,
91 Name: "short_text_note",
92 Content: func(author, index int) string {
93 messages := []string{
94 "Testing negentropy sync between relays!",
95 "This is event number %d in the test suite.",
96 "Nostr protocol testing for relay synchronization.",
97 "Event %d: checking if sync works correctly.",
98 "Negentropy is an efficient set reconciliation protocol.",
99 "Testing with kind 1 text notes.",
100 "Relay sync test message %d.",
101 "Making sure events propagate correctly between relays.",
102 "Test event for bidirectional sync testing.",
103 "NIP-77 negentropy implementation test.",
104 }
105 msg := messages[index%len(messages)]
106 if index%2 == 0 {
107 return fmt.Sprintf(msg, index)
108 }
109 return msg
110 },
111 },
112 {
113 Kind: kind.FollowList,
114 Name: "contacts",
115 Content: func(author, index int) string {
116 return fmt.Sprintf("Contact list update %d for test user %d", index, author)
117 },
118 },
119 {
120 Kind: kind.Reporting,
121 Name: "report",
122 Content: func(author, index int) string {
123 return fmt.Sprintf("Report content %d: testing moderation event sync", index)
124 },
125 },
126 {
127 Kind: kind.MuteList,
128 Name: "mute_list",
129 Content: func(author, index int) string {
130 return fmt.Sprintf("Mute list update %d", index)
131 },
132 },
133 {
134 Kind: kind.PinList,
135 Name: "pin_list",
136 Content: func(author, index int) string {
137 return fmt.Sprintf("Pinned events list %d", index)
138 },
139 },
140 {
141 Kind: kind.LongFormContent,
142 Name: "long_form",
143 Content: func(author, index int) string {
144 return fmt.Sprintf("# Long Form Article %d\n\nThis is a test long-form article for kind 30023. Testing negentropy sync with larger content payloads. Article number %d written by test author %d.", index, index, author)
145 },
146 },
147 {
148 Kind: kind.ApplicationSpecificData,
149 Name: "application_specific",
150 Content: func(author, index int) string {
151 appData := map[string]interface{}{
152 "app": "test-suite",
153 "version": "1.0.0",
154 "test_id": index,
155 "data": map[string]string{
156 "key1": fmt.Sprintf("value%d", index),
157 "key2": fmt.Sprintf("data%d", index*2),
158 },
159 }
160 b, _ := json.Marshal(appData)
161 return string(b)
162 },
163 },
164 }
165
166 type Config struct {
167 Count int
168 OutputFile string
169 RelayURL string
170 BatchSize int
171 }
172
173 func main() {
174 var cfg Config
175 flag.IntVar(&cfg.Count, "count", 1000, "Number of events to generate")
176 flag.StringVar(&cfg.OutputFile, "output", "", "Output file (JSON array)")
177 flag.StringVar(&cfg.RelayURL, "relay", "", "Send directly to relay WebSocket URL")
178 flag.IntVar(&cfg.BatchSize, "batch", 100, "Batch size for sending")
179 flag.Parse()
180
181 // Generate events
182 fmt.Fprintf(os.Stderr, "Generating %d events...\n", cfg.Count)
183 events := generateEvents(cfg.Count)
184
185 // Handle output
186 if cfg.RelayURL != "" {
187 if err := sendToRelay(events, cfg.RelayURL, cfg.BatchSize); err != nil {
188 fmt.Fprintf(os.Stderr, "Error sending to relay: %v\n", err)
189 os.Exit(1)
190 }
191 fmt.Fprintf(os.Stderr, "Sent %d events to %s\n", len(events), cfg.RelayURL)
192 } else if cfg.OutputFile != "" {
193 if err := writeToFile(events, cfg.OutputFile); err != nil {
194 fmt.Fprintf(os.Stderr, "Error writing to file: %v\n", err)
195 os.Exit(1)
196 }
197 fmt.Fprintf(os.Stderr, "Wrote %d events to %s\n", len(events), cfg.OutputFile)
198 } else {
199 // Print to stdout as JSON array
200 output := map[string]interface{}{
201 "events": events,
202 "count": len(events),
203 }
204 jsonBytes, _ := json.MarshalIndent(output, "", " ")
205 fmt.Println(string(jsonBytes))
206 }
207 }
208
209 func generateEvents(count int) []*event.E {
210 events := make([]*event.E, 0, count)
211 baseTime := time.Now().Add(-24 * time.Hour)
212
213 for i := 0; i < count; i++ {
214 authorIdx := i % len(testKeys)
215
216 kindIdx := getWeightedKindIndex(i)
217 kindDef := eventKinds[kindIdx]
218
219 createdAt := baseTime.Add(time.Duration(i) * time.Second).Unix()
220
221 ev, err := createEvent(authorIdx, kindDef.Kind, kindDef.Content(authorIdx, i), createdAt, i)
222 if err != nil {
223 fmt.Fprintf(os.Stderr, "Failed to create event %d: %v\n", i, err)
224 continue
225 }
226 events = append(events, ev)
227 }
228
229 return events
230 }
231
232 // kindPattern distributes event kinds in a repeating 20-event pattern.
233 // This ensures variety even for small event counts while maintaining
234 // approximate target proportions over larger samples.
235 //
236 // metadata (kind 0): 2/20 = 10%
237 // text notes (kind 1): 12/20 = 60%
238 // contacts (kind 3): 2/20 = 10%
239 // reporting (kind 1984): 1/20 = 5%
240 // mute list (kind 10000): 1/20 = 5%
241 // pin list (kind 10001): 1/20 = 5%
242 // long form (kind 30023): 1/20 = 5%
243 var kindPattern = []int{
244 1, 0, 1, 2, 1, 1, 3, 1, 1, 4,
245 1, 5, 1, 6, 1, 1, 0, 1, 2, 1,
246 }
247
248 func getWeightedKindIndex(seed int) int {
249 return kindPattern[seed%len(kindPattern)]
250 }
251
252 func createEvent(authorIdx int, kindDef *kind.K, content string, createdAt int64, index int) (*event.E, error) {
253 ev := event.New()
254 ev.CreatedAt = createdAt
255 ev.Kind = kindDef.K
256 ev.Content = []byte(content)
257 ev.Tags = tag.NewS()
258
259 signer := signers[authorIdx]
260
261 // Add tags based on kind
262 switch kindDef.K {
263 case kind.FollowList.K:
264 // Add p-tags with hex pubkeys of other test users
265 for j := 0; j < 3; j++ {
266 targetIdx := (index + j + 1) % len(testKeys)
267 targetPub := signers[targetIdx].Pub()
268 targetHex := hex.Enc(targetPub)
269 ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex)))
270 }
271
272 case kind.MuteList.K, kind.PinList.K:
273 // Replaceable list events need a d-tag
274 ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte("")))
275
276 case kind.LongFormContent.K:
277 // Addressable events MUST have a d-tag
278 ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("article-%d", index))))
279 ev.Tags.Append(tag.NewFromBytesSlice([]byte("title"), []byte(fmt.Sprintf("Article %d", index))))
280 ev.Tags.Append(tag.NewFromBytesSlice([]byte("published_at"), []byte(fmt.Sprintf("%d", createdAt))))
281
282 case kind.ApplicationSpecificData.K:
283 // Addressable events MUST have a d-tag
284 ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("test-data-%d", index))))
285
286 case kind.Reporting.K:
287 targetIdx := (index + 1) % len(testKeys)
288 targetPub := signers[targetIdx].Pub()
289 targetHex := hex.Enc(targetPub)
290 ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex), []byte("other"), []byte("spam")))
291 }
292
293 if err := ev.Sign(signer); err != nil {
294 return nil, fmt.Errorf("failed to sign event: %w", err)
295 }
296
297 return ev, nil
298 }
299
300 // sendToRelay sends events to a relay via a single WebSocket connection.
301 func sendToRelay(events []*event.E, relayURL string, batchSize int) error {
302 u, err := url.Parse(relayURL)
303 if err != nil {
304 return fmt.Errorf("invalid relay URL: %w", err)
305 }
306
307 fmt.Fprintf(os.Stderr, "Connecting to %s...\n", u.String())
308
309 dialer := websocket.Dialer{
310 HandshakeTimeout: 10 * time.Second,
311 }
312 conn, _, err := dialer.Dial(u.String(), nil)
313 if err != nil {
314 return fmt.Errorf("failed to connect to relay: %w", err)
315 }
316 defer conn.Close()
317
318 sent := 0
319 rejected := 0
320
321 for i, ev := range events {
322 eventJSON, err := ev.MarshalJSON()
323 if err != nil {
324 fmt.Fprintf(os.Stderr, "Warning: failed to marshal event %d: %v\n", i, err)
325 continue
326 }
327
328 msg := fmt.Sprintf(`["EVENT",%s]`, string(eventJSON))
329 if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
330 return fmt.Errorf("failed to send event %d: %w", i, err)
331 }
332
333 // Read the OK response
334 conn.SetReadDeadline(time.Now().Add(5 * time.Second))
335 _, response, err := conn.ReadMessage()
336 if err != nil {
337 fmt.Fprintf(os.Stderr, "Warning: no response for event %d: %v\n", i, err)
338 } else {
339 // Check if the response indicates success
340 respStr := string(response)
341 if len(respStr) > 10 {
342 // Parse ["OK","id",true/false,"message"]
343 var okResp []interface{}
344 if json.Unmarshal(response, &okResp) == nil && len(okResp) >= 3 {
345 if accepted, ok := okResp[2].(bool); ok && accepted {
346 sent++
347 } else {
348 rejected++
349 if rejected <= 5 {
350 fmt.Fprintf(os.Stderr, "Rejected: %s\n", respStr)
351 }
352 }
353 }
354 }
355 }
356
357 // Log progress periodically
358 if (i+1)%batchSize == 0 || i == len(events)-1 {
359 fmt.Fprintf(os.Stderr, "Progress: %d/%d sent, %d rejected\n", sent, i+1, rejected)
360 }
361 }
362
363 fmt.Fprintf(os.Stderr, "Total: %d sent, %d rejected out of %d\n", sent, rejected, len(events))
364 return nil
365 }
366
367 func writeToFile(events []*event.E, filename string) error {
368 f, err := os.Create(filename)
369 if err != nil {
370 return err
371 }
372 defer f.Close()
373
374 output := map[string]interface{}{
375 "events": events,
376 "count": len(events),
377 }
378
379 encoder := json.NewEncoder(f)
380 encoder.SetIndent("", " ")
381 return encoder.Encode(output)
382 }
383