A collection of Custom Bluesky Feeds, including Fresh Feeds, all under one roof
at main 173 lines 4.9 kB view raw
1package gin 2 3import ( 4 "fmt" 5 "net/http" 6 "strconv" 7 "strings" 8 9 appbsky "github.com/bluesky-social/indigo/api/bsky" 10 "github.com/ericvolp12/go-bsky-feed-generator/pkg/feedrouter" 11 "github.com/gin-gonic/gin" 12 "github.com/whyrusleeping/go-did" 13 "go.opentelemetry.io/otel" 14 "go.opentelemetry.io/otel/attribute" 15) 16 17type Endpoints struct { 18 FeedRouter *feedrouter.FeedRouter 19} 20 21type DidResponse struct { 22 Context []string `json:"@context"` 23 ID string `json:"id"` 24 Service []did.Service `json:"service"` 25} 26 27func NewEndpoints(feedRouter *feedrouter.FeedRouter) *Endpoints { 28 return &Endpoints{ 29 FeedRouter: feedRouter, 30 } 31} 32 33func (ep *Endpoints) GetWellKnownDID(c *gin.Context) { 34 tracer := otel.Tracer("feedrouter") 35 _, span := tracer.Start(c.Request.Context(), "GetWellKnownDID") 36 defer span.End() 37 38 // Use a custom struct to fix missing omitempty on did.Document 39 didResponse := DidResponse{ 40 Context: ep.FeedRouter.DIDDocument.Context, 41 ID: ep.FeedRouter.DIDDocument.ID.String(), 42 Service: ep.FeedRouter.DIDDocument.Service, 43 } 44 45 c.JSON(http.StatusOK, didResponse) 46} 47 48func (ep *Endpoints) DescribeFeeds(c *gin.Context) { 49 tracer := otel.Tracer("feedrouter") 50 ctx, span := tracer.Start(c.Request.Context(), "DescribeFeeds") 51 defer span.End() 52 53 feedDescriptions := []*appbsky.FeedDescribeFeedGenerator_Feed{} 54 55 for _, feed := range ep.FeedRouter.Feeds { 56 newDescriptions, err := feed.Describe(ctx) 57 if err != nil { 58 span.RecordError(err) 59 c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 60 return 61 } 62 63 for _, newDescription := range newDescriptions { 64 description := newDescription 65 feedDescriptions = append(feedDescriptions, &description) 66 } 67 } 68 69 span.SetAttributes(attribute.Int("feeds.length", len(feedDescriptions))) 70 71 feedGeneratorDescription := appbsky.FeedDescribeFeedGenerator_Output{ 72 Did: ep.FeedRouter.FeedActorDID.String(), 73 Feeds: feedDescriptions, 74 } 75 76 c.JSON(http.StatusOK, feedGeneratorDescription) 77} 78 79func (ep *Endpoints) GetFeedSkeleton(c *gin.Context) { 80 // Incoming requests should have a query parameter "feed" that looks like: 81 // at://did:web:feedsky.jazco.io/app.bsky.feed.generator/feed-name 82 // Also a query parameter "limit" that looks like: 50 83 // Also a query parameter "cursor" that is either the empty string 84 // or the cursor returned from a previous request 85 tracer := otel.Tracer("feed-generator") 86 ctx, span := tracer.Start(c.Request.Context(), "FeedGenerator:GetFeedSkeleton") 87 defer span.End() 88 89 // Get userDID from the request context, which is set by the auth middleware 90 userDID := c.GetString("user_did") 91 92 feedQuery := c.Query("feed") 93 if feedQuery == "" { 94 c.JSON(http.StatusBadRequest, gin.H{"error": "feed query parameter is required"}) 95 return 96 } 97 98 c.Set("feedQuery", feedQuery) 99 span.SetAttributes(attribute.String("feed.query", feedQuery)) 100 101 feedPrefix := "" 102 for _, acceptablePrefix := range ep.FeedRouter.AcceptableURIPrefixes { 103 if strings.HasPrefix(feedQuery, acceptablePrefix) { 104 feedPrefix = acceptablePrefix 105 break 106 } 107 } 108 109 if feedPrefix == "" { 110 c.JSON(http.StatusBadRequest, gin.H{"error": "this feed generator does not serve feeds for the given DID"}) 111 return 112 } 113 114 // Get the feed name from the query 115 feedName := strings.TrimPrefix(feedQuery, feedPrefix) 116 if feedName == "" { 117 c.JSON(http.StatusBadRequest, gin.H{"error": "feed name is required"}) 118 return 119 } 120 121 span.SetAttributes(attribute.String("feed.name", feedName)) 122 c.Set("feedName", feedName) 123 124 // Get the limit from the query, default to 50, maximum of 250 125 limit := int64(50) 126 limitQuery := c.Query("limit") 127 span.SetAttributes(attribute.String("feed.limit.raw", limitQuery)) 128 if limitQuery != "" { 129 parsedLimit, err := strconv.ParseInt(limitQuery, 10, 64) 130 if err != nil { 131 span.SetAttributes(attribute.Bool("feed.limit.failed_to_parse", true)) 132 limit = 50 133 } else { 134 limit = parsedLimit 135 if limit > 250 { 136 span.SetAttributes(attribute.Bool("feed.limit.clamped", true)) 137 limit = 250 138 } 139 } 140 } 141 142 span.SetAttributes(attribute.Int64("feed.limit.parsed", limit)) 143 144 // Get the cursor from the query 145 cursor := c.Query("cursor") 146 c.Set("cursor", cursor) 147 148 if ep.FeedRouter.FeedMap == nil { 149 c.JSON(http.StatusInternalServerError, gin.H{"error": "feed generator has no feeds configured"}) 150 return 151 } 152 153 feed, ok := ep.FeedRouter.FeedMap[feedName] 154 if !ok { 155 c.JSON(http.StatusNotFound, gin.H{"error": "feed not found"}) 156 return 157 } 158 159 // Get the feed items 160 feedItems, newCursor, err := feed.GetPage(ctx, feedName, userDID, limit, cursor) 161 if err != nil { 162 span.RecordError(err) 163 c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get feed items: %s", err.Error())}) 164 return 165 } 166 167 span.SetAttributes(attribute.Int("feed.items.length", len(feedItems))) 168 169 c.JSON(http.StatusOK, appbsky.FeedGetFeedSkeleton_Output{ 170 Feed: feedItems, 171 Cursor: newCursor, 172 }) 173}