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.
201 lines
5.9 KiB
201 lines
5.9 KiB
package neo4j |
|
|
|
import ( |
|
"context" |
|
"fmt" |
|
"strings" |
|
|
|
"git.mleku.dev/mleku/nostr/encoders/hex" |
|
"next.orly.dev/pkg/protocol/graph" |
|
) |
|
|
|
// TraverseFollows performs BFS traversal of the follow graph starting from a seed pubkey. |
|
// Returns pubkeys grouped by first-discovered depth (no duplicates across depths). |
|
// |
|
// Uses Neo4j's native path queries with FOLLOWS relationships created by |
|
// the social event processor from kind 3 contact list events. |
|
// |
|
// The traversal works by using variable-length path patterns: |
|
// - Depth 1: Direct follows (seed)-[:FOLLOWS]->(followed) |
|
// - Depth 2: Follows of follows (seed)-[:FOLLOWS*2]->(followed) |
|
// - etc. |
|
// |
|
// Each pubkey appears only at the depth where it was first discovered. |
|
func (n *N) TraverseFollows(seedPubkey []byte, maxDepth int) (graph.GraphResultI, error) { |
|
result := NewGraphResult() |
|
|
|
if len(seedPubkey) != 32 { |
|
return result, fmt.Errorf("invalid pubkey length: expected 32, got %d", len(seedPubkey)) |
|
} |
|
|
|
seedHex := strings.ToLower(hex.Enc(seedPubkey)) |
|
ctx := context.Background() |
|
|
|
// Track visited pubkeys to ensure each appears only at first-discovered depth |
|
visited := make(map[string]bool) |
|
visited[seedHex] = true // Seed is at depth 0, not included in results |
|
|
|
// Process each depth level separately to maintain BFS semantics |
|
for depth := 1; depth <= maxDepth; depth++ { |
|
// Query for pubkeys at exactly this depth that haven't been seen yet |
|
// We use a variable-length path of exactly 'depth' hops |
|
cypher := fmt.Sprintf(` |
|
MATCH path = (seed:NostrUser {pubkey: $seed})-[:FOLLOWS*%d]->(target:NostrUser) |
|
WHERE target.pubkey <> $seed |
|
AND NOT target.pubkey IN $visited |
|
RETURN DISTINCT target.pubkey AS pubkey |
|
`, depth) |
|
|
|
// Convert visited map to slice for query |
|
visitedList := make([]string, 0, len(visited)) |
|
for pk := range visited { |
|
visitedList = append(visitedList, pk) |
|
} |
|
|
|
params := map[string]any{ |
|
"seed": seedHex, |
|
"visited": visitedList, |
|
} |
|
|
|
queryResult, err := n.ExecuteRead(ctx, cypher, params) |
|
if err != nil { |
|
n.Logger.Warningf("TraverseFollows: error at depth %d: %v", depth, err) |
|
continue |
|
} |
|
|
|
newPubkeysAtDepth := 0 |
|
for queryResult.Next(ctx) { |
|
record := queryResult.Record() |
|
pubkey, ok := record.Values[0].(string) |
|
if !ok || pubkey == "" { |
|
continue |
|
} |
|
|
|
// Normalize to lowercase for consistency |
|
pubkey = strings.ToLower(pubkey) |
|
|
|
// Add to result if not already seen |
|
if !visited[pubkey] { |
|
visited[pubkey] = true |
|
result.AddPubkeyAtDepth(pubkey, depth) |
|
newPubkeysAtDepth++ |
|
} |
|
} |
|
|
|
n.Logger.Debugf("TraverseFollows: depth %d found %d new pubkeys", depth, newPubkeysAtDepth) |
|
|
|
// Early termination if no new pubkeys found at this depth |
|
if newPubkeysAtDepth == 0 { |
|
break |
|
} |
|
} |
|
|
|
n.Logger.Debugf("TraverseFollows: completed with %d total pubkeys across %d depths", |
|
result.TotalPubkeys, len(result.PubkeysByDepth)) |
|
|
|
return result, nil |
|
} |
|
|
|
// TraverseFollowers performs BFS traversal to find who follows the seed pubkey. |
|
// This is the reverse of TraverseFollows - it finds users whose kind-3 lists |
|
// contain the target pubkey(s). |
|
// |
|
// Uses Neo4j's native path queries, but in reverse direction: |
|
// - Depth 1: Users who directly follow the seed (follower)-[:FOLLOWS]->(seed) |
|
// - Depth 2: Users who follow anyone at depth 1 (followers of followers) |
|
// - etc. |
|
func (n *N) TraverseFollowers(seedPubkey []byte, maxDepth int) (graph.GraphResultI, error) { |
|
result := NewGraphResult() |
|
|
|
if len(seedPubkey) != 32 { |
|
return result, fmt.Errorf("invalid pubkey length: expected 32, got %d", len(seedPubkey)) |
|
} |
|
|
|
seedHex := strings.ToLower(hex.Enc(seedPubkey)) |
|
ctx := context.Background() |
|
|
|
// Track visited pubkeys |
|
visited := make(map[string]bool) |
|
visited[seedHex] = true |
|
|
|
// Process each depth level separately for BFS semantics |
|
for depth := 1; depth <= maxDepth; depth++ { |
|
// Query for pubkeys at exactly this depth that haven't been seen yet |
|
// Direction is reversed: we find users who follow the targets |
|
cypher := fmt.Sprintf(` |
|
MATCH path = (follower:NostrUser)-[:FOLLOWS*%d]->(seed:NostrUser {pubkey: $seed}) |
|
WHERE follower.pubkey <> $seed |
|
AND NOT follower.pubkey IN $visited |
|
RETURN DISTINCT follower.pubkey AS pubkey |
|
`, depth) |
|
|
|
visitedList := make([]string, 0, len(visited)) |
|
for pk := range visited { |
|
visitedList = append(visitedList, pk) |
|
} |
|
|
|
params := map[string]any{ |
|
"seed": seedHex, |
|
"visited": visitedList, |
|
} |
|
|
|
queryResult, err := n.ExecuteRead(ctx, cypher, params) |
|
if err != nil { |
|
n.Logger.Warningf("TraverseFollowers: error at depth %d: %v", depth, err) |
|
continue |
|
} |
|
|
|
newPubkeysAtDepth := 0 |
|
for queryResult.Next(ctx) { |
|
record := queryResult.Record() |
|
pubkey, ok := record.Values[0].(string) |
|
if !ok || pubkey == "" { |
|
continue |
|
} |
|
|
|
pubkey = strings.ToLower(pubkey) |
|
|
|
if !visited[pubkey] { |
|
visited[pubkey] = true |
|
result.AddPubkeyAtDepth(pubkey, depth) |
|
newPubkeysAtDepth++ |
|
} |
|
} |
|
|
|
n.Logger.Debugf("TraverseFollowers: depth %d found %d new pubkeys", depth, newPubkeysAtDepth) |
|
|
|
if newPubkeysAtDepth == 0 { |
|
break |
|
} |
|
} |
|
|
|
n.Logger.Debugf("TraverseFollowers: completed with %d total pubkeys", result.TotalPubkeys) |
|
|
|
return result, nil |
|
} |
|
|
|
// TraverseFollowsFromHex is a convenience wrapper that accepts hex-encoded pubkey. |
|
func (n *N) TraverseFollowsFromHex(seedPubkeyHex string, maxDepth int) (*GraphResult, error) { |
|
seedPubkey, err := hex.Dec(seedPubkeyHex) |
|
if err != nil { |
|
return nil, err |
|
} |
|
result, err := n.TraverseFollows(seedPubkey, maxDepth) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return result.(*GraphResult), nil |
|
} |
|
|
|
// TraverseFollowersFromHex is a convenience wrapper that accepts hex-encoded pubkey. |
|
func (n *N) TraverseFollowersFromHex(seedPubkeyHex string, maxDepth int) (*GraphResult, error) { |
|
seedPubkey, err := hex.Dec(seedPubkeyHex) |
|
if err != nil { |
|
return nil, err |
|
} |
|
result, err := n.TraverseFollowers(seedPubkey, maxDepth) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return result.(*GraphResult), nil |
|
}
|
|
|