blue_factory 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de68a9c6f0b159edc06d950cb3ad558537273ce95637a3c2b2ab183f0eeb52d9
4
- data.tar.gz: 609d5127c0ee34fbc19bca7b3cb37b01cbde365e05f24699e8cd4b0bd226d340
3
+ metadata.gz: a6c7d1d14d831467efed09597e81ee9e1b15a1c6581c06ec4853cc800f966da7
4
+ data.tar.gz: f4e819cdcd5877d97af4bae043859844426f5e43432688df7a4afddce87df0ce
5
5
  SHA512:
6
- metadata.gz: 25dee3b63c24fc48fce4b4aaa04ac8113c92e35df6464b8357053ef422fba14181c2cd3d7e26894fcbb14e736b029b2e5a3f45940d93fc294ddcbc52519f49bf
7
- data.tar.gz: bad39da56a7a4cec3217dbf507197dc23c2951e855d8501bb89babe8a0966e3809065cbf9a0e1c7c038c77e0d5764a04feb8e6815c931962d40cd1bb29d5c391
6
+ metadata.gz: 4bb21855d3f9803637d352581717c7e8720dfa82a55f6908eaf50852ca357ddca1c2f193bff5188dcb51d2b6bce0d15196c1e0799189a7c2fea5f8a01ccee559
7
+ data.tar.gz: b23b812347edb6bdc140293044fafe56e73ff457466fd91512e669eef42501c863cadd700231e2e1234b3962513b86249b6fb408674df18f0859f594be499eec
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.2.0] - 2025-10-16
2
+
3
+ Breaking changes / deprecations:
4
+
5
+ - changed `get_posts` API to (optionally) pass a `context` as the second param instead of `current_user`; context includes the user DID as `context.user.raw_did`, and will also include a verified DID in a future version
6
+ - `enable_unsafe_auth` option is deprecated – for now, `get_posts` will still work with `(params, current_user)` if that option is set
7
+ - `validate_responses` option is deprecated – responses are now always validated, also in production
8
+
9
+ Also:
10
+
11
+ - `get_posts` response can now return each post either as an AT URI string as before, or as a hash with URI in the `:post` field
12
+ - added support for post reasons (repost, pin) via `:reason` field in the post hash
13
+ - added support for post context info via `:context` field in the post hash and request ID info via `:req_id` in the response hash
14
+ - added `accepts_interactions` property to the feed API which lets it accept user interactions feedback ("show more/less" etc.)
15
+ - added support for accepting interactions sent to `app.bsky.feed.sendInteractions` via `on_interactions` handler
16
+
17
+ ## [0.1.6] - 2025-07-17
18
+
19
+ - detect PDS hostname automatically in the `bluesky:publish` Rake task
20
+
1
21
  ## [0.1.5] - 2025-03-20
2
22
 
3
23
  - added support for feed content mode field (video feeds)
data/README.md CHANGED
@@ -3,29 +3,31 @@
3
3
  A Ruby gem for hosting custom feeds for Bluesky.
4
4
 
5
5
  > [!NOTE]
