A demo of a Bluesky feed generator in Go
4
fork

Configure Feed

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

at main 244 lines 7.0 kB view raw
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}