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