From 3809bcab25e39f826994fa0f20c3b7e32d037e8f Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 22 Oct 2025 18:38:43 -0500 Subject: [PATCH] add bluesky post with status --- cmd/hold/main.go | 47 ++++++++++- pkg/hold/pds/status.go | 96 +++++++++++++++++++++ pkg/hold/pds/status_test.go | 162 ++++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 pkg/hold/pds/status.go create mode 100644 pkg/hold/pds/status_test.go diff --git a/cmd/hold/main.go b/cmd/hold/main.go index adc3b11..27b3c81 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -5,6 +5,10 @@ import ( "fmt" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" "atcr.io/pkg/hold" "atcr.io/pkg/hold/oci" @@ -122,7 +126,11 @@ func main() { WriteTimeout: cfg.Server.WriteTimeout, } - // Start server in goroutine so we can do auto-registration after it's running + // Set up signal handling for graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Start server in goroutine serverErr := make(chan error, 1) go func() { log.Printf("Starting hold service on %s", cfg.Server.Addr) @@ -131,8 +139,41 @@ func main() { } }() - // Wait for server error or shutdown - if err := <-serverErr; err != nil { + // Update status post to "online" after server starts + if holdPDS != nil { + ctx := context.Background() + if err := holdPDS.SetStatus(ctx, "online"); err != nil { + log.Printf("Warning: Failed to set status post to online: %v", err) + } else { + log.Printf("Status post set to online") + } + } + + // Wait for signal or server error + select { + case err := <-serverErr: log.Fatalf("Server failed: %v", err) + case sig := <-sigChan: + log.Printf("Received signal %v, shutting down gracefully...", sig) + + // Update status post to "offline" before shutdown + if holdPDS != nil { + ctx := context.Background() + if err := holdPDS.SetStatus(ctx, "offline"); err != nil { + log.Printf("Warning: Failed to set status post to offline: %v", err) + } else { + log.Printf("Status post set to offline") + } + } + + // Graceful shutdown with 10 second timeout + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("Server shutdown error: %v", err) + } else { + log.Printf("Server shutdown complete") + } } } diff --git a/pkg/hold/pds/status.go b/pkg/hold/pds/status.go new file mode 100644 index 0000000..8ce48b6 --- /dev/null +++ b/pkg/hold/pds/status.go @@ -0,0 +1,96 @@ +package pds + +import ( + "context" + "fmt" + "time" + + bsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/ipfs/go-cid" +) + +const ( + // StatusPostRkey is the fixed rkey for the status post (singleton) + StatusPostRkey = "status" + + // StatusPostCollection is the collection name for Bluesky posts + StatusPostCollection = "app.bsky.feed.post" +) + +// SetStatus creates or updates the hold's status post on Bluesky +// status should be "online" or "offline" +func (p *HoldPDS) SetStatus(ctx context.Context, status string) error { + // Format the post text with emoji indicator + emoji := "🟢" + if status == "offline" { + emoji = "🔴" + } + text := fmt.Sprintf("%s Current status: %s", emoji, status) + + // Check if status post already exists + _, existingPost, err := p.GetStatusPost(ctx) + if err != nil { + // Post doesn't exist, create it + return p.createStatusPost(ctx, text) + } + + // Post exists, update it + // We need to preserve the original CreatedAt timestamp + return p.updateStatusPost(ctx, text, existingPost.CreatedAt) +} + +// GetStatusPost retrieves the status post if it exists +func (p *HoldPDS) GetStatusPost(ctx context.Context) (cid.Cid, *bsky.FeedPost, error) { + // Use repomgr.GetRecord + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, cid.Undef) + if err != nil { + return cid.Undef, nil, fmt.Errorf("failed to get status post: %w", err) + } + + // Type assert to bsky.FeedPost + post, ok := val.(*bsky.FeedPost) + if !ok { + return cid.Undef, nil, fmt.Errorf("unexpected type for status post: %T", val) + } + + return recordCID, post, nil +} + +// createStatusPost creates a new status post (first time) +func (p *HoldPDS) createStatusPost(ctx context.Context, text string) error { + // Create post struct + now := time.Now().Format(time.RFC3339) + post := &bsky.FeedPost{ + LexiconTypeID: "app.bsky.feed.post", + Text: text, + CreatedAt: now, + } + + // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists + recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post) + if err != nil { + return fmt.Errorf("failed to create status post: %w", err) + } + + fmt.Printf("Created status post at %s, cid: %s, text: %s\n", recordPath, recordCID, text) + return nil +} + +// updateStatusPost updates an existing status post +func (p *HoldPDS) updateStatusPost(ctx context.Context, text string, createdAt string) error { + // Create updated post struct with original CreatedAt + post := &bsky.FeedPost{ + LexiconTypeID: "app.bsky.feed.post", + Text: text, + CreatedAt: createdAt, // Preserve original creation time + } + + // Use repomgr.UpdateRecord + recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post) + if err != nil { + return fmt.Errorf("failed to update status post: %w", err) + } + + fmt.Printf("Updated status post, cid: %s, text: %s\n", recordCID, text) + return nil +} diff --git a/pkg/hold/pds/status_test.go b/pkg/hold/pds/status_test.go new file mode 100644 index 0000000..a954c17 --- /dev/null +++ b/pkg/hold/pds/status_test.go @@ -0,0 +1,162 @@ +package pds + +import ( + "context" + "os" + "path/filepath" + "testing" + + bsky "github.com/bluesky-social/indigo/api/bsky" +) + +func TestStatusPost(t *testing.T) { + // Create temporary directory for test database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + keyPath := filepath.Join(tmpDir, "test.key") + + // Create test PDS + ctx := context.Background() + did := "did:web:test.example.com" + publicURL := "https://test.example.com" + + holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath) + if err != nil { + t.Fatalf("Failed to create test PDS: %v", err) + } + + // Initialize empty repo (required before creating records) + err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "") + if err != nil { + t.Fatalf("Failed to initialize repo: %v", err) + } + + t.Run("CreateStatusPost", func(t *testing.T) { + // Set status to online (creates new post) + err := holdPDS.SetStatus(ctx, "online") + if err != nil { + t.Fatalf("Failed to set status to online: %v", err) + } + + // Verify post was created + _, post, err := holdPDS.GetStatusPost(ctx) + if err != nil { + t.Fatalf("Failed to get status post: %v", err) + } + + if post.Text != "🟢 Current status: online" { + t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text) + } + + if post.LexiconTypeID != "app.bsky.feed.post" { + t.Errorf("Expected LexiconTypeID 'app.bsky.feed.post', got '%s'", post.LexiconTypeID) + } + + if post.CreatedAt == "" { + t.Error("CreatedAt should not be empty") + } + }) + + t.Run("UpdateStatusPost", func(t *testing.T) { + // Get the original post to check CreatedAt preservation + _, originalPost, err := holdPDS.GetStatusPost(ctx) + if err != nil { + t.Fatalf("Failed to get original status post: %v", err) + } + + // Set status to offline (updates existing post) + err = holdPDS.SetStatus(ctx, "offline") + if err != nil { + t.Fatalf("Failed to set status to offline: %v", err) + } + + // Verify post was updated + _, post, err := holdPDS.GetStatusPost(ctx) + if err != nil { + t.Fatalf("Failed to get updated status post: %v", err) + } + + if post.Text != "🔴 Current status: offline" { + t.Errorf("Expected text '🔴 Current status: offline', got '%s'", post.Text) + } + + // Verify CreatedAt was preserved + if post.CreatedAt != originalPost.CreatedAt { + t.Errorf("CreatedAt should be preserved. Expected '%s', got '%s'", originalPost.CreatedAt, post.CreatedAt) + } + }) + + t.Run("ToggleStatus", func(t *testing.T) { + // Toggle back to online + err := holdPDS.SetStatus(ctx, "online") + if err != nil { + t.Fatalf("Failed to set status to online: %v", err) + } + + _, post, err := holdPDS.GetStatusPost(ctx) + if err != nil { + t.Fatalf("Failed to get status post: %v", err) + } + + if post.Text != "🟢 Current status: online" { + t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text) + } + }) +} + +func TestStatusPostCollection(t *testing.T) { + // Verify constants + if StatusPostCollection != "app.bsky.feed.post" { + t.Errorf("Expected StatusPostCollection 'app.bsky.feed.post', got '%s'", StatusPostCollection) + } + + if StatusPostRkey != "status" { + t.Errorf("Expected StatusPostRkey 'status', got '%s'", StatusPostRkey) + } +} + +func TestGetStatusPostNotExists(t *testing.T) { + // Create temporary directory for test database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + keyPath := filepath.Join(tmpDir, "test.key") + + // Create test PDS + ctx := context.Background() + did := "did:web:test2.example.com" + publicURL := "https://test2.example.com" + + holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath) + if err != nil { + t.Fatalf("Failed to create test PDS: %v", err) + } + + // Initialize empty repo + err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "") + if err != nil { + t.Fatalf("Failed to initialize repo: %v", err) + } + + // Try to get status post that doesn't exist + _, _, err = holdPDS.GetStatusPost(ctx) + if err == nil { + t.Error("Expected error when getting non-existent status post, got nil") + } +} + +func init() { + // Register FeedPost type for testing + // This is normally done by the bsky package init(), but we need to ensure it's done + // The actual registration happens in the bsky package, so this is a no-op + // but kept for clarity + _ = &bsky.FeedPost{} +} + +// Cleanup function to remove test files +func TestMain(m *testing.M) { + // Run tests + code := m.Run() + + // Cleanup is automatic with t.TempDir() + os.Exit(code) +}