6
- > ATProto Ruby gems collection: [skyfall](https://github.com/mackuba/skyfall) | [blue_factory](https://github.com/mackuba/blue_factory) | [minisky](https://github.com/mackuba/minisky) | [didkit](https://github.com/mackuba/didkit)
6
+ > ATProto Ruby gems collection: [skyfall](https://tangled.org/@mackuba.eu/skyfall) | [blue_factory](https://tangled.org/@mackuba.eu/blue_factory) | [minisky](https://tangled.org/@mackuba.eu/minisky) | [didkit](https://tangled.org/@mackuba.eu/didkit)
7
7
 
8
8
 
9
9
  ## What does it do
10
10
 
11
11
  BlueFactory is a Ruby library which helps you build a web service that hosts custom feeds a.k.a. "[feed generators](https://github.com/bluesky-social/feed-generator)" for the Bluesky social network. It implements a simple HTTP server based on [Sinatra](https://sinatrarb.com) which provides the required endpoints for the feed generator interface. You need to provide the content for the feed by making a query to your preferred local database.
12
12
 
13
- A feed server will usually be run together with a second piece of code that streams posts from the Bluesky "firehose" stream, runs them through some kind of filter and saves some or all of them to the database. To build that part, you can use my other Ruby gem [Skyfall](https://github.com/mackuba/skyfall).
13
+ A feed server will usually be run together with a second piece of code that streams posts from the Bluesky "firehose" stream, runs them through some kind of filter and saves some or all of them to the database. To build that part, you can use my other Ruby gem [Skyfall](https://tangled.org/@mackuba.eu/skyfall).
14
14
 
15
15
 
16
16
  ## Installation
17
17
 
18
- gem install blue_factory
18
+ Add this to your `Gemfile`:
19
+
20
+ gem 'blue_factory', '~> 0.2'
19
21
 
20
22
 
21
23
  ## Usage
22
24
 
23
25
  The server is configured through the `BlueFactory` module. The two required settings are:
24
26
 
25
- - `publisher_did` - DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`)
26
- - `hostname` - the hostname on which the feed service will be run
27
+ - `publisher_did` DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`)
28
+ - `hostname` the hostname on which the feed service will be run
27
29
 
28
- You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier that will appear at the end of the feed URI - it must only contain characters that are valid in URLs (preferably all lowercase) and it can't be longer than 15 characters. The object is anything that implements the single required method `get_posts` (could be a class, a module or an instance).
30
+ You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier (rkey) that will appear at the end of the feed URI it must only contain characters that are valid in URLs (preferably all lowercase) and it can't be longer than 15 characters. The feed object is anything that implements the single required method `get_posts` (could be a class, a module or an instance).
29
31
 
30
32
  So a simple setup could look like this:
31
33
 
@@ -39,31 +41,85 @@ BlueFactory.add_feed 'starwars', StarWarsFeed.new
39
41
  ```
40
42
 
41
43
 
42
- ### The feed object
44
+ ## The feed API
43
45
 
44
46
  The `get_posts` method of the feed object should:
45
47
 
46
48
  - accept a `params` argument which is a hash with fields: `:feed`, `:cursor` and `:limit` (the last two are optional)
47
- - optionally, accept a second `current_user` argument which is a string with the authenticated user's DID (depends on authentication config - [see below](#authentication))
48
- - return a hash with two fields: `:cursor` and `:posts`
49
+ - optionally, it can accept a second `context` argument with additional info like the authenticated user's DID (see "[Authentication](#authentication)")
50
+ - return a response hash with the posts data, with at least one key `:posts`
51
+
52
+
53
+ ### Parameters
49
54
 
50
- The `:feed` is the `at://` URI of the feed. The `:cursor` param, if included, should be a cursor returned by your feed from one of the previous requests, so it should be in the format used by the same function - but anyone can call the endpoint with any params, so you should validate it. The cursor is used for pagination to provide more pages further down in the feed (the first request to load the top of the feed doesn't include a cursor).
55
+ The `:feed` is the `at://` URI of the feed.
56
+
57
+ The `:cursor` param, if included, should be a cursor returned earlier by your feed from one of the previous requests, so it should be in the format used by the same function – but anyone can call the endpoint with any params, so you should validate it. The cursor is used for pagination to provide more pages further down in the feed (the first request to load the top of the feed doesn't include a cursor).
51
58
 
52
59
  The `:limit`, if included, should be a numeric value specifying the number of posts to return, and you should return at most that many posts in response. According to the spec, the maximum allowed value for the limit is 100, but again, you should verify this. The default limit is 50.
53
60
 
54
- The `:cursor` that you return is some kind of string that encodes the offset in the feed for a request for the next page. The structure of the cursor is something for you to decide, and it could possibly be a very long string (the actual length limit is uncertain). See the readme of the official [feed-generator repo](https://github.com/bluesky-social/feed-generator#pagination) for some guidelines on how to construct cursor strings.
61
+ ### Response
62
+
63
+ The `:posts` in the response hash that you return should be an array of URIs of posts. You only return the URI of a post to the Bluesky server, not all contents of the post like text and embed data – the server will "hydrate" the posts with all the other data from its own database.
64
+
65
+ The posts in the `get_posts` response from your feed object can be either:
66
+
67
+ - strings with the `at://` URI of a post
68
+ - hashes with the URI in the `:post` field and additional metadata
69
+
70
+ A combination of both is also allowed – some posts can be returned as URI strings, and some as hashes.
71
+
72
+ A response hash should also include a `:cursor`, which is some kind of string that encodes the offset in the feed, which will be passed back to you in a request for the next page. The structure of the cursor is something for you to decide, and it could possibly be a very long string (the actual length limit is uncertain). See the readme of the official [feed-generator repo](https://github.com/bluesky-social/feed-generator#pagination) for some guidelines on how to construct cursor strings. In practice, it's usually some combination of a timestamp in some form and/or an internal record ID, possibly with some separator like `:` or `-`.
73
+
74
+ The response can also include a `:req_id`, which is a "request ID" assigned to this specific request (again, the form of which is decided by you), which may be useful for processing [interactions](#handling-feed-interactions).
75
+
76
+ #### Post metadata:
55
77
 
56
- And finally, the `:posts` value should be an array of posts, returned as `at://` URI strings only. The Bluesky server that makes the request for the feed will provide all the other data for the posts based on the URIs you return.
78
+ If the post entry in `:posts` array is a hash, apart from the `:post` field with the URI it can include:
57
79
 
58
- If you determine that the request is somehow invalid (e.g. the cursor doesn't match what you expect), you can also raise a `BlueFactory::InvalidRequestError` error, which will return a JSON error message with status 400.
80
+ * `:context` some kind of internal metadata about this specific post in this specific response, e.g. identifying how this post ended up in that response, used for processing [interactions](#handling-feed-interactions)
81
+ * `:reason` – information about why this post is being displayed, which can be shown to the user; currently supported reasons are:
82
+ - `{ :repost => repost_uri }` – the post is displayed because someone reposted it (the uri points to a `app.bsky.feed.repost` record)
83
+ - `{ :pin => true }` – the post is pinned at the top of the feed
59
84
 
60
- An example implementation could look like this:
85
+ So the complete structure of your reponse in full form may look something like this:
86
+
87
+ ```rb
88
+ {
89
+ posts: [
90
+ {
91
+ post: "at://.../app.bsky.feed.post/...",
92
+ reason: { pin: true }
93
+ },
94
+ "at://.../app.bsky.feed.post/...",
95
+ "at://.../app.bsky.feed.post/...",
96
+ "at://.../app.bsky.feed.post/...",
97
+ {
98
+ post: "at://.../app.bsky.feed.post/...",
99
+ reason: { repost: "at://.../app.bsky.feed.repost/..." },
100
+ context: 'qweqweqwe'
101
+ },
102
+ "at://.../app.bsky.feed.post/...",
103
+ ...
104
+ ],
105
+ cursor: "1760639159",
106
+ req_id: "req2048"
107
+ }
108
+ ```
109
+
110
+ ### Error handling
111
+
112
+ If you determine that the request is somehow invalid (e.g. the cursor doesn't match what you expect), you can also raise a `BlueFactory::InvalidRequestError` error, which will return a JSON error message with status 400. The `message` of the exception might be shown to the user in an error banner.
113
+
114
+ ### Example code
115
+
116
+ A simple example implementation could look like this:
61
117
 
62
118
  ```rb
63
119
  require 'time'
64
120
 
65
121
  class StarWarsFeed
66
- def get_posts(params, current_user = nil)
122
+ def get_posts(params)
67
123
  limit = check_query_limit(params)
68
124
  query = Post.select('uri, time').order('time DESC').limit(limit)
69
125
 
@@ -90,7 +146,7 @@ class StarWarsFeed
90
146
  end
91
147
  ```
92
148
 
93
- ### Starting the server
149
+ ## Running the server
94
150
 
95
151
  The server itself is run using the `BlueFactory::Server` class, which is a subclass of `Sinatra::Base` and is used as described in the [Sinatra documentation](https://sinatrarb.com/intro.html) (as a "modular application").
96
152
 
@@ -128,24 +184,20 @@ server {
128
184
 
129
185
  ## Authentication
130
186
 
131
- Feeds are authenticated using a technology called [JSON Web Tokens](https://jwt.io). If a user is logged in, when they open, refresh or scroll down a feed in their app, requests are made to the feed service from the Bluesky network's IP address with user's authentication token in the `Authorization` HTTP header. (This is not the same kind of token as the access token that you use to make API calls - it does not let you perform any actions on user's behalf.)
187
+ Feeds are authenticated using a technology called [JSON Web Tokens](https://jwt.io). If a user is logged in, when they open, refresh or scroll down a feed in their app, requests are made to the feed service from the Bluesky network's IP address with user's authentication token in the `Authorization` HTTP header. (This is not the same kind of token as the access token that you use to make API calls it does not let you perform any actions on user's behalf.)
132
188
 
133
- At the moment, Blue Factory handles authentication in a very simplified way - it extracts the user's DID from the authentication header, but it does not verify the signature. This means that anyone with some programming knowledge can trivially prepare a fake token and make requests to the `getFeedSkeleton` endpoint as a different user.
189
+ At the moment, Blue Factory handles authentication in a very simplified way it extracts the user's DID from the authentication header, but it does not verify the signature. This means that anyone with some programming knowledge can trivially prepare a fake token and make requests to the `getFeedSkeleton` endpoint as a different user.
134
190
 
135
191
  As such, this authentication should not be used for anything critical. It may be used for things like logging, analytics, or as "security by obscurity" to just discourage others from accessing the feed in the app. You can also use this to build personalized feeds, as long as it's not a problem that the user DID may be fake.
136
192
 
137
- To use this simple authentication, set the `enable_unsafe_auth` option:
138
-
139
- ```rb
140
- BlueFactory.set :enable_unsafe_auth, true
141
- ```
193
+ To use this simple authentication, make a `get_posts` method that accepts two arguments: the second argument is a `context`, from which you can get user info via `context.user.raw_did`. `context.user.token` returns the whole Base64-encoded JWT token.
142
194
 
143
- The user's DID extracted from the token is passed as a second argument to `#get_posts`. You may, for example, return an empty list when the user is not authorized to use it:
195
+ So this way you could, for example, return an empty list when the user is not authorized to use it:
144
196
 
145
197
  ```rb
146
198
  class HiddenFeed
147
- def get_posts(params, current_user)
148
- if AUTHORIZED_USERS.include?(current_user)
199
+ def get_posts(params, context)
200
+ if AUTHORIZED_USERS.include?(context.user.raw_did)
149
201
  # ...
150
202
  else
151
203
  { posts: [] }
@@ -158,8 +210,8 @@ Alternatively, you can raise a `BlueFactory::AuthorizationError` with an optiona
158
210
 
159
211
  ```rb
160
212
  class HiddenFeed
161
- def get_posts(params, current_user)
162
- if AUTHORIZED_USERS.include?(current_user)
213
+ def get_posts(params, context)
214
+ if AUTHORIZED_USERS.include?(context.user.raw_did)
163
215
  # ...
164
216
  else
165
217
  raise BlueFactory::AuthorizationError, "You shall not pass!"
@@ -173,14 +225,14 @@ end
173
225
 
174
226
  ### Unauthenticated access
175
227
 
176
- Please note that the `current_user` may be nil - this will happen if the authentication header is not set at all. Since the [bsky.app](https://bsky.app) website is now open to the public and can be accessed without authentication, people can also access your feeds without being logged in.
228
+ Please note that there might not be any user information in the context – this will happen if the authentication header is not set at all. Since the [bsky.app](https://bsky.app) website can be accessed while logged out, people can also access your feeds this way. In that case, `context.user` will exist, but `context.user.token` and `context.user.raw_did` will be nil. You can also use the `context.has_auth?` method as a shortcut.
177
229
 
178
- If you want the feed to only be available to logged in users (even if it's a non-personalized feed), simply raise an `AuthorizationError` if `current_user` is nil:
230
+ If you want the feed to only be available to logged in users (even if it's a non-personalized feed), simply raise an `AuthorizationError` if there's no authentication info:
179
231
 
180
232
  ```rb
181
233
  class RestrictedFeed
182
- def get_posts(params, current_user)
183
- if current_user.nil?
234
+ def get_posts(params, context)
235
+ if !context.has_auth?
184
236
  raise BlueFactory::AuthorizationError, "Log in to see this feed"
185
237
  end
186
238
 
@@ -190,6 +242,51 @@ end
190
242
  ```
191
243
 
192
244
 
245
+ ## Handling feed interactions
246
+
247
+ If that makes sense in your feed, you can opt in to receiving "feed interaction" events from the users who view it. Interactions are either explicit actions that the user takes – they can press the "Show more like this" or "Show less like this" buttons in the context menu on a post they see in your feed – or implicit events that get sent automatically.
248
+
249
+ To receive interactions, your feed needs to opt in to that (the "Show more/less" buttons are only displayed in your feed if you do). This is done by setting an `acceptsInteractions` field in the feed generator record – in BlueFactory, you need to add an `accepts_interactions` property or method to your feed object and return `true` from it (and re-publish the feed if it was already live).
250
+
251
+ The interactions are sent to your feed by making a `POST` request to the `app.bsky.feed.sendInteractions` endpoint. BlueFactory passes these to you using a handler which you configure this way:
252
+
253
+ ```rb
254
+ BlueFactory.on_interactions do |interactions, context|
255
+ interactions.each do |i|
256
+ unless i.type == :seen
257
+ puts "[#{Time.now}] #{context.user.raw_did}: #{i.type} #{i.item}"
258
+ end
259
+ end
260
+ end
261
+ ```
262
+
263
+ or, alternatively:
264
+
265
+ ```rb
266
+ BlueFactory.interactions_handler = proc { ... }
267
+ ```
268
+
269
+ There is one shared handler for all the feeds you're hosting – to find out what a given interaction is about, you need to add the fields `:req_id` and/or `:context` to the feed response (see "[Feed API – Response](#response)").
270
+
271
+ An `Interaction` has such properties:
272
+
273
+ - `item` – at:// URI of a post the interaction is about
274
+ - `event` – name of the interaction type as specified in the lexicon, e.g. `app.bsky.feed.defs#requestLess`
275
+ - `context` – the context that was assigned in your response to this specific post
276
+ - `req_id` – the request ID that was assigned in your response to the request
277
+ - `type` – a short symbolic code of the interaction type
278
+
279
+ Currently enabled interaction types are:
280
+
281
+ - `:request_more` – user asked to see more posts like this
282
+ - `:request_less` – user asked to see fewer posts like this
283
+ - `:like` – user pressed like on the post
284
+ - `:repost` – user reposted the post
285
+ - `:reply` – user replied to the post
286
+ - `:quote` – user quoted the post
287
+ - `:seen` – user has seen the post (scrolled down to it)
288
+
289
+
193
290
  ## Additional configuration & customizing
194
291
 
195
292
  You can use the [Sinatra API](https://sinatrarb.com/intro.html#configuration) to do any additional configuration, like changing the server port, enabling/disabling logging and so on.
@@ -204,7 +301,7 @@ You can also add additional routes, e.g. to make a redirect or print something o
204
301
 
205
302
  ```rb
206
303
  BlueFactory::Server.get '/' do
207
- redirect 'https://github.com/mackuba/blue_factory'
304
+ redirect 'https://welcome.example.com'
208
305
  end
209
306
  ```
210
307
 
@@ -221,18 +318,19 @@ You also need to load your `BlueFactory` configuration and your feed classes her
221
318
 
222
319
  To publish the feed, you will need to provide some additional info about the feed, like its public name, through a few more methods in the feed object (the same one that responds to `#get_posts`):
223
320
 
224
- - `display_name` (required) - the publicly visible name of your feed, e.g. "WWDC 23" (should be something short)
225
- - `description` (optional) - a longer (~1-2 lines) description of what the feed does, displayed on the feed page as the "bio"
226
- - `avatar_file` (optional) - path to an avatar image from the project's root (PNG or JPG)
227
- - `content_mode` (optional) - return `:video` to create a video feed
321
+ - `display_name` (required) the publicly visible name of your feed, e.g. "Cat Pics" (should be something short)
322
+ - `description` (optional) a longer (~1-2 lines) description of what the feed does, displayed on the feed page as the "bio"
323
+ - `avatar_file` (optional) path to an avatar image from the project's root (PNG or JPG)
324
+ - `content_mode` (optional) return `:video` to create a video feed, which is displayed with a special layout
325
+ - `accepts_interactions` (optional) – return `true` to opt in to receiving [interactions](#handling-feed-interactions)
228
326
 
229
- When you're ready, run the rake task passing the feed key (you will be asked for the uploader account's password):
327
+ When you're ready, run the rake task passing the feed key (you will be asked for the uploader's account password or app password):
230
328
 
231
329
  ```
232
330
  bundle exec rake bluesky:publish KEY=wwdc
233
331
  ```
234
332
 
235
- For non-Bluesky PDSes, you need to also add an env var `SERVER_URL=https://your.pds.host`.
333
+ You also need to republish the feed by running the same task again any time you make changes to these properties and you want them to take effect.
236
334
 
237
335
 
238
336
  ## Credits
@@ -1,9 +1,11 @@
1
1
  require_relative 'modules/configurable'
2
+ require_relative 'modules/interactions'
2
3
  require_relative 'modules/feeds'
3
4
 
4
5
  module BlueFactory
5
6
  extend Configurable
6
7
  extend Feeds
8
+ extend Interactions
7
9
 
8
10
  def self.service_did
9
11
  'did:web:' + hostname
@@ -15,5 +17,20 @@ module BlueFactory
15
17
 
16
18
  configurable :publisher_did, :hostname, :validate_responses, :enable_unsafe_auth
17
19
 
18
- set :validate_responses, (environment != :production)
20
+ def self.set(property, value)
21
+ if property.to_sym == :enable_unsafe_auth
22
+ puts "==="
23
+ puts "WARNING: option :enable_unsafe_auth and old API get_posts(args, user_did) is deprecated and will be removed in version 0.3."
24
+ puts "Switch to get_posts(args, context) instead and get the user DID from: context.user.raw_did."
25
+ puts "==="
26
+ @enable_unsafe_auth = value
27
+ elsif property.to_sym == :validate_responses
28
+ puts "==="
29
+ puts "WARNING: option :validate_responses is deprecated and will be removed in version 0.3. " +
30
+ "Responses are now always validated, also in production."
31
+ puts "==="
32
+ else
33
+ super
34
+ end
35
+ end
19
36
  end
@@ -23,6 +23,9 @@ module BlueFactory
23
23
  class InvalidResponseError < StandardError
24
24
  end
25
25
 
26
+ class InvalidFeedClassError < StandardError
27
+ end
28
+
26
29
  class UnsupportedAlgorithmError < StandardError
27
30
  end
28
31
  end
@@ -0,0 +1,23 @@
1
+ module BlueFactory
2
+ class Interaction
3
+ EVENTS = {
4
+ 'app.bsky.feed.defs#interactionLike' => :like,
5
+ 'app.bsky.feed.defs#interactionQuote' => :quote,
6
+ 'app.bsky.feed.defs#interactionReply' => :reply,
7
+ 'app.bsky.feed.defs#interactionRepost' => :repost,
8
+ 'app.bsky.feed.defs#interactionSeen' => :seen,
9
+ 'app.bsky.feed.defs#requestLess' => :request_less,
10
+ 'app.bsky.feed.defs#requestMore' => :request_more
11
+ }
12
+
13
+ attr_reader :item, :event, :context, :req_id, :type
14
+
15
+ def initialize(data)
16
+ @item = data['item']
17
+ @event = data['event']
18
+ @context = data['feedContext']
19
+ @req_id = data['reqId']
20
+ @type = EVENTS[@event]
21
+ end
22
+ end
23
+ end
@@ -14,7 +14,7 @@ module BlueFactory
14
14
  if @properties.include?(property.to_sym)
15
15
  self.instance_variable_set("@#{property}", value)
16
16
  else
17
- raise NoMethodError
17
+ raise NoMethodError, "No such property: #{property}"
18
18
  end
19
19
  end
20
20
 
@@ -0,0 +1,9 @@
1
+ module BlueFactory
2
+ module Interactions
3
+ def on_interactions(&block)
4
+ @interactions_handler = block
5
+ end
6
+
7
+ attr_accessor :interactions_handler
8
+ end
9
+ end
@@ -0,0 +1,83 @@
1
+ require_relative 'errors'
2
+
3
+ module BlueFactory
4
+ class OutputGenerator
5
+ AT_URI_REGEXP = %r(^at://did:(plc:[a-z0-9]+|web:[a-z0-9\-]+(\.[a-z0-9\-]+)+)/app\.bsky\.feed\.post/[a-z0-9]+$)
6
+
7
+ def generate(response)
8
+ output = {}
9
+
10
+ raise InvalidResponseError, ":posts key is missing" unless response.has_key?(:posts)
11
+ raise InvalidResponseError, ":posts should be an array" unless response[:posts].is_a?(Array)
12
+
13
+ output[:feed] = response[:posts].map { |x| process_post_element(x) }
14
+
15
+ if cursor = response[:cursor]
16
+ output[:cursor] = cursor.to_s
17
+ end
18
+
19
+ if req_id = response[:req_id]
20
+ output[:reqId] = req_id.to_s
21
+ end
22
+
23
+ output
24
+ end
25
+
26
+ def process_post_element(object)
27
+ if object.is_a?(String)
28
+ validate_uri(object)
29
+ { post: object }
30
+ elsif object.is_a?(Hash)
31
+ process_post_hash(object)
32
+ else
33
+ raise InvalidResponseError, "Invalid post entry, expected string or hash: #{object.inspect}"
34
+ end
35
+ end
36
+
37
+ def process_post_hash(object)
38
+ post = {}
39
+
40
+ if object[:post]
41
+ validate_uri(object[:post])
42
+ post[:post] = object[:post]
43
+ else
44
+ raise InvalidResponseError, "Post hash is missing a :post key"
45
+ end
46
+
47
+ if object[:reason]
48
+ post[:reason] = process_post_reason(object[:reason])
49
+ end
50
+
51
+ if object[:context]
52
+ post[:feedContext] = object[:context].to_s
53
+ end
54
+
55
+ post
56
+ end
57
+
58
+ def process_post_reason(reason)
59
+ raise InvalidResponseError, "Invalid post reason: #{reason.inspect}" unless reason.is_a?(Hash)
60
+
61
+ if reason[:repost]
62
+ {
63
+ "$type" => "app.bsky.feed.defs#skeletonReasonRepost",
64
+ "repost" => reason[:repost]
65
+ }
66
+ elsif reason[:pin]
67
+ {
68
+ "$type" => "app.bsky.feed.defs#skeletonReasonPin"
69
+ }
70
+ else
71
+ raise InvalidResponseError, "Invalid post reason: #{reason.inspect}"
72
+ end
73
+ end
74
+
75
+ def validate_uri(uri)
76
+ if !uri.is_a?(String)
77
+ raise InvalidResponseError, "Post URI should be a string: #{uri.inspect}"
78
+ elsif uri !~ AT_URI_REGEXP
79
+ raise InvalidResponseError, "Invalid post URI: #{uri.inspect}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,23 @@
1
+ require_relative 'user_info'
2
+
3
+ module BlueFactory
4
+ class RequestContext
5
+ attr_accessor :request
6
+
7
+ def initialize(request)
8
+ @request = request
9
+ end
10
+
11
+ def env
12
+ @request.env
13
+ end
14
+
15
+ def user
16
+ UserInfo.new(env['HTTP_AUTHORIZATION'])
17
+ end
18
+
19
+ def has_auth?
20
+ env['HTTP_AUTHORIZATION'] != nil
21
+ end
22
+ end
23
+ end
@@ -1,14 +1,14 @@
1
- require 'base64'
2
1
  require 'json'
3
2
  require 'sinatra/base'
4
3
 
5
4
  require_relative 'configuration'
6
5
  require_relative 'errors'
6
+ require_relative 'interaction'
7
+ require_relative 'output_generator'
8
+ require_relative 'request_context'
7
9
 
8
10
  module BlueFactory
9
11
  class Server < Sinatra::Base
10
- AT_URI_REGEXP = %r(^at://did:plc:[a-z0-9]+/app\.bsky\.feed\.post/[a-z0-9]+$)
11
-
12
12
  configure do
13
13
  disable :static
14
14
  enable :quiet
@@ -37,83 +37,46 @@ module BlueFactory
37
37
  [status, JSON.generate({ error: name, message: message })]
38
38
  end
39
39
 
40
- def get_feed
41
- if params[:feed].to_s.empty?
42
- raise InvalidResponseError, "Error: Params must have the property \"feed\""
40
+ def get_feed(feed_uri)
41
+ if feed_uri.to_s.empty?
42
+ raise InvalidRequestError, "Error: Params must have the property \"feed\""
43
43
  end
44
44
 
45
- if params[:feed] !~ %r(^at://[\w\-\.\:]+/[\w\.]+/[\w\.\-]+$)
46
- raise InvalidResponseError, "Error: feed must be a valid at-uri"
45
+ if feed_uri !~ %r(^at://[\w\-\.\:]+/[\w\.]+/[\w\.\-]+$)
46
+ raise InvalidRequestError, "Error: feed must be a valid at-uri"
47
47
  end
48
48
 
49
- feed_key = params[:feed].split('/').last
49
+ feed_key = feed_uri.split('/').last
50
50
  feed = config.get_feed(feed_key)
51
51
 
52
- if feed.nil? || feed_uri(feed_key) != params[:feed]
52
+ if feed.nil? || feed_uri(feed_key) != feed_uri
53
53
  raise UnsupportedAlgorithmError, "Unsupported algorithm"
54
54
  end
55
55
 
56
56
  feed
57
57
  end
58
-
59
- def parse_did_from_token
60
- auth = env['HTTP_AUTHORIZATION']
61
-
62
- if auth.to_s.strip.empty?
63
- return nil
64
- end
65
-
66
- if !auth.start_with?('Bearer ')
67
- raise AuthorizationError, "Unsupported authorization method"
68
- end
69
-
70
- token = auth.gsub(/^Bearer /, '')
71
- parts = token.split('.')
72
- raise AuthorizationError.new("Invalid JWT format", "BadJwt") unless parts.length == 3
73
-
74
- begin
75
- payload = JSON.parse(Base64.decode64(parts[1]))
76
- payload['iss']
77
- rescue StandardError => e
78
- raise AuthorizationError.new("Invalid JWT format", "BadJwt")
79
- end
80
- end
81
-
82
- def validate_response(response)
83
- cursor = response[:cursor]
84
- raise InvalidResponseError, ":cursor key is missing" unless response.has_key?(:cursor)
85
- raise InvalidResponseError, ":cursor should be a string or nil" unless cursor.nil? || cursor.is_a?(String)
86
-
87
- posts = response[:posts]
88
- raise InvalidResponseError, ":posts key is missing" unless response.has_key?(:posts)
89
- raise InvalidResponseError, ":posts should be an array of strings" unless posts.is_a?(Array)
90
- raise InvalidResponseError, ":posts should be an array of strings" unless posts.all? { |x| x.is_a?(String) }
91
-
92
- if bad_uri = posts.detect { |x| x !~ AT_URI_REGEXP }
93
- raise InvalidResponseError, "Invalid post URI: #{bad_uri}"
94
- end
95
- end
96
58
  end
97
59
 
98
60
  get '/xrpc/app.bsky.feed.getFeedSkeleton' do
99
61
  begin
100
- feed = get_feed
62
+ feed = get_feed(params[:feed])
63
+ get_posts = feed.method(:get_posts)
101
64
  args = params.slice(:feed, :cursor, :limit)
102
65
 
103
66
  if config.enable_unsafe_auth
104
- did = parse_did_from_token
105
- response = feed.get_posts(args, did)
106
- else
67
+ context = RequestContext.new(request)
68
+ response = feed.get_posts(args, context.user.raw_did)
69
+ elsif get_posts.arity == 1
107
70
  response = feed.get_posts(args)
71
+ elsif get_posts.arity == 2
72
+ context = RequestContext.new(request)
73
+ response = feed.get_posts(args, context)
74
+ else
75
+ raise InvalidFeedClassError, "get_posts method has invalid API (arity #{get_posts.arity})"
108
76
  end
109
77
 
110
- validate_response(response) if config.validate_responses
111
-
112
- output = {}
113
- output[:feed] = response[:posts].map { |s| { post: s }}
114
- output[:cursor] = response[:cursor] if response[:cursor]
115
-
116
- return json_response(output)
78
+ json = OutputGenerator.new.generate(response)
79
+ return json_response(json)
117
80
  rescue InvalidRequestError => e
118
81
  return json_error(e.error_type || "InvalidRequest", e.message)
119
82
  rescue AuthorizationError => e
@@ -121,19 +84,24 @@ module BlueFactory
121
84
  rescue UnsupportedAlgorithmError => e
122
85
  return json_error("UnsupportedAlgorithm", e.message)
123
86
  rescue InvalidResponseError => e
124
- return json_error("InvalidResponse", e.message)
87
+ if settings.development?
88
+ return json_error("InvalidResponse", e.message, status: 500)
89
+ else
90
+ request.logger&.<< "#{e.class}: #{e.message}\n"
91
+ return json_error("InvalidResponse", "Feed response was invalid", status: 500)
92
+ end
125
93
  end
126
94
  end
127
95
 
128
96
  get '/xrpc/app.bsky.feed.describeFeedGenerator' do
129
- return json_response({
97
+ json_response({
130
98
  did: config.service_did,
131
99
  feeds: config.feed_keys.map { |f| { uri: feed_uri(f) }}
132
100
  })
133
101
  end
134
102
 
135
103
  get '/.well-known/did.json' do
136
- return json_response({
104
+ json_response({
137
105
  '@context': ['https://www.w3.org/ns/did/v1'],
138
106
  id: config.service_did,
139
107
  service: [
@@ -145,5 +113,18 @@ module BlueFactory
145
113
  ]
146
114
  })
147
115
  end
116
+
117
+ post '/xrpc/app.bsky.feed.sendInteractions' do
118
+ if config.interactions_handler
119
+ json = JSON.parse(request.body.read)
120
+ interactions = json['interactions'].map { |x| Interaction.new(x) }
121
+ context = RequestContext.new(request)
122
+
123
+ config.interactions_handler.call(interactions, context)
124
+ status 200
125
+ else
126
+ json_error('MethodNotImplemented', 'Method Not Implemented', status: 501)
127
+ end
128
+ end
148
129
  end
149
130
  end
@@ -22,6 +22,8 @@ namespace :bluesky do
22
22
  exit 1
23
23
  end
24
24
 
25
+ publisher_did = BlueFactory.publisher_did
26
+
25
27
  feed = BlueFactory.get_feed(feed_key)
26
28
 
27
29
  if feed.nil?
@@ -71,21 +73,29 @@ namespace :bluesky do
71
73
  avatar_data = File.read(avatar_file)
72
74
  end
73
75
 
74
- server = ENV['SERVER_URL'] || "https://bsky.social"
76
+ did_url = if publisher_did.start_with?('did:plc:')
77
+ "https://plc.directory/#{publisher_did}"
78
+ else
79
+ web_domain = did.gsub(/^did\:web\:/, '')
80
+ "https://#{web_domain}/.well-known/did.json"
81
+ end
82
+
83
+ did_json = BlueFactory::Net.get_request(did_url)
84
+ pds_host = did_json['service'].detect { |x| x['id'] == '#atproto_pds' }['serviceEndpoint']
75
85
 
76
- print "Enter password for your publisher account (#{BlueFactory.publisher_did}): "
86
+ print "Enter password for your publisher account (#{publisher_did}): "
77
87
  password = STDIN.noecho(&:gets).chomp
78
88
  puts
79
89
 
80
- json = BlueFactory::Net.post_request(server, 'com.atproto.server.createSession', {
81
- identifier: BlueFactory.publisher_did,
90
+ json = BlueFactory::Net.post_request(pds_host, 'com.atproto.server.createSession', {
91
+ identifier: publisher_did,
82
92
  password: password
83
93
  })
84
94
 
85
95
  access_token = json['accessJwt']
86
96
 
87
97
  if avatar_data
88
- json = BlueFactory::Net.post_request(server, 'com.atproto.repo.uploadBlob', avatar_data,
98
+ json = BlueFactory::Net.post_request(pds_host, 'com.atproto.repo.uploadBlob', avatar_data,
89
99
  content_type: encoding, auth: access_token)
90
100
 
91
101
  avatar_ref = json['blob']
@@ -100,9 +110,10 @@ namespace :bluesky do
100
110
 
101
111
  record[:avatar] = avatar_ref if avatar_ref
102
112
  record[:contentMode] = feed_content_mode if feed_content_mode
113
+ record[:acceptsInteractions] = true if feed.respond_to?(:accepts_interactions) && feed.accepts_interactions
103
114
 
104
- json = BlueFactory::Net.post_request(server, 'com.atproto.repo.putRecord', {
105
- repo: BlueFactory.publisher_did,
115
+ json = BlueFactory::Net.post_request(pds_host, 'com.atproto.repo.putRecord', {
116
+ repo: publisher_did,
106
117
  collection: BlueFactory::FEED_GENERATOR_TYPE,
107
118
  rkey: feed_key,
108
119
  record: record
@@ -1,10 +1,27 @@
1
1
  require 'json'
2
2
  require 'net/http'
3
+ require 'uri'
3
4
 
4
5
  module BlueFactory
5
6
  module Net
6
7
  class ResponseError < StandardError; end
7
8
 
9
+ def self.get_request(server, method = nil, params = nil, auth: nil)
10
+ headers = {}
11
+ headers['Authorization'] = "Bearer #{auth}" if auth
12
+
13
+ url = method ? URI("#{server}/xrpc/#{method}") : URI(server)
14
+
15
+ if params && !params.empty?
16
+ url.query = URI.encode_www_form(params)
17
+ end
18
+
19
+ response = ::Net::HTTP.get_response(url, headers)
20
+ raise ResponseError, "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
21
+
22
+ JSON.parse(response.body)
23
+ end
24
+
8
25
  def self.post_request(server, method, data, auth: nil, content_type: "application/json")
9
26
  headers = {}
10
27
  headers['Content-Type'] = content_type
@@ -0,0 +1,38 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ require_relative 'errors'
5
+
6
+ module BlueFactory
7
+ class UserInfo
8
+ def initialize(auth_header)
9
+ @auth = auth_header
10
+ end
11
+
12
+ def token
13
+ @token ||= begin
14
+ if @auth.nil? || @auth.strip.empty?
15
+ nil
16
+ elsif !@auth.start_with?('Bearer ')
17
+ raise AuthorizationError, "Unsupported authorization method"
18
+ else
19
+ @auth.gsub(/^Bearer /, '')
20
+ end
21
+ end
22
+ end
23
+
24
+ def raw_did
25
+ return nil if token.nil?
26
+
27
+ parts = token.split('.')
28
+ raise AuthorizationError.new("Invalid JWT format", "BadJwt") unless parts.length == 3
29
+
30
+ begin
31
+ payload = JSON.parse(Base64.decode64(parts[1]))
32
+ payload['iss']
33
+ rescue StandardError => e
34
+ raise AuthorizationError.new("Invalid JWT format", "BadJwt")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,3 @@
1
1
  module BlueFactory
2
- VERSION = "0.1.5"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blue_factory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-03-20 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: sinatra
@@ -40,12 +40,17 @@ files:
40
40
  - lib/blue_factory.rb
41
41
  - lib/blue_factory/configuration.rb
42
42
  - lib/blue_factory/errors.rb
43
+ - lib/blue_factory/interaction.rb
43
44
  - lib/blue_factory/modules/configurable.rb
44
45
  - lib/blue_factory/modules/feeds.rb
46
+ - lib/blue_factory/modules/interactions.rb
47
+ - lib/blue_factory/output_generator.rb
45
48
  - lib/blue_factory/rake.rb
49
+ - lib/blue_factory/request_context.rb
46
50
  - lib/blue_factory/server.rb
47
51
  - lib/blue_factory/tasks/publish.rake
48
52
  - lib/blue_factory/tasks/support.rb
53
+ - lib/blue_factory/user_info.rb
49
54
  - lib/blue_factory/version.rb
50
55
  - sig/blue_factory.rbs
51
56
  homepage: https://github.com/mackuba/blue_factory
@@ -69,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
74
  - !ruby/object:Gem::Version
70
75
  version: '0'
71
76
  requirements: []
72
- rubygems_version: 3.6.5
77
+ rubygems_version: 3.6.9
73
78
  specification_version: 4
74
79
  summary: A Ruby gem for hosting custom feeds for Bluesky
75
80
  test_files: []