You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

382 lines
11 KiB

// event-generator generates properly signed Nostr events for negentropy testing.
// Creates events of various kinds with realistic content for sync testing.
// Sends events via a single WebSocket connection using gorilla/websocket.
package main
import (
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"time"
"github.com/gorilla/websocket"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/kind"
"git.mleku.dev/mleku/nostr/encoders/tag"
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
)
// Test key pairs (deterministic for reproducible tests)
var testKeys = []struct {
Name string
PrivKey string
}{
{
Name: "alice",
PrivKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
Name: "bob",
PrivKey: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
},
{
Name: "carol",
PrivKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
},
}
// Pre-create signers so we don't recreate them per event
var signers []*p8k.Signer
func init() {
signers = make([]*p8k.Signer, len(testKeys))
for i, key := range testKeys {
s, err := p8k.New()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create signer for %s: %v\n", key.Name, err)
os.Exit(1)
}
secretKey, err := hex.Dec(key.PrivKey)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to decode key for %s: %v\n", key.Name, err)
os.Exit(1)
}
if err := s.InitSec(secretKey); err != nil {
fmt.Fprintf(os.Stderr, "Failed to init signer for %s: %v\n", key.Name, err)
os.Exit(1)
}
signers[i] = s
}
}
// EventKind represents a Nostr event kind with sample content
type EventKind struct {
Kind *kind.K
Name string
Content func(author, index int) string
}
var eventKinds = []EventKind{
{
Kind: kind.ProfileMetadata,
Name: "metadata",
Content: func(author, index int) string {
metadata := map[string]string{
"name": fmt.Sprintf("TestUser%d_%d", author, index),
"about": fmt.Sprintf("Test user %d, event %d for negentropy testing", author, index),
"picture": fmt.Sprintf("https://example.com/avatar%d.png", index),
"nip05": fmt.Sprintf("user%d@example.com", index),
"displayName": fmt.Sprintf("Test Display %d", index),
}
b, _ := json.Marshal(metadata)
return string(b)
},
},
{
Kind: kind.TextNote,
Name: "short_text_note",
Content: func(author, index int) string {
messages := []string{
"Testing negentropy sync between relays!",
"This is event number %d in the test suite.",
"Nostr protocol testing for relay synchronization.",
"Event %d: checking if sync works correctly.",
"Negentropy is an efficient set reconciliation protocol.",
"Testing with kind 1 text notes.",
"Relay sync test message %d.",
"Making sure events propagate correctly between relays.",
"Test event for bidirectional sync testing.",
"NIP-77 negentropy implementation test.",
}
msg := messages[index%len(messages)]
if index%2 == 0 {
return fmt.Sprintf(msg, index)
}
return msg
},
},
{
Kind: kind.FollowList,
Name: "contacts",
Content: func(author, index int) string {
return fmt.Sprintf("Contact list update %d for test user %d", index, author)
},
},
{
Kind: kind.Reporting,
Name: "report",
Content: func(author, index int) string {
return fmt.Sprintf("Report content %d: testing moderation event sync", index)
},
},
{
Kind: kind.MuteList,
Name: "mute_list",
Content: func(author, index int) string {
return fmt.Sprintf("Mute list update %d", index)
},
},
{
Kind: kind.PinList,
Name: "pin_list",
Content: func(author, index int) string {
return fmt.Sprintf("Pinned events list %d", index)
},
},
{
Kind: kind.LongFormContent,
Name: "long_form",
Content: func(author, index int) string {
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)
},
},
{
Kind: kind.ApplicationSpecificData,
Name: "application_specific",
Content: func(author, index int) string {
appData := map[string]interface{}{
"app": "test-suite",
"version": "1.0.0",
"test_id": index,
"data": map[string]string{
"key1": fmt.Sprintf("value%d", index),
"key2": fmt.Sprintf("data%d", index*2),
},
}
b, _ := json.Marshal(appData)
return string(b)
},
},
}
type Config struct {
Count int
OutputFile string
RelayURL string
BatchSize int
}
func main() {
var cfg Config
flag.IntVar(&cfg.Count, "count", 1000, "Number of events to generate")
flag.StringVar(&cfg.OutputFile, "output", "", "Output file (JSON array)")
flag.StringVar(&cfg.RelayURL, "relay", "", "Send directly to relay WebSocket URL")
flag.IntVar(&cfg.BatchSize, "batch", 100, "Batch size for sending")
flag.Parse()
// Generate events
fmt.Fprintf(os.Stderr, "Generating %d events...\n", cfg.Count)
events := generateEvents(cfg.Count)
// Handle output
if cfg.RelayURL != "" {
if err := sendToRelay(events, cfg.RelayURL, cfg.BatchSize); err != nil {
fmt.Fprintf(os.Stderr, "Error sending to relay: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Sent %d events to %s\n", len(events), cfg.RelayURL)
} else if cfg.OutputFile != "" {
if err := writeToFile(events, cfg.OutputFile); err != nil {
fmt.Fprintf(os.Stderr, "Error writing to file: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Wrote %d events to %s\n", len(events), cfg.OutputFile)
} else {
// Print to stdout as JSON array
output := map[string]interface{}{
"events": events,
"count": len(events),
}
jsonBytes, _ := json.MarshalIndent(output, "", " ")
fmt.Println(string(jsonBytes))
}
}
func generateEvents(count int) []*event.E {
events := make([]*event.E, 0, count)
baseTime := time.Now().Add(-24 * time.Hour)
for i := 0; i < count; i++ {
authorIdx := i % len(testKeys)
kindIdx := getWeightedKindIndex(i)
kindDef := eventKinds[kindIdx]
createdAt := baseTime.Add(time.Duration(i) * time.Second).Unix()
ev, err := createEvent(authorIdx, kindDef.Kind, kindDef.Content(authorIdx, i), createdAt, i)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create event %d: %v\n", i, err)
continue
}
events = append(events, ev)
}
return events
}
// kindPattern distributes event kinds in a repeating 20-event pattern.
// This ensures variety even for small event counts while maintaining
// approximate target proportions over larger samples.
//
// metadata (kind 0): 2/20 = 10%
// text notes (kind 1): 12/20 = 60%
// contacts (kind 3): 2/20 = 10%
// reporting (kind 1984): 1/20 = 5%
// mute list (kind 10000): 1/20 = 5%
// pin list (kind 10001): 1/20 = 5%
// long form (kind 30023): 1/20 = 5%
var kindPattern = []int{
1, 0, 1, 2, 1, 1, 3, 1, 1, 4,
1, 5, 1, 6, 1, 1, 0, 1, 2, 1,
}
func getWeightedKindIndex(seed int) int {
return kindPattern[seed%len(kindPattern)]
}
func createEvent(authorIdx int, kindDef *kind.K, content string, createdAt int64, index int) (*event.E, error) {
ev := event.New()
ev.CreatedAt = createdAt
ev.Kind = kindDef.K
ev.Content = []byte(content)
ev.Tags = tag.NewS()
signer := signers[authorIdx]
// Add tags based on kind
switch kindDef.K {
case kind.FollowList.K:
// Add p-tags with hex pubkeys of other test users
for j := 0; j < 3; j++ {
targetIdx := (index + j + 1) % len(testKeys)
targetPub := signers[targetIdx].Pub()
targetHex := hex.Enc(targetPub)
ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex)))
}
case kind.MuteList.K, kind.PinList.K:
// Replaceable list events need a d-tag
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte("")))
case kind.LongFormContent.K:
// Addressable events MUST have a d-tag
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("article-%d", index))))
ev.Tags.Append(tag.NewFromBytesSlice([]byte("title"), []byte(fmt.Sprintf("Article %d", index))))
ev.Tags.Append(tag.NewFromBytesSlice([]byte("published_at"), []byte(fmt.Sprintf("%d", createdAt))))
case kind.ApplicationSpecificData.K:
// Addressable events MUST have a d-tag
ev.Tags.Append(tag.NewFromBytesSlice([]byte("d"), []byte(fmt.Sprintf("test-data-%d", index))))
case kind.Reporting.K:
targetIdx := (index + 1) % len(testKeys)
targetPub := signers[targetIdx].Pub()
targetHex := hex.Enc(targetPub)
ev.Tags.Append(tag.NewFromBytesSlice([]byte("p"), []byte(targetHex), []byte("other"), []byte("spam")))
}
if err := ev.Sign(signer); err != nil {
return nil, fmt.Errorf("failed to sign event: %w", err)
}
return ev, nil
}
// sendToRelay sends events to a relay via a single WebSocket connection.
func sendToRelay(events []*event.E, relayURL string, batchSize int) error {
u, err := url.Parse(relayURL)
if err != nil {
return fmt.Errorf("invalid relay URL: %w", err)
}
fmt.Fprintf(os.Stderr, "Connecting to %s...\n", u.String())
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("failed to connect to relay: %w", err)
}
defer conn.Close()
sent := 0
rejected := 0
for i, ev := range events {
eventJSON, err := ev.MarshalJSON()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to marshal event %d: %v\n", i, err)
continue
}
msg := fmt.Sprintf(`["EVENT",%s]`, string(eventJSON))
if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
return fmt.Errorf("failed to send event %d: %w", i, err)
}
// Read the OK response
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, response, err := conn.ReadMessage()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: no response for event %d: %v\n", i, err)
} else {
// Check if the response indicates success
respStr := string(response)
if len(respStr) > 10 {
// Parse ["OK","id",true/false,"message"]
var okResp []interface{}
if json.Unmarshal(response, &okResp) == nil && len(okResp) >= 3 {
if accepted, ok := okResp[2].(bool); ok && accepted {
sent++
} else {
rejected++
if rejected <= 5 {
fmt.Fprintf(os.Stderr, "Rejected: %s\n", respStr)
}
}
}
}
}
// Log progress periodically
if (i+1)%batchSize == 0 || i == len(events)-1 {
fmt.Fprintf(os.Stderr, "Progress: %d/%d sent, %d rejected\n", sent, i+1, rejected)
}
}
fmt.Fprintf(os.Stderr, "Total: %d sent, %d rejected out of %d\n", sent, rejected, len(events))
return nil
}
func writeToFile(events []*event.E, filename string) error {
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
output := map[string]interface{}{
"events": events,
"count": len(events),
}
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
return encoder.Encode(output)
}