A collection of Custom Bluesky Feeds, including Fresh Feeds, all under one roof
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}