A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

Select the types of activity you want to include in your feed.

add bluesky post with status

+302 -3
+44 -3
cmd/hold/main.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "os" 9 + "os/signal" 10 + "syscall" 11 + "time" 8 12 9 13 "atcr.io/pkg/hold" 10 14 "atcr.io/pkg/hold/oci" ··· 122 126 WriteTimeout: cfg.Server.WriteTimeout, 123 127 } 124 128 125 - // Start server in goroutine so we can do auto-registration after it's running 129 + // Set up signal handling for graceful shutdown 130 + sigChan := make(chan os.Signal, 1) 131 + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 132 + 133 + // Start server in goroutine 126 134 serverErr := make(chan error, 1) 127 135 go func() { 128 136 log.Printf("Starting hold service on %s", cfg.Server.Addr) ··· 131 139 } 132 140 }() 133 141 134 - // Wait for server error or shutdown 135 - if err := <-serverErr; err != nil { 142 + // Update status post to "online" after server starts 143 + if holdPDS != nil { 144 + ctx := context.Background() 145 + if err := holdPDS.SetStatus(ctx, "online"); err != nil { 146 + log.Printf("Warning: Failed to set status post to online: %v", err) 147 + } else { 148 + log.Printf("Status post set to online") 149 + } 150 + } 151 + 152 + // Wait for signal or server error 153 + select { 154 + case err := <-serverErr: 136 155 log.Fatalf("Server failed: %v", err) 156 + case sig := <-sigChan: 157 + log.Printf("Received signal %v, shutting down gracefully...", sig) 158 + 159 + // Update status post to "offline" before shutdown 160 + if holdPDS != nil { 161 + ctx := context.Background() 162 + if err := holdPDS.SetStatus(ctx, "offline"); err != nil { 163 + log.Printf("Warning: Failed to set status post to offline: %v", err) 164 + } else { 165 + log.Printf("Status post set to offline") 166 + } 167 + } 168 + 169 + // Graceful shutdown with 10 second timeout 170 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 171 + defer cancel() 172 + 173 + if err := server.Shutdown(shutdownCtx); err != nil { 174 + log.Printf("Server shutdown error: %v", err) 175 + } else { 176 + log.Printf("Server shutdown complete") 177 + } 137 178 } 138 179 }
+96
pkg/hold/pds/status.go
··· 1 + package pds 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + bsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/ipfs/go-cid" 10 + ) 11 + 12 + const ( 13 + // StatusPostRkey is the fixed rkey for the status post (singleton) 14 + StatusPostRkey = "status" 15 + 16 + // StatusPostCollection is the collection name for Bluesky posts 17 + StatusPostCollection = "app.bsky.feed.post" 18 + ) 19 + 20 + // SetStatus creates or updates the hold's status post on Bluesky 21 + // status should be "online" or "offline" 22 + func (p *HoldPDS) SetStatus(ctx context.Context, status string) error { 23 + // Format the post text with emoji indicator 24 + emoji := "🟢" 25 + if status == "offline" { 26 + emoji = "🔴" 27 + } 28 + text := fmt.Sprintf("%s Current status: %s", emoji, status) 29 + 30 + // Check if status post already exists 31 + _, existingPost, err := p.GetStatusPost(ctx) 32 + if err != nil { 33 + // Post doesn't exist, create it 34 + return p.createStatusPost(ctx, text) 35 + } 36 + 37 + // Post exists, update it 38 + // We need to preserve the original CreatedAt timestamp 39 + return p.updateStatusPost(ctx, text, existingPost.CreatedAt) 40 + } 41 + 42 + // GetStatusPost retrieves the status post if it exists 43 + func (p *HoldPDS) GetStatusPost(ctx context.Context) (cid.Cid, *bsky.FeedPost, error) { 44 + // Use repomgr.GetRecord 45 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, cid.Undef) 46 + if err != nil { 47 + return cid.Undef, nil, fmt.Errorf("failed to get status post: %w", err) 48 + } 49 + 50 + // Type assert to bsky.FeedPost 51 + post, ok := val.(*bsky.FeedPost) 52 + if !ok { 53 + return cid.Undef, nil, fmt.Errorf("unexpected type for status post: %T", val) 54 + } 55 + 56 + return recordCID, post, nil 57 + } 58 + 59 + // createStatusPost creates a new status post (first time) 60 + func (p *HoldPDS) createStatusPost(ctx context.Context, text string) error { 61 + // Create post struct 62 + now := time.Now().Format(time.RFC3339) 63 + post := &bsky.FeedPost{ 64 + LexiconTypeID: "app.bsky.feed.post", 65 + Text: text, 66 + CreatedAt: now, 67 + } 68 + 69 + // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists 70 + recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post) 71 + if err != nil { 72 + return fmt.Errorf("failed to create status post: %w", err) 73 + } 74 + 75 + fmt.Printf("Created status post at %s, cid: %s, text: %s\n", recordPath, recordCID, text) 76 + return nil 77 + } 78 + 79 + // updateStatusPost updates an existing status post 80 + func (p *HoldPDS) updateStatusPost(ctx context.Context, text string, createdAt string) error { 81 + // Create updated post struct with original CreatedAt 82 + post := &bsky.FeedPost{ 83 + LexiconTypeID: "app.bsky.feed.post", 84 + Text: text, 85 + CreatedAt: createdAt, // Preserve original creation time 86 + } 87 + 88 + // Use repomgr.UpdateRecord 89 + recordCID, err := p.repomgr.UpdateRecord(ctx, p.uid, StatusPostCollection, StatusPostRkey, post) 90 + if err != nil { 91 + return fmt.Errorf("failed to update status post: %w", err) 92 + } 93 + 94 + fmt.Printf("Updated status post, cid: %s, text: %s\n", recordCID, text) 95 + return nil 96 + }
+162
pkg/hold/pds/status_test.go
··· 1 + package pds 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + 9 + bsky "github.com/bluesky-social/indigo/api/bsky" 10 + ) 11 + 12 + func TestStatusPost(t *testing.T) { 13 + // Create temporary directory for test database 14 + tmpDir := t.TempDir() 15 + dbPath := filepath.Join(tmpDir, "test.db") 16 + keyPath := filepath.Join(tmpDir, "test.key") 17 + 18 + // Create test PDS 19 + ctx := context.Background() 20 + did := "did:web:test.example.com" 21 + publicURL := "https://test.example.com" 22 + 23 + holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath) 24 + if err != nil { 25 + t.Fatalf("Failed to create test PDS: %v", err) 26 + } 27 + 28 + // Initialize empty repo (required before creating records) 29 + err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "") 30 + if err != nil { 31 + t.Fatalf("Failed to initialize repo: %v", err) 32 + } 33 + 34 + t.Run("CreateStatusPost", func(t *testing.T) { 35 + // Set status to online (creates new post) 36 + err := holdPDS.SetStatus(ctx, "online") 37 + if err != nil { 38 + t.Fatalf("Failed to set status to online: %v", err) 39 + } 40 + 41 + // Verify post was created 42 + _, post, err := holdPDS.GetStatusPost(ctx) 43 + if err != nil { 44 + t.Fatalf("Failed to get status post: %v", err) 45 + } 46 + 47 + if post.Text != "🟢 Current status: online" { 48 + t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text) 49 + } 50 + 51 + if post.LexiconTypeID != "app.bsky.feed.post" { 52 + t.Errorf("Expected LexiconTypeID 'app.bsky.feed.post', got '%s'", post.LexiconTypeID) 53 + } 54 + 55 + if post.CreatedAt == "" { 56 + t.Error("CreatedAt should not be empty") 57 + } 58 + }) 59 + 60 + t.Run("UpdateStatusPost", func(t *testing.T) { 61 + // Get the original post to check CreatedAt preservation 62 + _, originalPost, err := holdPDS.GetStatusPost(ctx) 63 + if err != nil { 64 + t.Fatalf("Failed to get original status post: %v", err) 65 + } 66 + 67 + // Set status to offline (updates existing post) 68 + err = holdPDS.SetStatus(ctx, "offline") 69 + if err != nil { 70 + t.Fatalf("Failed to set status to offline: %v", err) 71 + } 72 + 73 + // Verify post was updated 74 + _, post, err := holdPDS.GetStatusPost(ctx) 75 + if err != nil { 76 + t.Fatalf("Failed to get updated status post: %v", err) 77 + } 78 + 79 + if post.Text != "🔴 Current status: offline" { 80 + t.Errorf("Expected text '🔴 Current status: offline', got '%s'", post.Text) 81 + } 82 + 83 + // Verify CreatedAt was preserved 84 + if post.CreatedAt != originalPost.CreatedAt { 85 + t.Errorf("CreatedAt should be preserved. Expected '%s', got '%s'", originalPost.CreatedAt, post.CreatedAt) 86 + } 87 + }) 88 + 89 + t.Run("ToggleStatus", func(t *testing.T) { 90 + // Toggle back to online 91 + err := holdPDS.SetStatus(ctx, "online") 92 + if err != nil { 93 + t.Fatalf("Failed to set status to online: %v", err) 94 + } 95 + 96 + _, post, err := holdPDS.GetStatusPost(ctx) 97 + if err != nil { 98 + t.Fatalf("Failed to get status post: %v", err) 99 + } 100 + 101 + if post.Text != "🟢 Current status: online" { 102 + t.Errorf("Expected text '🟢 Current status: online', got '%s'", post.Text) 103 + } 104 + }) 105 + } 106 + 107 + func TestStatusPostCollection(t *testing.T) { 108 + // Verify constants 109 + if StatusPostCollection != "app.bsky.feed.post" { 110 + t.Errorf("Expected StatusPostCollection 'app.bsky.feed.post', got '%s'", StatusPostCollection) 111 + } 112 + 113 + if StatusPostRkey != "status" { 114 + t.Errorf("Expected StatusPostRkey 'status', got '%s'", StatusPostRkey) 115 + } 116 + } 117 + 118 + func TestGetStatusPostNotExists(t *testing.T) { 119 + // Create temporary directory for test database 120 + tmpDir := t.TempDir() 121 + dbPath := filepath.Join(tmpDir, "test.db") 122 + keyPath := filepath.Join(tmpDir, "test.key") 123 + 124 + // Create test PDS 125 + ctx := context.Background() 126 + did := "did:web:test2.example.com" 127 + publicURL := "https://test2.example.com" 128 + 129 + holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath) 130 + if err != nil { 131 + t.Fatalf("Failed to create test PDS: %v", err) 132 + } 133 + 134 + // Initialize empty repo 135 + err = holdPDS.repomgr.InitNewActor(ctx, holdPDS.uid, "", did, "", "", "") 136 + if err != nil { 137 + t.Fatalf("Failed to initialize repo: %v", err) 138 + } 139 + 140 + // Try to get status post that doesn't exist 141 + _, _, err = holdPDS.GetStatusPost(ctx) 142 + if err == nil { 143 + t.Error("Expected error when getting non-existent status post, got nil") 144 + } 145 + } 146 + 147 + func init() { 148 + // Register FeedPost type for testing 149 + // This is normally done by the bsky package init(), but we need to ensure it's done 150 + // The actual registration happens in the bsky package, so this is a no-op 151 + // but kept for clarity 152 + _ = &bsky.FeedPost{} 153 + } 154 + 155 + // Cleanup function to remove test files 156 + func TestMain(m *testing.M) { 157 + // Run tests 158 + code := m.Run() 159 + 160 + // Cleanup is automatic with t.TempDir() 161 + os.Exit(code) 162 + }