Template of a custom feed generator service for the Bluesky network in Ruby
1require 'blue_factory/errors'
2require 'rainbow'
3require 'time'
4
5require_relative '../models/feed_post'
6
7class Feed
8 DEFAULT_LIMIT = 50
9 MAX_LIMIT = 100
10
11 # any unique number to use as a key in the database
12 def feed_id
13 raise NotImplementedError
14 end
15
16 def post_matches?(post)
17 raise NotImplementedError
18 end
19
20 # name of your feed, e.g. "What's Hot"
21 def display_name
22 raise NotImplementedError
23 end
24
25 # (optional) description of the feed, e.g. "Top trending content from the whole network"
26 def description
27 nil
28 end
29
30 # (optional) path of the feed avatar file
31 def avatar_file
32 nil
33 end
34
35 # (optional) special feed type (return :video for video feeds)
36 def content_mode
37 nil
38 end
39
40 # (optional) should posts be added to the feed from the firehose?
41 def is_updating?
42 true
43 end
44
45 # if the feed matches posts using keywords/regexps, highlight these keywords in the passed text
46 def colored_text(text)
47 text
48 end
49
50 def get_posts(params)
51 limit = check_query_limit(params)
52 query = FeedPost.where(feed_id: feed_id).joins(:post).select('posts.repo, posts.rkey, feed_posts.time, post_id')
53 .order('feed_posts.time DESC, post_id DESC').limit(limit)
54
55 if params[:cursor].to_s != ""
56 time, last_id = parse_cursor(params)
57 query = query.where("feed_posts.time < ? OR (feed_posts.time = ? AND post_id < ?)", time, time, last_id)
58 end
59
60 posts = query.to_a
61 last = posts.last
62
63 cursor = last && sprintf('%.06f', last.time.to_f) + ':' + last.post_id.to_s
64
65 { cursor: cursor, posts: posts.map { |p| 'at://' + p.repo + '/app.bsky.feed.post/' + p.rkey }}
66 end
67
68 # Use this version of the method when enable_unsafe_auth is set to true in app/config.rb.
69 # This method is called with the DID of the user viewing the feed decoded from the auth header.
70 #
71 # Important: BlueFactory does not verify auth signatures of the JWT token at the moment,
72 # which means that anyone can easily spoof the DID in the header - do not use for anything critical!
73 #
74 # def get_posts(params, visitor_did)
75 # # You can use this DID to e.g.:
76 # # 1) Provide a personalized feed:
77 #
78 # user = User.find_by(did: visitor_did)
79 # raise BlueFactory::AuthorizationError unless user
80 # build_feed_for_user(user)
81 #
82 # # 2) Only allow whitelisted users to view the feed and show it as empty to others:
83 # if visitor_did == BlueFactory.publisher_did || ALLOWED_USERS.include?(visitor_did)
84 # get_feed_posts(params)
85 # else
86 # { posts: [] }
87 # end
88 #
89 # # 3) Ban some users from accessing the feed:
90 # # (note: you can return a custom error message that will be shown in the error banner in the app)
91 # if BANNED_USERS.include?(visitor_did)
92 # raise BlueFactory::AuthorizationError, "You shall not pass!"
93 # else
94 # get_feed_posts(params)
95 # end
96 #
97 # # 4) Collect feed analytics (please take the privacy of your users into account)
98 # Stats.count_visit(visitor_did)
99 # end
100
101
102 private
103
104 def check_query_limit(params)
105 if params[:limit]
106 limit = params[:limit].to_i
107 (limit < 0) ? 0 : [limit, MAX_LIMIT].min
108 else
109 DEFAULT_LIMIT
110 end
111 end
112
113 def parse_cursor(params)
114 parts = params[:cursor].split(':')
115
116 if parts.length != 2 || parts[0] !~ /^\d+(\.\d+)?$/ || parts[1] !~ /^\d+$/
117 raise BlueFactory::InvalidRequestError.new("Malformed cursor")
118 end
119
120 sec = parts[0].to_i
121 usec = (parts[0].to_f * 1_000_000).to_i % 1_000_000
122 time = Time.at(sec, usec)
123
124 last_id = parts[1].to_i
125
126 [time, last_id]
127 end
128end