From 3b5c4ddeb247f0448dab91652c00fd5fad694f68 Mon Sep 17 00:00:00 2001 From: primal Date: Mon, 2 Feb 2026 15:46:27 -0500 Subject: [PATCH] Add item status (pass/fail) support - Filter unpublished items by status='pass' in publish loop - Add DeletePost function to remove posts from PDS - Add /api/setItemStatus endpoint to mark items pass/fail - When marking fail, deletes the Bluesky post if it was published Co-Authored-By: Claude Opus 4.5 --- items.go | 5 ++-- main.go | 6 ++-- publisher.go | 43 +++++++++++++++++++++++++++++ server.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/items.go b/items.go index 7813bd0..86bad24 100644 --- a/items.go +++ b/items.go @@ -15,7 +15,7 @@ func scanItems(rows pgx.Rows) ([]*commons.Item, error) { item := &commons.Item{} var guid, title, link, description, content, author *string var pubDate, updatedAt, publishedAt *interface{} - var publishedUri *string + var status, publishedUri *string var enclosureUrl, enclosureType *string var enclosureLength *int64 var imageUrlsJSON, tagsJSON *string @@ -26,7 +26,7 @@ func scanItems(rows pgx.Rows) ([]*commons.Item, error) { &item.DiscoveredAt, &updatedAt, &enclosureUrl, &enclosureType, &enclosureLength, &imageUrlsJSON, &tagsJSON, - &publishedAt, &publishedUri, + &status, &publishedAt, &publishedUri, ) if err != nil { continue @@ -38,6 +38,7 @@ func scanItems(rows pgx.Rows) ([]*commons.Item, error) { item.Description = commons.StringValue(description) item.Content = commons.StringValue(content) item.Author = commons.StringValue(author) + item.Status = commons.StringValue(status) item.PublishedUri = commons.StringValue(publishedUri) if pubDate != nil { diff --git a/main.go b/main.go index 81237fe..9f1291d 100644 --- a/main.go +++ b/main.go @@ -183,14 +183,14 @@ func (s *PublisherService) publishFeedItems(feedURL, account string) { } } -// GetUnpublishedItems returns unpublished items for a feed +// GetUnpublishedItems returns unpublished items for a feed (only items with status='pass') func (s *PublisherService) GetUnpublishedItems(feedURL string, limit int) ([]*commons.Item, error) { rows, err := s.db.Query(` SELECT feed_url, guid, title, link, description, content, author, pub_date, discovered_at, updated_at, enclosure_url, enclosure_type, enclosure_length, image_urls, tags, - published_at, published_uri + status, published_at, published_uri FROM items - WHERE feed_url = $1 AND published_at IS NULL + WHERE feed_url = $1 AND published_at IS NULL AND status = 'pass' ORDER BY pub_date ASC LIMIT $2 `, feedURL, limit) diff --git a/publisher.go b/publisher.go index 61474c7..4b6702a 100644 --- a/publisher.go +++ b/publisher.go @@ -370,6 +370,49 @@ func (p *Publisher) PublishItem(session *PDSSession, item *commons.Item) (string return result.URI, nil } +// DeletePost deletes a post from the PDS by its AT URI +// AT URI format: at://did:plc:xxxxx/app.bsky.feed.post/rkey +func (p *Publisher) DeletePost(session *PDSSession, atURI string) error { + // Parse the AT URI to extract rkey + // Format: at://did/collection/rkey + parts := strings.Split(atURI, "/") + if len(parts) < 5 { + return fmt.Errorf("invalid AT URI format: %s", atURI) + } + rkey := parts[len(parts)-1] + + payload := map[string]interface{}{ + "repo": session.DID, + "collection": "app.bsky.feed.post", + "rkey": rkey, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", p.pdsHost+"/xrpc/com.atproto.repo.deleteRecord", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+session.AccessJwt) + + resp, err := p.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete record failed: %s - %s", resp.Status, string(respBody)) + } + + return nil +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/server.go b/server.go index 9d3654b..316bd29 100644 --- a/server.go +++ b/server.go @@ -36,6 +36,7 @@ func (s *PublisherService) StartServer(addr string) error { mux.HandleFunc("/api/deriveHandle", s.handleDeriveHandle) mux.HandleFunc("/api/resetAllPublishing", s.handleResetAllPublishing) mux.HandleFunc("/api/refreshProfiles", s.handleRefreshProfiles) + mux.HandleFunc("/api/setItemStatus", s.handleSetItemStatus) fmt.Printf("Publisher API running at http://%s\n", addr) return http.ListenAndServe(addr, mux) @@ -505,6 +506,78 @@ func (s *PublisherService) handleRefreshProfiles(w http.ResponseWriter, r *http. }) } +// handleSetItemStatus sets an item's status to 'pass' or 'fail' +// If setting to 'fail' and the item was published, deletes the Bluesky post +func (s *PublisherService) handleSetItemStatus(w http.ResponseWriter, r *http.Request) { + feedURL := r.URL.Query().Get("feedUrl") + guid := r.URL.Query().Get("guid") + status := r.URL.Query().Get("status") + + if feedURL == "" || guid == "" { + http.Error(w, "feedUrl and guid parameters required", http.StatusBadRequest) + return + } + if status != "pass" && status != "fail" { + http.Error(w, "status must be 'pass' or 'fail'", http.StatusBadRequest) + return + } + + // Get the item to check if it was published + item, err := s.GetItemByGUID(feedURL, guid) + if err != nil { + http.Error(w, "item not found: "+err.Error(), http.StatusNotFound) + return + } + + result := map[string]interface{}{ + "feedUrl": feedURL, + "guid": guid, + "status": status, + } + + // If setting to fail and item was published, delete the post + if status == "fail" && item.PublishedUri != "" { + // Get the feed's publish account to authenticate + var account string + err := s.db.QueryRow(`SELECT publish_account FROM feeds WHERE url = $1`, feedURL).Scan(&account) + if err != nil || account == "" { + http.Error(w, "could not find publish account for feed", http.StatusInternalServerError) + return + } + + // Authenticate and delete the post + session, err := s.publisher.CreateSession(account, s.feedPassword) + if err != nil { + http.Error(w, "auth failed: "+err.Error(), http.StatusUnauthorized) + return + } + + if err := s.publisher.DeletePost(session, item.PublishedUri); err != nil { + // Log but don't fail - the post might already be deleted + fmt.Printf("Warning: failed to delete post %s: %v\n", item.PublishedUri, err) + result["delete_warning"] = err.Error() + } else { + result["post_deleted"] = true + } + + // Clear the published fields + _, err = s.db.Exec(`UPDATE items SET published_at = NULL, published_uri = NULL WHERE feed_url = $1 AND guid = $2`, feedURL, guid) + if err != nil { + fmt.Printf("Warning: failed to clear published fields: %v\n", err) + } + } + + // Update the item status + _, err = s.db.Exec(`UPDATE items SET status = $1 WHERE feed_url = $2 AND guid = $3`, status, feedURL, guid) + if err != nil { + http.Error(w, "failed to update status: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + // Database helper methods func (s *PublisherService) SetPublishStatus(feedURL, status, account string) error { @@ -567,7 +640,7 @@ func (s *PublisherService) GetPublishCandidates(limit int) ([]*commons.Feed, err func (s *PublisherService) GetUnpublishedItemCount(feedURL string) (int, error) { var count int err := s.db.QueryRow(` - SELECT COUNT(*) FROM items WHERE feed_url = $1 AND published_at IS NULL + SELECT COUNT(*) FROM items WHERE feed_url = $1 AND published_at IS NULL AND status = 'pass' `, feedURL).Scan(&count) return count, err } @@ -576,7 +649,7 @@ func (s *PublisherService) GetItemByGUID(feedURL, guid string) (*commons.Item, e items, err := s.db.Query(` SELECT feed_url, guid, title, link, description, content, author, pub_date, discovered_at, updated_at, enclosure_url, enclosure_type, enclosure_length, image_urls, tags, - published_at, published_uri + status, published_at, published_uri FROM items WHERE feed_url = $1 AND guid = $2 `, feedURL, guid)