Complete BZZZ functionality port to CHORUS
🎭 CHORUS now contains full BZZZ functionality adapted for containers Core systems ported: - P2P networking (libp2p with DHT and PubSub) - Task coordination (COOEE protocol) - HMMM collaborative reasoning - SHHH encryption and security - SLURP admin election system - UCXL content addressing - UCXI server integration - Hypercore logging system - Health monitoring and graceful shutdown - License validation with KACHING Container adaptations: - Environment variable configuration (no YAML files) - Container-optimized logging to stdout/stderr - Auto-generated agent IDs for container deployments - Docker-first architecture All proven BZZZ P2P protocols, AI integration, and collaboration features are now available in containerized form. Next: Build and test container deployment. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
569
pkg/slurp/temporal/navigator_impl.go
Normal file
569
pkg/slurp/temporal/navigator_impl.go
Normal file
@@ -0,0 +1,569 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
// decisionNavigatorImpl implements the DecisionNavigator interface
|
||||
type decisionNavigatorImpl struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Reference to the temporal graph
|
||||
graph *temporalGraphImpl
|
||||
|
||||
// Navigation state
|
||||
navigationSessions map[string]*NavigationSession
|
||||
bookmarks map[string]*DecisionBookmark
|
||||
|
||||
// Configuration
|
||||
maxNavigationHistory int
|
||||
}
|
||||
|
||||
// NavigationSession represents a navigation session
|
||||
type NavigationSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
LastActivity time.Time `json:"last_activity"`
|
||||
CurrentPosition ucxl.Address `json:"current_position"`
|
||||
History []*DecisionStep `json:"history"`
|
||||
Bookmarks []string `json:"bookmarks"`
|
||||
Preferences *NavPreferences `json:"preferences"`
|
||||
}
|
||||
|
||||
// NavPreferences represents navigation preferences
|
||||
type NavPreferences struct {
|
||||
MaxHops int `json:"max_hops"`
|
||||
PreferRecentDecisions bool `json:"prefer_recent_decisions"`
|
||||
FilterByConfidence float64 `json:"filter_by_confidence"`
|
||||
IncludeStaleContexts bool `json:"include_stale_contexts"`
|
||||
}
|
||||
|
||||
// NewDecisionNavigator creates a new decision navigator
|
||||
func NewDecisionNavigator(graph *temporalGraphImpl) DecisionNavigator {
|
||||
return &decisionNavigatorImpl{
|
||||
graph: graph,
|
||||
navigationSessions: make(map[string]*NavigationSession),
|
||||
bookmarks: make(map[string]*DecisionBookmark),
|
||||
maxNavigationHistory: 100,
|
||||
}
|
||||
}
|
||||
|
||||
// NavigateDecisionHops navigates by decision distance, not time
|
||||
func (dn *decisionNavigatorImpl) NavigateDecisionHops(ctx context.Context, address ucxl.Address,
|
||||
hops int, direction NavigationDirection) (*TemporalNode, error) {
|
||||
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
// Get starting node
|
||||
startNode, err := dn.graph.getLatestNodeUnsafe(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get starting node: %w", err)
|
||||
}
|
||||
|
||||
// Navigate by hops
|
||||
currentNode := startNode
|
||||
for i := 0; i < hops; i++ {
|
||||
nextNode, err := dn.navigateOneHop(currentNode, direction)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to navigate hop %d: %w", i+1, err)
|
||||
}
|
||||
currentNode = nextNode
|
||||
}
|
||||
|
||||
return currentNode, nil
|
||||
}
|
||||
|
||||
// GetDecisionTimeline gets timeline ordered by decision sequence
|
||||
func (dn *decisionNavigatorImpl) GetDecisionTimeline(ctx context.Context, address ucxl.Address,
|
||||
includeRelated bool, maxHops int) (*DecisionTimeline, error) {
|
||||
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
// Get evolution history for the primary address
|
||||
history, err := dn.graph.GetEvolutionHistory(ctx, address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get evolution history: %w", err)
|
||||
}
|
||||
|
||||
// Build decision timeline entries
|
||||
decisionSequence := make([]*DecisionTimelineEntry, len(history))
|
||||
for i, node := range history {
|
||||
entry := &DecisionTimelineEntry{
|
||||
Version: node.Version,
|
||||
DecisionHop: node.Version, // Version number as decision hop
|
||||
ChangeReason: node.ChangeReason,
|
||||
DecisionMaker: dn.getDecisionMaker(node),
|
||||
DecisionRationale: dn.getDecisionRationale(node),
|
||||
ConfidenceEvolution: node.Confidence,
|
||||
Timestamp: node.Timestamp,
|
||||
InfluencesCount: len(node.Influences),
|
||||
InfluencedByCount: len(node.InfluencedBy),
|
||||
ImpactScope: node.ImpactScope,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
decisionSequence[i] = entry
|
||||
}
|
||||
|
||||
// Get related decisions if requested
|
||||
relatedDecisions := make([]*RelatedDecision, 0)
|
||||
if includeRelated && maxHops > 0 {
|
||||
relatedPaths, err := dn.graph.FindRelatedDecisions(ctx, address, maxHops)
|
||||
if err == nil {
|
||||
for _, path := range relatedPaths {
|
||||
if len(path.Steps) > 0 {
|
||||
lastStep := path.Steps[len(path.Steps)-1]
|
||||
related := &RelatedDecision{
|
||||
Address: path.To,
|
||||
DecisionHops: path.TotalHops,
|
||||
LatestVersion: lastStep.TemporalNode.Version,
|
||||
ChangeReason: lastStep.TemporalNode.ChangeReason,
|
||||
DecisionMaker: dn.getDecisionMaker(lastStep.TemporalNode),
|
||||
Confidence: lastStep.TemporalNode.Confidence,
|
||||
LastDecisionTimestamp: lastStep.TemporalNode.Timestamp,
|
||||
RelationshipType: lastStep.Relationship,
|
||||
}
|
||||
relatedDecisions = append(relatedDecisions, related)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate timeline analysis
|
||||
analysis := dn.analyzeTimeline(decisionSequence, relatedDecisions)
|
||||
|
||||
// Calculate time span
|
||||
var timeSpan time.Duration
|
||||
if len(history) > 1 {
|
||||
timeSpan = history[len(history)-1].Timestamp.Sub(history[0].Timestamp)
|
||||
}
|
||||
|
||||
timeline := &DecisionTimeline{
|
||||
PrimaryAddress: address,
|
||||
DecisionSequence: decisionSequence,
|
||||
RelatedDecisions: relatedDecisions,
|
||||
TotalDecisions: len(decisionSequence),
|
||||
TimeSpan: timeSpan,
|
||||
AnalysisMetadata: analysis,
|
||||
}
|
||||
|
||||
return timeline, nil
|
||||
}
|
||||
|
||||
// FindStaleContexts finds contexts that may be outdated based on decisions
|
||||
func (dn *decisionNavigatorImpl) FindStaleContexts(ctx context.Context, stalenessThreshold float64) ([]*StaleContext, error) {
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
staleContexts := make([]*StaleContext, 0)
|
||||
|
||||
// Check all nodes for staleness
|
||||
for _, node := range dn.graph.nodes {
|
||||
if node.Staleness >= stalenessThreshold {
|
||||
staleness := &StaleContext{
|
||||
UCXLAddress: node.UCXLAddress,
|
||||
TemporalNode: node,
|
||||
StalenessScore: node.Staleness,
|
||||
LastUpdated: node.Timestamp,
|
||||
Reasons: dn.getStalenessReasons(node),
|
||||
SuggestedActions: dn.getSuggestedActions(node),
|
||||
RelatedChanges: dn.getRelatedChanges(node),
|
||||
Priority: dn.calculateStalePriority(node),
|
||||
}
|
||||
staleContexts = append(staleContexts, staleness)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by staleness score (highest first)
|
||||
sort.Slice(staleContexts, func(i, j int) bool {
|
||||
return staleContexts[i].StalenessScore > staleContexts[j].StalenessScore
|
||||
})
|
||||
|
||||
return staleContexts, nil
|
||||
}
|
||||
|
||||
// ValidateDecisionPath validates that a decision path is reachable
|
||||
func (dn *decisionNavigatorImpl) ValidateDecisionPath(ctx context.Context, path []*DecisionStep) error {
|
||||
if len(path) == 0 {
|
||||
return fmt.Errorf("empty decision path")
|
||||
}
|
||||
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
// Validate each step in the path
|
||||
for i, step := range path {
|
||||
// Check if the temporal node exists
|
||||
if step.TemporalNode == nil {
|
||||
return fmt.Errorf("step %d has nil temporal node", i)
|
||||
}
|
||||
|
||||
nodeID := step.TemporalNode.ID
|
||||
if _, exists := dn.graph.nodes[nodeID]; !exists {
|
||||
return fmt.Errorf("step %d references non-existent node %s", i, nodeID)
|
||||
}
|
||||
|
||||
// Validate hop distance
|
||||
if step.HopDistance != i {
|
||||
return fmt.Errorf("step %d has incorrect hop distance: expected %d, got %d",
|
||||
i, i, step.HopDistance)
|
||||
}
|
||||
|
||||
// Validate relationship to next step
|
||||
if i < len(path)-1 {
|
||||
nextStep := path[i+1]
|
||||
if !dn.validateStepRelationship(step, nextStep) {
|
||||
return fmt.Errorf("invalid relationship between step %d and %d", i, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNavigationHistory gets navigation history for a session
|
||||
func (dn *decisionNavigatorImpl) GetNavigationHistory(ctx context.Context, sessionID string) ([]*DecisionStep, error) {
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
session, exists := dn.navigationSessions[sessionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("navigation session %s not found", sessionID)
|
||||
}
|
||||
|
||||
// Return a copy of the history
|
||||
history := make([]*DecisionStep, len(session.History))
|
||||
copy(history, session.History)
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// ResetNavigation resets navigation state to latest versions
|
||||
func (dn *decisionNavigatorImpl) ResetNavigation(ctx context.Context, address ucxl.Address) error {
|
||||
dn.mu.Lock()
|
||||
defer dn.mu.Unlock()
|
||||
|
||||
// Clear any navigation sessions for this address
|
||||
for sessionID, session := range dn.navigationSessions {
|
||||
if session.CurrentPosition.String() == address.String() {
|
||||
// Reset to latest version
|
||||
latestNode, err := dn.graph.getLatestNodeUnsafe(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get latest node: %w", err)
|
||||
}
|
||||
|
||||
session.CurrentPosition = address
|
||||
session.History = []*DecisionStep{}
|
||||
session.LastActivity = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BookmarkDecision creates a bookmark for a specific decision point
|
||||
func (dn *decisionNavigatorImpl) BookmarkDecision(ctx context.Context, address ucxl.Address, hop int, name string) error {
|
||||
dn.mu.Lock()
|
||||
defer dn.mu.Unlock()
|
||||
|
||||
// Validate the decision point exists
|
||||
node, err := dn.graph.GetVersionAtDecision(ctx, address, hop)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decision point not found: %w", err)
|
||||
}
|
||||
|
||||
// Create bookmark
|
||||
bookmarkID := fmt.Sprintf("%s-%d-%d", address.String(), hop, time.Now().Unix())
|
||||
bookmark := &DecisionBookmark{
|
||||
ID: bookmarkID,
|
||||
Name: name,
|
||||
Description: fmt.Sprintf("Decision at hop %d for %s", hop, address.String()),
|
||||
Address: address,
|
||||
DecisionHop: hop,
|
||||
CreatedBy: "system", // Could be passed as parameter
|
||||
CreatedAt: time.Now(),
|
||||
Tags: []string{},
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
// Add context information to metadata
|
||||
bookmark.Metadata["change_reason"] = node.ChangeReason
|
||||
bookmark.Metadata["decision_id"] = node.DecisionID
|
||||
bookmark.Metadata["confidence"] = node.Confidence
|
||||
|
||||
dn.bookmarks[bookmarkID] = bookmark
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBookmarks lists all bookmarks for navigation
|
||||
func (dn *decisionNavigatorImpl) ListBookmarks(ctx context.Context) ([]*DecisionBookmark, error) {
|
||||
dn.mu.RLock()
|
||||
defer dn.mu.RUnlock()
|
||||
|
||||
bookmarks := make([]*DecisionBookmark, 0, len(dn.bookmarks))
|
||||
for _, bookmark := range dn.bookmarks {
|
||||
bookmarks = append(bookmarks, bookmark)
|
||||
}
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
sort.Slice(bookmarks, func(i, j int) bool {
|
||||
return bookmarks[i].CreatedAt.After(bookmarks[j].CreatedAt)
|
||||
})
|
||||
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (dn *decisionNavigatorImpl) navigateOneHop(currentNode *TemporalNode, direction NavigationDirection) (*TemporalNode, error) {
|
||||
switch direction {
|
||||
case NavigationForward:
|
||||
return dn.navigateForward(currentNode)
|
||||
case NavigationBackward:
|
||||
return dn.navigateBackward(currentNode)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid navigation direction: %s", direction)
|
||||
}
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) navigateForward(currentNode *TemporalNode) (*TemporalNode, error) {
|
||||
// Forward navigation means going to a newer decision
|
||||
addressKey := currentNode.UCXLAddress.String()
|
||||
nodes, exists := dn.graph.addressToNodes[addressKey]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("no nodes found for address")
|
||||
}
|
||||
|
||||
// Find current node in the list and get the next one
|
||||
for i, node := range nodes {
|
||||
if node.ID == currentNode.ID && i < len(nodes)-1 {
|
||||
return nodes[i+1], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no forward navigation possible")
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) navigateBackward(currentNode *TemporalNode) (*TemporalNode, error) {
|
||||
// Backward navigation means going to an older decision
|
||||
if currentNode.ParentNode == nil {
|
||||
return nil, fmt.Errorf("no backward navigation possible: no parent node")
|
||||
}
|
||||
|
||||
parentNode, exists := dn.graph.nodes[*currentNode.ParentNode]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("parent node not found: %s", *currentNode.ParentNode)
|
||||
}
|
||||
|
||||
return parentNode, nil
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) getDecisionMaker(node *TemporalNode) string {
|
||||
if decision, exists := dn.graph.decisions[node.DecisionID]; exists {
|
||||
return decision.Maker
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) getDecisionRationale(node *TemporalNode) string {
|
||||
if decision, exists := dn.graph.decisions[node.DecisionID]; exists {
|
||||
return decision.Rationale
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) analyzeTimeline(sequence []*DecisionTimelineEntry, related []*RelatedDecision) *TimelineAnalysis {
|
||||
if len(sequence) == 0 {
|
||||
return &TimelineAnalysis{
|
||||
AnalyzedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate change velocity
|
||||
var changeVelocity float64
|
||||
if len(sequence) > 1 {
|
||||
firstTime := sequence[0].Timestamp
|
||||
lastTime := sequence[len(sequence)-1].Timestamp
|
||||
duration := lastTime.Sub(firstTime)
|
||||
if duration > 0 {
|
||||
changeVelocity = float64(len(sequence)-1) / duration.Hours()
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze confidence trend
|
||||
confidenceTrend := "stable"
|
||||
if len(sequence) > 1 {
|
||||
firstConfidence := sequence[0].ConfidenceEvolution
|
||||
lastConfidence := sequence[len(sequence)-1].ConfidenceEvolution
|
||||
diff := lastConfidence - firstConfidence
|
||||
|
||||
if diff > 0.1 {
|
||||
confidenceTrend = "increasing"
|
||||
} else if diff < -0.1 {
|
||||
confidenceTrend = "decreasing"
|
||||
}
|
||||
}
|
||||
|
||||
// Count change reasons
|
||||
reasonCounts := make(map[ChangeReason]int)
|
||||
for _, entry := range sequence {
|
||||
reasonCounts[entry.ChangeReason]++
|
||||
}
|
||||
|
||||
// Find dominant reasons
|
||||
dominantReasons := make([]ChangeReason, 0)
|
||||
maxCount := 0
|
||||
for reason, count := range reasonCounts {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
dominantReasons = []ChangeReason{reason}
|
||||
} else if count == maxCount {
|
||||
dominantReasons = append(dominantReasons, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Count decision makers
|
||||
makerCounts := make(map[string]int)
|
||||
for _, entry := range sequence {
|
||||
makerCounts[entry.DecisionMaker]++
|
||||
}
|
||||
|
||||
// Count impact scope distribution
|
||||
scopeCounts := make(map[ImpactScope]int)
|
||||
for _, entry := range sequence {
|
||||
scopeCounts[entry.ImpactScope]++
|
||||
}
|
||||
|
||||
return &TimelineAnalysis{
|
||||
ChangeVelocity: changeVelocity,
|
||||
ConfidenceTrend: confidenceTrend,
|
||||
DominantChangeReasons: dominantReasons,
|
||||
DecisionMakers: makerCounts,
|
||||
ImpactScopeDistribution: scopeCounts,
|
||||
InfluenceNetworkSize: len(related),
|
||||
AnalyzedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) getStalenessReasons(node *TemporalNode) []string {
|
||||
reasons := make([]string, 0)
|
||||
|
||||
// Time-based staleness
|
||||
timeSinceUpdate := time.Since(node.Timestamp)
|
||||
if timeSinceUpdate > 7*24*time.Hour {
|
||||
reasons = append(reasons, "not updated in over a week")
|
||||
}
|
||||
|
||||
// Influence-based staleness
|
||||
if len(node.InfluencedBy) > 0 {
|
||||
reasons = append(reasons, "influenced by other contexts that may have changed")
|
||||
}
|
||||
|
||||
// Confidence-based staleness
|
||||
if node.Confidence < 0.7 {
|
||||
reasons = append(reasons, "low confidence score")
|
||||
}
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) getSuggestedActions(node *TemporalNode) []string {
|
||||
actions := make([]string, 0)
|
||||
|
||||
actions = append(actions, "review context for accuracy")
|
||||
actions = append(actions, "check related decisions for impact")
|
||||
|
||||
if node.Confidence < 0.7 {
|
||||
actions = append(actions, "improve context confidence through additional analysis")
|
||||
}
|
||||
|
||||
if len(node.InfluencedBy) > 3 {
|
||||
actions = append(actions, "validate dependencies are still accurate")
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) getRelatedChanges(node *TemporalNode) []ucxl.Address {
|
||||
// Find contexts that have changed recently and might affect this one
|
||||
relatedChanges := make([]ucxl.Address, 0)
|
||||
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
for _, otherNode := range dn.graph.nodes {
|
||||
if otherNode.Timestamp.After(cutoff) && otherNode.ID != node.ID {
|
||||
// Check if this node influences the stale node
|
||||
for _, influenced := range otherNode.Influences {
|
||||
if influenced.String() == node.UCXLAddress.String() {
|
||||
relatedChanges = append(relatedChanges, otherNode.UCXLAddress)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return relatedChanges
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) calculateStalePriority(node *TemporalNode) StalePriority {
|
||||
score := node.Staleness
|
||||
|
||||
// Adjust based on influence
|
||||
if len(node.Influences) > 5 {
|
||||
score += 0.2 // Higher priority if it influences many others
|
||||
}
|
||||
|
||||
// Adjust based on impact scope
|
||||
switch node.ImpactScope {
|
||||
case ImpactSystem:
|
||||
score += 0.3
|
||||
case ImpactProject:
|
||||
score += 0.2
|
||||
case ImpactModule:
|
||||
score += 0.1
|
||||
}
|
||||
|
||||
if score >= 0.9 {
|
||||
return PriorityCritical
|
||||
} else if score >= 0.7 {
|
||||
return PriorityHigh
|
||||
} else if score >= 0.5 {
|
||||
return PriorityMedium
|
||||
}
|
||||
return PriorityLow
|
||||
}
|
||||
|
||||
func (dn *decisionNavigatorImpl) validateStepRelationship(step, nextStep *DecisionStep) bool {
|
||||
// Check if there's a valid relationship between the steps
|
||||
currentNodeID := step.TemporalNode.ID
|
||||
nextNodeID := nextStep.TemporalNode.ID
|
||||
|
||||
switch step.Relationship {
|
||||
case "influences":
|
||||
if influences, exists := dn.graph.influences[currentNodeID]; exists {
|
||||
for _, influenced := range influences {
|
||||
if influenced == nextNodeID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "influenced_by":
|
||||
if influencedBy, exists := dn.graph.influencedBy[currentNodeID]; exists {
|
||||
for _, influencer := range influencedBy {
|
||||
if influencer == nextNodeID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user