A demo of a Bluesky feed generator in Go
1package feed
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "strconv"
12)
13
14const (
15 defaultLimit = 50
16)
17
18// FeedSkeletonReponse describes a response that will contain a skeleton feed
19type FeedSkeletonReponse struct {
20 Cursor string `json:"cursor"`
21 Feed []FeedSkeletonPost `json:"feed"`
22}
23
24// FeedSkeletonPost describes an individual post which is just the post URI
25type FeedSkeletonPost struct {
26 Post string `json:"post"`
27 FeedContext string `json:"feedContext"`
28}
29
30// HandleGetFeedSkeleton is the handler that will build up and return a feed response
31func (s *Server) HandleGetFeedSkeleton(w http.ResponseWriter, r *http.Request) {
32 slog.Debug("got request for feed skeleton", "host", r.RemoteAddr)
33
34 // if you need to get a feed based on the user making the request you can use this to get the callers DID.
35 // It's also a good idea to have this here incase you're getting spammed by non bluesky users - looking at you bots!
36 _, err := getRequestUserDID(r)
37 if err != nil {
38 slog.Error("validate user auth", "error", err)
39 http.Error(w, "validate auth", http.StatusUnauthorized)
40 return
41 }
42
43 params := r.URL.Query()
44
45 feed := params.Get("feed")
46 if feed == "" {
47 slog.Error("missing feed query param", "host", r.RemoteAddr)
48 http.Error(w, "missing feed query param", http.StatusBadRequest)
49 return
50 }
51 slog.Debug("request for feed", "feed", feed)
52
53 limit, err := limitFromParams(params)
54 if err != nil {
55 slog.Error("get limit from params", "error", err)
56 http.Error(w, "invalid limit query param", http.StatusBadRequest)
57 return
58 }
59 if limit < 1 || limit > 100 {
60 limit = defaultLimit
61 }
62
63 cursor := params.Get("cursor")
64
65 resp, err := s.getFeed(r.Context(), feed, cursor, limit)
66 if err != nil {
67 slog.Error("get feed", "error", err, "feed", feed)
68 http.Error(w, "error getting feed", http.StatusInternalServerError)
69 return
70 }
71
72 b, err := json.Marshal(resp)
73 if err != nil {
74 slog.Error("marshall error", "error", err, "host", r.RemoteAddr)
75 http.Error(w, "failed to encode resp", http.StatusInternalServerError)
76 return
77 }
78
79 w.Header().Set("Content-Type", "application/json")
80
81 _, _ = w.Write(b)
82}
83
84// DescribeFeedResponse is what's returned when the 'app.bsky.feed.describeFeedGenerator' endpoint is called
85type DescribeFeedResponse struct {
86 DID string `json:"did"`
87 Feeds []Feed `json:"feeds"`
88}
89
90// Feed describes the feed URI
91type Feed struct {
92 URI string `json:"uri"`
93}
94
95// HandleDescribeFeedGenerator handles the describe feed generator endpoint
96func (s *Server) HandleDescribeFeedGenerator(w http.ResponseWriter, r *http.Request) {
97 slog.Debug("got request for describe feed", "host", r.RemoteAddr)
98 resp := DescribeFeedResponse{
99 DID: fmt.Sprintf("did:web:%s", s.feedHost),
100 Feeds: []Feed{
101 {
102 URI: fmt.Sprintf("at://%s/app.bsky.feed.generator/%s", s.feedHost, s.feedName),
103 },
104 },
105 }
106
107 b, err := json.Marshal(resp)
108 if err != nil {
109 http.Error(w, "failed to encode resp", http.StatusInternalServerError)
110 return
111 }
112
113 _, _ = w.Write(b)
114}
115
116// FeedInteractions details the interactions that a user had with a feed when they viewed it
117type FeedInteractions struct {
118 Interactions []Interaction `json:"interactions"`
119}
120
121type Interaction struct {
122 Item string `json:"item"`
123 Event string `json:"event"`
124}
125
126// HandleFeedInteractions will handle when the client sends back a feed interaction so you can improve
127// the feed quality for the user
128func (s *Server) HandleFeedInteractions(w http.ResponseWriter, r *http.Request) {
129 slog.Debug("handle feed interactions")
130 userDID, err := getRequestUserDID(r)
131 if err != nil {
132 slog.Error("validate user auth", "error", err)
133 http.Error(w, "validate auth", http.StatusUnauthorized)
134 return
135 }
136
137 body, err := io.ReadAll(r.Body)
138 if err != nil {
139 slog.Error("read feed interactions request body", "error", err)
140 http.Error(w, "read body", http.StatusBadRequest)
141 return
142 }
143
144 var feedInteractions FeedInteractions
145 err = json.Unmarshal(body, &feedInteractions)
146 if err != nil {
147 slog.Error("decode feed interactions request body", "error", err)
148 http.Error(w, "decode body", http.StatusBadRequest)
149 return
150 }
151
152 // here is where you would likely do something with the data that is sent to you such as improving the
153 // data you store for a users feed
154 for _, interaction := range feedInteractions.Interactions {
155 slog.Info("interaction for user", "user", userDID, "item", interaction.Item, "interaction", interaction.Event)
156 }
157}
158
159// WellKnownResponse is what's returned on a well-known endpoint
160type WellKnownResponse struct {
161 Context []string `json:"@context"`
162 Id string `json:"id"`
163 Service []WellKnownService `json:"service"`
164}
165
166// WellKnownService describes the service returned on a well-known endpoint
167type WellKnownService struct {
168 Id string `json:"id"`
169 Type string `json:"type"`
170 ServiceEndpoint string `json:"serviceEndpoint"`
171}
172
173// HandleWellKnown handles returning a well-known endpoint
174func (s *Server) HandleWellKnown(w http.ResponseWriter, r *http.Request) {
175 slog.Debug("got request for well known", "host", r.RemoteAddr)
176 resp := WellKnownResponse{
177 Context: []string{"https://www.w3.org/ns/did/v1"},
178 Id: fmt.Sprintf("did:web:%s", s.feedHost),
179 Service: []WellKnownService{
180 {
181 Id: "#bsky_fg",
182 Type: "BskyFeedGenerator",
183 ServiceEndpoint: fmt.Sprintf("https://%s", s.feedHost),
184 },
185 },
186 }
187
188 b, err := json.Marshal(resp)
189 if err != nil {
190 http.Error(w, "failed to encode resp", http.StatusInternalServerError)
191 return
192 }
193
194 _, _ = w.Write(b)
195}
196
197func limitFromParams(params url.Values) (int, error) {
198 limitStr := params.Get("limit")
199 if limitStr == "" {
200 return 0, nil
201 }
202 limit, err := strconv.Atoi(limitStr)
203 if err != nil {
204 return 0, fmt.Errorf("parsing limit param: %w", err)
205 }
206 return limit, nil
207}
208
209func (s *Server) getFeed(ctx context.Context, feed, cursor string, limit int) (FeedSkeletonReponse, error) {
210 resp := FeedSkeletonReponse{
211 Feed: make([]FeedSkeletonPost, 0),
212 }
213
214 cursorInt, err := strconv.Atoi(cursor)
215 if err != nil && cursor != "" {
216 slog.Error("convert cursor to int", "error", err, "cursor value", cursor)
217 }
218 if cursorInt == 0 {
219 // if no cursor provided use a date waaaaay in the future to start the less than query
220 cursorInt = 9999999999999
221 }
222
223 posts, err := s.postStore.GetFeedPosts(cursorInt, limit)
224 if err != nil {
225 return resp, fmt.Errorf("get feed from DB: %w", err)
226 }
227
228 usersFeed := make([]FeedSkeletonPost, 0, len(posts))
229 for _, post := range posts {
230 usersFeed = append(usersFeed, FeedSkeletonPost{
231 Post: post.PostURI,
232 })
233 }
234
235 resp.Feed = usersFeed
236
237 // only set the return cursor if there was at least 1 record returned and that the len of records
238 // being returned is the same as the limit
239 if len(posts) > 0 && len(posts) == limit {
240 lastPost := posts[len(posts)-1]
241 resp.Cursor = fmt.Sprintf("%d", lastPost.CreatedAt)
242 }
243 return resp, nil
244}