minisky 0.4.0 → 0.5.1

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: b22e3b707a428f0381e5c194ce6b5bd4118f6b6dfb7f2d249140c8025e3b1dd7
4
- data.tar.gz: 2a6370ea6cb538c680252836295c411ff4c6df93b8c5aab9d8b73b843753febb
3
+ metadata.gz: 11cd25820ca8ab1f4cd6f9c94604a8229fd2f824695341ccd052aae62bc085c6
4
+ data.tar.gz: ecca2bbad124246a93e7090a3be0ace4675208ad1750d36adfdfa523cf9fd420
5
5
  SHA512:
6
- metadata.gz: 3f86def9480e86d2989a52f143eacee8e5b78fede45bd6ba49ea7ad54f1da88a55555421d3fdfdddad025bed21a708c830e5e9b2c351ba57e016234e200b9f74
7
- data.tar.gz: f8f7faa8d7e5df7807d6e9bb7ee1b87d1d5ab5503c0193fd031190be6e6346004618c66efdba96970fc93117d4f587c53ff5f17585a2ffa3ddafb503abc5a3cc
6
+ metadata.gz: 2169712a4d8596840ae0aa9ab891cfccee951f73a7fce146105503a69d647545b3ee0ed8f475bf65096d88226cf66c07a8637048a076c65e9363023877b8b55d
7
+ data.tar.gz: 2283843f151c6f03ce5a275195e21ff85e33085c06a6db56bc9301c205d97ef7ea86ff245b0f609e406c2d54ddaa732b2e72930aba5ad75e27dab2ef7c1bac1c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## Unreleased
2
+
3
+ The "really niche bugfix" edition:
4
+
5
+ * don't stop fetching in `fetch_all` if an empty page is returned but the cursor is not nil; it's technically allowed for the server to return an empty page but still have more data to send, e.g. if the service does some filtering at the last step and all records happen to be filtered out
6
+ - unfortunately, this on the other hand causes problems with some specific endpoints which incorrectly return a cursor when reaching the end of data, so you can also restore the previous behavior if needed, by setting the `stop_fetch_on_empty_page` option
7
+ * in `post_request`, don't set Content-Type to "application/json" if the data sent is a string or nil (it might cause an error in some cases, like when uploading some binary content)
8
+ * handle the (somewhat theoretical but possible) case where an access token is not a JWT but just some opaque blob – in that case, Minisky will now not throw an error trying to parse it, but just treat it as "unknown" and will not try to refresh it
9
+ - note: at the moment Minisky will not catch the "token expired" error and refresh the token automatically in such scenario
10
+ * allow connecting to non-HTTPS servers (e.g. `http://localhost:3000`)
11
+ * allow making unauthenticated clients with custom classes by returning `nil` from `#config`; custom clients with a config that's missing an `id` or `pass` are treated as an error
12
+ * deprecate logging in using an email address in the `id` field – `createSession` accepts such identifier, but unlike with handle or DID, there's no way to use it to look up the DID document and PDS location if we wanted to
13
+ * fixed URL query params in POST requests on Ruby 2.x
14
+ * marked `Minisky#active_repl?` method as private
15
+
16
+ Also added YARD API documentation for most of the code.
17
+
18
+ ## [0.5.0] - 2024-12-27 🎄
19
+
20
+ * `host` param in the initializer can be passed with a `https://` prefix (useful if you're passing it directly from a DID document, e.g. using DIDKit)
21
+ * added validation of the `method` parameter in request calls: it needs to be either a proper NSID, or a full URL as a string or a URI object
22
+ * added new optional `params` keyword argument in `post_request`, which lets you append query parameters to the URL if a POST endpoint requires passing them this way (e.g. `uploadVideo`)
23
+ * `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
24
+ * when experimenting with Minisky in the console, you can now skip the `field:` parameter to `fetch_all` if you don't remember the expected key name in the response, and the method will make a request and return an error which tells you the list of available keys
25
+ * added `access_token_expired?` helper method
26
+ * moved `token_expiration_date` to public methods
27
+ * `check_access` now returns a result symbol: `:logged_in`, `:refreshed` or `:ok`
28
+ * fixed `method_missing` setter on `User`
29
+
1
30
  ## [0.4.0] - 2024-03-31 🐣
2
31
 
3
32
  * allow passing non-JSON body to requests (e.g. when uploading blobs)
@@ -5,7 +34,7 @@
5
34
  * fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
6
35
  * allow passing options to the client in the initializer
7
36
  * aliased `default_progress` setting as `progress`
8
- * added `base64` dependency explicitly to the gemspec - fixes a warning in Ruby 3.3, since it will be extracted as an optional gem in 3.4
37
+ * added `base64` dependency explicitly to the gemspec fixes a warning in Ruby 3.3, since it will be extracted as an optional gem in 3.4
9
38
 
10
39
  ## [0.3.1] - 2023-10-10
11
40
 
@@ -16,11 +45,11 @@
16
45
  * authentication improvements & changes:
17
46
  - Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
18
47
  - `check_access` now just checks token's expiry time instead of making a request to `getSession`
19
- - added `send_auth_headers` option - set to `false` to not set auth header automatically, which is the default
20
- - removed default config file name - explicit file name is now required
21
- - Minisky can now be used in unauthenticated mode - pass `nil` as the config file name
48
+ - added `send_auth_headers` option set to `false` to not set auth header automatically, which is the default
49
+ - removed default config file name explicit file name is now required
50
+ - Minisky can now be used in unauthenticated mode pass `nil` as the config file name
22
51
  - added `reset_tokens` helper method
23
- * refactored response handling - typed errors are now raised on non-success response status
52
+ * refactored response handling typed errors are now raised on non-success response status
24
53
  * `user` wrapper can also be used for writing fields to the config
25
54
  * improved error handling
26
55
 
@@ -39,7 +68,7 @@
39
68
  * renamed `ident` field in the config hash to `id`
40
69
  * config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
41
70
  * config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
42
-
71
+
43
72
  ## [0.1.0] - 2023-09-01
44
73
 
45
74
  - extracted most code to a `Requests` module that can be included into a different client class with custom config handling
@@ -50,7 +79,7 @@
50
79
 
51
80
  ## [0.0.1] - 2023-08-30
52
81
 
53
- Initial release - extracted from original gist:
82
+ Initial release extracted from original gist:
54
83
 
55
84
  - logging in and refreshing the token
56
85
  - making GET & POST requests
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The zlib License
2
2
 
3
- Copyright (c) 2023 Jakub Suder
3
+ Copyright (c) 2026 Jakub Suder
4
4
 
5
5
  This software is provided 'as-is', without any express or implied
6
6
  warranty. In no event will the authors be held liable for any damages
data/README.md CHANGED
@@ -1,19 +1,24 @@
1
- # Minisky
1
+ # Minisky 🌤
2
2
 
3
3
  Minisky is a minimal client of the Bluesky (ATProto) API. It provides a simple API client class that you can use to log in to the Bluesky API and make any GET and POST requests there. It's meant to be an easy way to start playing and experimenting with the AT Protocol API.
4
4
 
5
+ This is designed as a low-level XRPC client library - it purposefully does not include any convenience methods like "get posts" or "get profile" etc., it only provides base components that you could use to build a higher level API.
6
+
7
+ > [!NOTE]
8
+ > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
9
+
5
10
 
6
11
  ## Installation
7
12
 
8
- To use Minisky, you need a reasonably new version of Ruby (2.6+). Such version should be preinstalled on macOS Big Sur and above and some Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux.
13
+ To use Minisky, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.2+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
9
14
 
10
15
  To install the Minisky gem, run the command:
11
16
 
12
17
  [sudo] gem install minisky
13
18
 
14
- Or alternatively, add it to the `Gemfile` file for Bundler:
19
+ Or add it to your app's `Gemfile`:
15
20
 
16
- gem 'minisky', '~> 0.3'
21
+ gem 'minisky', '~> 0.5'
17
22
 
18
23
 
19
24
  ## Usage
@@ -31,14 +36,21 @@ This allows you to do things like:
31
36
  - look up profile information about any account
32
37
  - load complete threads or users' profile feeds from the AppView
33
38
 
34
- To use Minisky this way, create a `Minisky` instance passing the API hostname string (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live) and `nil` as the configuration in the arguments:
39
+ To use Minisky this way, create a `Minisky` instance, passing the API hostname string and `nil` as the configuration in the arguments. Use the hostname `api.bsky.app` or `public.api.bsky.app` for the AppView, or a PDS hostname for the `com.atproto.*` raw data endpoints:
35
40
 
36
41
  ```rb
37
42
  require 'minisky'
38
43
 
39
- bsky = Minisky.new('bsky.social', nil)
44
+ bsky = Minisky.new('api.bsky.app', nil)
40
45
  ```
41
46
 
47
+ > [!NOTE]
48
+ > To call PDS endpoints like `getRecord` or `listRecords`, you need to connect to the PDS of the user whose data you're loading, not to yours (unless it's the same one). Alternatively, you can use the `bsky.social` "entryway" PDS hostname for any Bluesky-hosted accounts, but this will not work for self-hosted accounts.
49
+ >
50
+ > To look up the PDS hostname of a user given their handle or DID, you can use the [didkit](https://tangled.org/mackuba.eu/didkit) library.
51
+ >
52
+ > For the AppView, `api.bsky.app` connects directly to Bluesky's AppView, and `public.api.bsky.app` to a version with extra caching that will usually be faster.
53
+
42
54
 
43
55
  ### Authenticated access
44
56
 
@@ -53,9 +65,12 @@ pass: very-secret-password
53
65
 
54
66
  The `id` can be either your handle, or your DID, or the email you've used to sign up. It's recommended that you use the "app password" that you can create in the settings instead of your main account password.
55
67
 
68
+ > [!NOTE]
69
+ > Bluesky has recently implemented OAuth, but Minisky doesn't support it yet - it will be added in a future version. App passwords should still be supported for a fairly long time.
70
+
56
71
  After you log in, this file will also be used to store your access & request tokens and DID. The data in the config file can be accessed through a `user` wrapper property that exposes them as methods, e.g. the password is available as `user.pass` and the DID as `user.did`.
57
72
 
58
- Next, create the Minisky client instance, passing the server name and the config file name:
73
+ Next, create the Minisky client instance, passing your PDS hostname (for Bluesky-hosted PDSes, you can use either `bsky.social` or your specific PDS like `amanita.us-east.host.bsky.network`) and the name of the config file:
59
74
 
60
75
  ```rb
61
76
  require 'minisky'
@@ -93,7 +108,7 @@ bsky.post_request('com.atproto.repo.createRecord', {
93
108
 
94
109
  In authenticated mode, the requests use the saved access token for auth headers automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers for a given request, or `auth: sometoken` to use a specific other token. In unauthenticated mode, sending of auth headers is disabled.
95
110
 
96
- The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days, but not earlier:
111
+ The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days but not earlier:
97
112
 
98
113
  ```rb
99
114
  time_limit = Time.now - 86400 * 30
@@ -120,14 +135,14 @@ This will output a line like this:
120
135
  .................
121
136
  ```
122
137
 
123
- You can find more examples in the [example](https://github.com/mackuba/minisky/tree/master/example) directory.
138
+ You can find more examples on the [examples page](https://ruby.sdk.blue/examples/) on [ruby.sdk.blue](https://ruby.sdk.blue).
124
139
 
125
140
 
126
141
  ## Customization
127
142
 
128
143
  The `Minisky` client currently supports such configuration options:
129
144
 
130
- - `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
145
+ - `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `.` when in an interactive console, `nil` otherwise)
131
146
  - `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
132
147
  - `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
133
148
 
@@ -177,7 +192,7 @@ The class needs to provide:
177
192
 
178
193
  ## Credits
179
194
 
180
- Copyright © 2023 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
195
+ Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
181
196
 
182
197
  The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
183
198
 
@@ -0,0 +1,20 @@
1
+ require_relative 'minisky'
2
+
3
+ class Minisky
4
+
5
+ #
6
+ # Versions of {Requests#get_request} & {Requests#post_request} that work on Ruby 2.x.
7
+ #
8
+
9
+ module Ruby2Compat
10
+ def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
11
+ params ||= kwargs unless kwargs.empty?
12
+ super(method, params, auth: auth, headers: headers)
13
+ end
14
+
15
+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil, **kwargs)
16
+ data ||= kwargs unless kwargs.empty?
17
+ super(method, data, auth: auth, headers: headers, params: params)
18
+ end
19
+ end
20
+ end
@@ -1,18 +1,34 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
+
5
+ #
6
+ # Common base error class for Minisky errors.
7
+ #
4
8
  class Error < StandardError
5
9
  end
6
10
 
11
+ #
12
+ # Raised when a required token or credentials are missing or invalid.
13
+ #
7
14
  class AuthError < Error
8
- def initialize(message)
9
- super(message)
10
- end
11
15
  end
12
16
 
17
+ #
18
+ # Raised when the API returns an error status code.
19
+ #
13
20
  class BadResponse < Error
14
- attr_reader :status, :data
15
21
 
22
+ # @return [Integer] HTTP status code
23
+ attr_reader :status
24
+
25
+ # @return [String, Hash] response data (JSON hash or string)
26
+ attr_reader :data
27
+
28
+ # @param status [Integer] HTTP status code
29
+ # @param status_message [String] HTTP status message
30
+ # @param data [Hash, String] response data (JSON hash or string)
31
+ #
16
32
  def initialize(status, status_message, data)
17
33
  @status = status
18
34
  @data = data
@@ -26,30 +42,81 @@ class Minisky
26
42
  super(message)
27
43
  end
28
44
 
45
+ # @return [String, nil] machine-readable error code from the response data
29
46
  def error_type
30
47
  @data['error'] if @data.is_a?(Hash)
31
48
  end
32
49
 
50
+ # @return [String, nil] human-readable error message from the response data
33
51
  def error_message
34
52
  @data['message'] if @data.is_a?(Hash)
35
53
  end
36
54
  end
37
55
 
56
+ #
57
+ # Raised when the API returns a client error status code (4xx).
58
+ #
38
59
  class ClientErrorResponse < BadResponse
39
60
  end
40
61
 
62
+ #
63
+ # Raised when the API returns a server error status code (5xx).
64
+ #
41
65
  class ServerErrorResponse < BadResponse
42
66
  end
43
67
 
68
+ #
69
+ # Raised when the API returns an error indicating that the access or request
70
+ # token that was passed in the header is expired.
71
+ #
44
72
  class ExpiredTokenError < ClientErrorResponse
45
73
  end
46
74
 
75
+ #
76
+ # Raised when the API returns a redirect status code (3xx). Minisky doesn't
77
+ # currently follow any redirects.
78
+ #
47
79
  class UnexpectedRedirect < BadResponse
80
+
81
+ # @return [String] value of the "Location" header
48
82
  attr_reader :location
49
83
 
84
+ # @param status [Integer] HTTP status code
85
+ # @param status_message [String] HTTP status message
86
+ # @param location [String] value of the "Location" header
87
+ #
50
88
  def initialize(status, status_message, location)
51
89
  super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
52
90
  @location = location
53
91
  end
54
92
  end
93
+
94
+ #
95
+ # Raised by {Requests#fetch_all} when the `field` parameter isn't set.
96
+ #
97
+ # The message of the exception lists the fields available in the first fetched page.
98
+ #
99
+ # @example Making a request in the console with empty `field`
100
+ # sky = Minisky.new('public.api.bsky.app', nil)
101
+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
102
+ #
103
+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
104
+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
105
+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
106
+ #
107
+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
108
+ # # => .....
109
+ #
110
+ class FieldNotSetError < Error
111
+
112
+ # @return [Array<String>] list of fields in the response data
113
+ attr_reader :fields
114
+
115
+ # @param fields [Array<String>] list of fields in the response data
116
+ #
117
+ def initialize(fields)
118
+ @fields = fields
119
+ super("Field parameter not provided; available fields: #{@fields.inspect}")
120
+ end
121
+ end
55
122
  end
@@ -1,8 +1,47 @@
1
1
  require 'yaml'
2
2
 
3
+ #
4
+ # The default API client class for making requests to AT Protocol servers. Can be used
5
+ # with authentication – with the credentials stored in a YAML file – or without it, for
6
+ # unauthenticated requests only (by passing `nil` as the config file name).
7
+ #
8
+ # @example Authenticated client
9
+ # # Expects a config.yml file like:
10
+ # #
11
+ # # id: test.example.com
12
+ # # pass: secret7
13
+ # #
14
+ # # "id" can be a handle or a DID.
15
+ #
16
+ # sky = Minisky.new('eurosky.social', 'config.yml')
17
+ #
18
+ # feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
19
+ #
20
+ # @example Unauthenticated client
21
+ # sky = Minisky.new('public.api.bsky.app', nil, progress: '*')
22
+ #
23
+ # follows = sky.get_request('app.bsky.graph.getFollows',
24
+ # { actor: 'atproto.com', limit: 100 },
25
+ # field: 'follows'
26
+ # )
27
+ #
28
+
3
29
  class Minisky
4
- attr_reader :host, :config
5
30
 
31
+ # @return [String] the hostname (or base URL) of the server
32
+ attr_reader :host
33
+
34
+ # @return [Hash] loaded contents of the config file
35
+ attr_reader :config
36
+
37
+ # Creates a new client instance.
38
+ #
39
+ # @param host [String] the hostname (or base URL) of the server
40
+ # @param config_file [String, nil] path to the YAML config file, or `nil` for unauthenticated client
41
+ # @param options [Hash] option properties to set on the new instance (see {Minisky::Requests} properties)
42
+ #
43
+ # @raise [AuthError] if the config file is missing an ID or password
44
+ #
6
45
  def initialize(host, config_file, options = {})
7
46
  @host = host
8
47
  @config_file = config_file
@@ -14,9 +53,11 @@ class Minisky
14
53
  raise AuthError, "Missing user id or password in the config file #{@config_file}"
15
54
  end
16
55
  else
17
- @config = {}
18
- @send_auth_headers = false
19
- @auto_manage_tokens = false
56
+ @config = nil
57
+ end
58
+
59
+ if active_repl?
60
+ @default_progress = '.'
20
61
  end
21
62
 
22
63
  if options
@@ -29,6 +70,15 @@ class Minisky
29
70
  def save_config
30
71
  File.write(@config_file, YAML.dump(@config)) if @config_file
31
72
  end
73
+
74
+
75
+ private
76
+
77
+ def active_repl?
78
+ return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
79
+ return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
80
+ false
81
+ end
32
82
  end
33
83
 
34
84
  require_relative 'requests'
@@ -13,12 +13,16 @@ class Minisky
13
13
  @config = config
14
14
  end
15
15
 
16
+ def has_credentials?
17
+ !!(id && pass)
18
+ end
19
+
16
20
  def logged_in?
17
21
  !!(access_token && refresh_token)
18
22
  end
19
23
 
20
24
  def method_missing(name, *args)
21
- if name.end_with?('=')
25
+ if name.to_s.end_with?('=')
22
26
  @config[name.to_s.chop] = args[0]
23
27
  else
24
28
  @config[name.to_s]
@@ -26,35 +30,113 @@ class Minisky
26
30
  end
27
31
  end
28
32
 
33
+ # Regexp for NSID identifiers, used in lexicon names for record collection and API endpoints
34
+ NSID_REGEXP = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/
35
+
36
+ #
37
+ # This module contains most of the Minisky code for making HTTP requests and managing
38
+ # authentication tokens. The module is included into the {Minisky} API client class and you'll
39
+ # normally use it through that class, but you can also include it into your custom class if you
40
+ # want to implement the data storage differently than using a local YAML file as {Minisky} does.
41
+ #
42
+
29
43
  module Requests
44
+
45
+ # A character to print before each request in {#fetch_all} as a progress indicator.
46
+ # Can also be passed explicitly instead or overridden using the `progress:` parameter.
47
+ # Default is `'.'` when running inside IRB, and `nil` otherwise.
48
+ #
49
+ # @return [String, nil]
50
+ #
30
51
  attr_accessor :default_progress
52
+
53
+ # By default, when `fetch_all` receives a response with an empty page with no records but
54
+ # which includes a cursor for the next page, it keeps fetching until it receives a response
55
+ # with null cursor. This is more technically correct, but can cause problems with some
56
+ # non-compliant APIs, so you can set this option to stop fetching when an empty page
57
+ # is received.
58
+ #
59
+ # @return [Boolean]
60
+ #
61
+ attr_accessor :stop_fetch_on_empty_page
62
+
31
63
  attr_writer :send_auth_headers
32
64
  attr_writer :auto_manage_tokens
33
65
 
66
+ # Tells whether to set authentication headers automatically (default: true if there
67
+ # is a user config).
68
+ #
69
+ # If false, you will need to pass `auth: 'sometoken'` explicitly to requests that
70
+ # require authentication.
71
+ #
72
+ # @return [Boolean] whether to set authentication headers in requests
73
+ #
34
74
  def send_auth_headers
35
- instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : true
75
+ instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : (config != nil)
36
76
  end
37
77
 
78
+ # Tells whether the library should manage the access & refresh tokens automatically
79
+ # for you (default: true if there is a user config).
80
+ #
81
+ # If true, {#check_access} is called before each request to make sure that there is a
82
+ # fresh access token available; if false, you will need to call {#log_in} and
83
+ # {#perform_token_refresh} manually when needed.
84
+ #
85
+ # @return [Boolean] whether to automatically manage access tokens
86
+ #
38
87
  def auto_manage_tokens
39
- instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
88
+ instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : (config != nil)
40
89
  end
41
90
 
42
91
  alias progress default_progress
43
92
  alias progress= default_progress=
44
93
 
45
94
  def base_url
46
- @base_url ||= "https://#{host}/xrpc"
95
+ if host.include?('://')
96
+ host.chomp('/') + '/xrpc'
97
+ else
98
+ "https://#{host}/xrpc"
99
+ end
47
100
  end
48
101
 
49
102
  def user
50
- @user ||= User.new(config)
103
+ @user ||= config && User.new(config)
51
104
  end
52
105
 
106
+ # Sends a GET request to the service's API.
107
+ #
108
+ # @param method [String, URI] an XRPC endpoint name or a full URL
109
+ # @param params [Hash, nil] query parameters
110
+ #
111
+ # @param auth [Boolean, String]
112
+ # boolean value which tells whether to send an auth header with the access token or not,
113
+ # or an explicit bearer token to use
114
+ # @param headers [Hash, nil]
115
+ # additional headers to include
116
+ #
117
+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
118
+ #
119
+ # @raise [ArgumentError] if method name is invalid
120
+ # @raise [BadResponse] if the HTTP response has an error status code
121
+ # @raise [AuthError]
122
+ # - if logging in is required, but login or password isn't provided
123
+ # - if token refresh is needed, but refresh token is missing
124
+ # - if a token has invalid format
125
+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
126
+ #
127
+ # @example Unauthenticated call
128
+ # sky = Minisky.new('public.api.bsky.app', nil)
129
+ # profile = sky.get_request('app.bsky.actor.getProfile', { actor: 'ec.europa.eu' })
130
+ #
131
+ # @example Authenticated call
132
+ # sky = Minisky.new('blacksky.app', 'config.yml')
133
+ # feed = sky.get_request('app.bsky.feed.getTimeline', { limit: 100 })
134
+
53
135
  def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
54
136
  check_access if auto_manage_tokens && auth == true
55
137
 
56
138
  headers = authentication_header(auth).merge(headers || {})
57
- url = URI("#{base_url}/#{method}")
139
+ url = build_request_uri(method)
58
140
 
59
141
  if params && !params.empty?
60
142
  url.query = URI.encode_www_form(params)
@@ -66,24 +148,133 @@ class Minisky
66
148
  handle_response(response)
67
149
  end
68
150
 
69
- def post_request(method, params = nil, auth: default_auth_mode, headers: nil)
151
+ # Sends a POST request to the service's API.
152
+ #
153
+ # @param method [String, URI] an XRPC endpoint name or a full URL
154
+ # @param data [Hash, String, nil] JSON or string data to send
155
+ #
156
+ # @param auth [Boolean, String]
157
+ # boolean value which tells whether to send an auth header with the access token or not,
158
+ # or an explicit bearer token to use
159
+ # @param headers [Hash, nil]
160
+ # additional headers to include
161
+ # @param params [Hash, nil]
162
+ # query parameters to append to the URL
163
+ #
164
+ # @return [Hash, String] parsed JSON hash for JSON responses, or raw response body otherwise
165
+ #
166
+ # @raise [ArgumentError] if method name is invalid
167
+ # @raise [BadResponse] if the HTTP response has an error status code
168
+ # @raise [AuthError]
169
+ # - if logging in is required, but login or password isn't provided
170
+ # - if token refresh is needed, but refresh token is missing
171
+ # - if a token has invalid format
172
+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
173
+ #
174
+ # @example Making a Bluesky post
175
+ # sky = Minisky.new('lab.martianbase.net', 'config.yml')
176
+ #
177
+ # sky.post_request('com.atproto.repo.createRecord', {
178
+ # repo: sky.user.did,
179
+ # collection: 'app.bsky.feed.post',
180
+ # record: {
181
+ # text: "Hello Bluesky!",
182
+ # createdAt: Time.now.iso8601,
183
+ # langs: ['en']
184
+ # }
185
+ # })
186
+
187
+ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
70
188
  check_access if auto_manage_tokens && auth == true
71
189
 
72
190
  headers = authentication_header(auth).merge(headers || {})
73
- headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
74
191
 
75
- body = if params.is_a?(String) || params.nil?
76
- params.to_s
192
+ if data.is_a?(String) || data.nil?
193
+ body = data.to_s
77
194
  else
78
- params.to_json
195
+ body = data.to_json
196
+ headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
79
197
  end
80
198
 
81
- response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
199
+ url = build_request_uri(method)
200
+
201
+ if params && !params.empty?
202
+ url.query = URI.encode_www_form(params)
203
+ end
204
+
205
+ response = Net::HTTP.post(url, body, headers)
82
206
  handle_response(response)
83
207
  end
84
208
 
85
- def fetch_all(method, params = nil, field:,
86
- auth: default_auth_mode, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
209
+ # Fetches and merges paginated responses from a service's endpoint in a loop, updating the
210
+ # cursor after each page, until the cursor is nil or a break condition is met. The data is
211
+ # extracted from a designated field of the response (`field`) and added to a single array,
212
+ # which is returned at the end.
213
+ #
214
+ # A condition for when the fetching should stop can be passed as a block in `break_when`, or
215
+ # alternatively, a max number of pages can be passed to `max_pages` (or both together). If
216
+ # neither is set, the fetching continues until the server returns an empty cursor.
217
+ #
218
+ # When experimenting in the Ruby console, you can pass `nil` as `field` (or skip the parameter)
219
+ # to make a single request and raise an exception, which will tell you what fields are available.
220
+ #
221
+ # @param method [String, URI] an XRPC endpoint name or a full URL
222
+ # @param params [Hash, nil] query parameters
223
+ #
224
+ # @param auth [Boolean, String]
225
+ # boolean value which tells whether to send an auth header with the access token or not,
226
+ # or an explicit bearer token to use
227
+ # @param field [String, nil]
228
+ # name of the field in the responses which contains the data array
229
+ # @param break_when [Proc, nil]
230
+ # if passed, the fetching will stop when the block returns true for any of the
231
+ # returned records, and records matching the condition will be deleted from the last page
232
+ # @param max_pages [Integer, nil]
233
+ # maximum number of pages to fetch
234
+ # @param headers [Hash, nil]
235
+ # additional headers to include
236
+ # @param progress [String, nil]
237
+ # a character to print before each request as a progress indicator
238
+ #
239
+ # @return [Array] records or objects collected from all pages
240
+ #
241
+ # @raise [ArgumentError] if method name is invalid
242
+ # @raise [FieldNotSetError] if field parameter wasn't set (the message tells you what fields were in the response)
243
+ # @raise [BadResponse] if the HTTP response has an error status code
244
+ # @raise [AuthError]
245
+ # - if logging in is required, but login or password isn't provided
246
+ # - if token refresh is needed, but refresh token is missing
247
+ # - if a token has invalid format
248
+ # - if required access token is missing, and {#auto_manage_tokens} is disabled
249
+ #
250
+ # @example Fetching with a `break_when` block
251
+ # sky = Minisky.new('public.api.bsky.app', nil)
252
+ # time_limit = Time.now - 86400 * 30
253
+ #
254
+ # sky.fetch_all('app.bsky.feed.getAuthorFeed',
255
+ # { actor: 'pfrazee.com', limit: 100 },
256
+ # field: 'feed',
257
+ # progress: '|',
258
+ # break_when: ->(x) { Time.at(x['post']['record']['createdAt']) < time_limit }
259
+ # )
260
+ #
261
+ # @example Fetching with `max_pages`
262
+ # sky = Minisky.new('tngl.sh', 'config.yml')
263
+ # sky.fetch_all('app.bsky.feed.getTimeline', { limit: 100 }, field: 'feed', max_pages: 10)
264
+ #
265
+ # @example Making a request in the console with empty `field`
266
+ # sky = Minisky.new('public.api.bsky.app', nil)
267
+ # # => #<Minisky:0x0000000120f5f6b0 @host="public.api.bsky.app", ...>
268
+ #
269
+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' })
270
+ # # ./lib/minisky/requests.rb:270:in 'block in Minisky::Requests#fetch_all':
271
+ # # Field parameter not provided; available fields: ["followers"] (Minisky::FieldNotSetError)
272
+ #
273
+ # sky.fetch_all('app.bsky.graph.getFollowers', { actor: 'sdk.blue' }, field: 'followers')
274
+ # # => .....
275
+
276
+ def fetch_all(method, params = nil, auth: default_auth_mode,
277
+ field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
87
278
  data = []
88
279
  params = {} if params.nil?
89
280
  pages = 0
@@ -92,6 +283,11 @@ class Minisky
92
283
  print(progress) if progress
93
284
 
94
285
  response = get_request(method, params, auth: auth, headers: headers)
286
+
287
+ if field.nil?
288
+ raise FieldNotSetError, response.keys.select { |f| response[f].is_a?(Array) }
289
+ end
290
+
95
291
  records = response[field]
96
292
  cursor = response['cursor']
97
293
 
@@ -99,7 +295,7 @@ class Minisky
99
295
  params[:cursor] = cursor
100
296
  pages += 1
101
297
 
102
- break if !cursor || records.empty? || pages == max_pages
298
+ break if !cursor || pages == max_pages || (stop_fetch_on_empty_page && records.empty?)
103
299
  break if break_when && records.any? { |x| break_when.call(x) }
104
300
  end
105
301
 
@@ -107,16 +303,64 @@ class Minisky
107
303
  data
108
304
  end
109
305
 
306
+ # Ensures that the user has a fresh access token, by checking the access token's expiry date
307
+ # and performing a refresh if needed, or by logging in with a password if no tokens are present.
308
+ #
309
+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
310
+ # before {#get_request}, {#post_request} and {#fetch_all}, so you generally don't need to call it
311
+ # yourself.
312
+ #
313
+ # @return [Symbol]
314
+ # - `:logged_in` if a login using a password was performed
315
+ # - `:refreshed` if the access token was expired and was refreshed
316
+ # - `:ok` if no refresh was needed
317
+ # - `:unknown` if the token is not a valid JWT (e.g. an opaque blob)
318
+ #
319
+ # @raise [BadResponse] if login or refresh returns an error status code
320
+ # @raise [AuthError]
321
+ # - if the client doesn't include user config at all
322
+ # - if logging in is required, but login or password isn't provided
323
+ # - if token refresh is needed, but refresh token is missing
324
+
110
325
  def check_access
111
- if !user.logged_in?
326
+ if !user
327
+ raise AuthError, "User config is missing"
328
+ elsif !user.has_credentials?
329
+ raise AuthError, "User id or password is missing"
330
+ elsif !user.logged_in?
112
331
  log_in
113
- elsif token_expiration_date(user.access_token) < Time.now + 60
332
+ return :logged_in
333
+ end
334
+
335
+ begin
336
+ expired = access_token_expired?
337
+ rescue AuthError
338
+ return :unknown
339
+ end
340
+
341
+ if expired
114
342
  perform_token_refresh
343
+ :refreshed
344
+ else
345
+ :ok
115
346
  end
116
347
  end
117
348
 
349
+ # Logs in the user using an ID and password stored in the config by calling the
350
+ # `createSession` endpoint, and stores the received access & refresh tokens.
351
+ #
352
+ # This is generally handled automatically by {#check_access}. Calling this method
353
+ # repeatedly many times in a short period of time may use up your rate limit for this
354
+ # endpoint (which is lower than for others) and make it inaccessible to you for some
355
+ # time.
356
+ #
357
+ # @return [Hash] the response JSON with access tokens
358
+ #
359
+ # @raise [AuthError] if login or password are missing
360
+ # @raise [BadResponse] if the server responds with an error status code
361
+
118
362
  def log_in
119
- if user.id.nil? || user.pass.nil?
363
+ if user.nil? || !user.has_credentials?
120
364
  raise AuthError, "To log in, please provide a user id and password"
121
365
  end
122
366
 
@@ -125,6 +369,11 @@ class Minisky
125
369
  password: user.pass
126
370
  }
127
371
 
372
+ if user.id =~ /\A[^@]+@[^@]+\z/
373
+ STDERR.puts "Warning: logging in using an email address is deprecated in Minisky and will be " +
374
+ "removed in a future version. Use either a handle or a DID instead."
375
+ end
376
+
128
377
  json = post_request('com.atproto.server.createSession', data, auth: false)
129
378
 
130
379
  user.did = json['did']
@@ -135,8 +384,19 @@ class Minisky
135
384
  json
136
385
  end
137
386
 
387
+ # Refreshes the access token using the stored refresh token. If successful, this
388
+ # invalidates *both* old tokens and replaces them with new ones from the response.
389
+ #
390
+ # If {#auto_manage_tokens} is enabled (the default setting), this method is automatically called
391
+ # before any requests through {#check_access}, so you generally don't need to call it yourself.
392
+ #
393
+ # @return [Hash] the response JSON with access tokens
394
+ #
395
+ # @raise [AuthError] if the refresh token is missing
396
+ # @raise [BadResponse] if the server responds with an error status code
397
+
138
398
  def perform_token_refresh
139
- if user.refresh_token.nil?
399
+ if user&.refresh_token.nil?
140
400
  raise AuthError, "Can't refresh access token - refresh token is missing"
141
401
  end
142
402
 
@@ -149,7 +409,64 @@ class Minisky
149
409
  json
150
410
  end
151
411
 
412
+ # Attempts to parse a given token as JWT and extract the expiration date from the payload.
413
+ # An access token technically isn't required to be a (valid) JWT, so if the parsing fails
414
+ # for whatever reason, nil is returned.
415
+ #
416
+ # @return [Time, nil] parsed expiration time, or nil if token is not a valid JWT
417
+
418
+ def token_expiration_date(token)
419
+ return nil unless token.valid_encoding?
420
+
421
+ parts = token.split('.')
422
+ return nil unless parts.length == 3
423
+
424
+ begin
425
+ payload = JSON.parse(Base64.decode64(parts[1]))
426
+ rescue JSON::ParserError
427
+ return nil
428
+ end
429
+
430
+ exp = payload['exp']
431
+ return nil unless exp.is_a?(Numeric) && exp > 0
432
+
433
+ time = Time.at(exp)
434
+ return nil if time.year < 2023 || time.year > 2100
435
+
436
+ time
437
+ end
438
+
439
+ # Attempts to parse the user's access token as JWT, extract the expiration date from the
440
+ # payload, and check if the token hasn't expired yet.
441
+ #
442
+ # @return [Boolean] true if the token's expiration time is more than a minute away
443
+ # @raise [AuthError] if the token is not a valid JWT, or user is not logged in
444
+
445
+ def access_token_expired?
446
+ if user&.access_token.nil?
447
+ raise AuthError, "No access token (user is not logged in)"
448
+ end
449
+
450
+ exp_date = token_expiration_date(user.access_token)
451
+
452
+ if exp_date
453
+ exp_date < Time.now + 60
454
+ else
455
+ raise AuthError, "Token expiration date cannot be decoded"
456
+ end
457
+ end
458
+
459
+ #
460
+ # Clear stored access and refresh tokens, effectively logging out the user.
461
+ #
462
+ # @raise [AuthError] if the client doesn't have a user config
463
+ #
464
+
152
465
  def reset_tokens
466
+ if !user
467
+ raise AuthError, "User config is missing"
468
+ end
469
+
153
470
  user.access_token = nil
154
471
  user.refresh_token = nil
155
472
  save_config
@@ -157,19 +474,8 @@ class Minisky
157
474
  end
158
475
 
159
476
  if RUBY_VERSION.to_i == 2
160
- alias_method :do_get_request, :get_request
161
- alias_method :do_post_request, :post_request
162
- private :do_get_request, :do_post_request
163
-
164
- def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
165
- params ||= kwargs unless kwargs.empty?
166
- do_get_request(method, params, auth: auth, headers: headers)
167
- end
168
-
169
- def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
170
- params ||= kwargs unless kwargs.empty?
171
- do_post_request(method, params, auth: auth, headers: headers)
172
- end
477
+ require_relative 'compat'
478
+ prepend Ruby2Compat
173
479
  end
174
480
 
175
481
 
@@ -177,11 +483,23 @@ class Minisky
177
483
 
178
484
  def make_request(request)
179
485
  # this long form is needed because #get_response only supports a headers param in Ruby 3.x
180
- response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: true) do |http|
486
+ response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: (request.uri.scheme == 'https')) do |http|
181
487
  http.request(request)
182
488
  end
183
489
  end
184
490
 
491
+ def build_request_uri(method)
492
+ if method.is_a?(URI)
493
+ method
494
+ elsif method.include?('://')
495
+ URI(method)
496
+ elsif method =~ NSID_REGEXP
497
+ URI("#{base_url}/#{method}")
498
+ else
499
+ raise ArgumentError, "Invalid method name #{method.inspect} (should be an NSID, URL or an URI object)"
500
+ end
501
+ end
502
+
185
503
  def default_auth_mode
186
504
  !!send_auth_headers
187
505
  end
@@ -190,7 +508,7 @@ class Minisky
190
508
  if auth.is_a?(String)
191
509
  { 'Authorization' => "Bearer #{auth}" }
192
510
  elsif auth
193
- if user.access_token
511
+ if user&.access_token
194
512
  { 'Authorization' => "Bearer #{user.access_token}" }
195
513
  else
196
514
  raise AuthError, "Can't send auth headers, access token is missing"
@@ -200,22 +518,6 @@ class Minisky
200
518
  end
201
519
  end
202
520
 
203
- def token_expiration_date(token)
204
- parts = token.split('.')
205
- raise AuthError, "Invalid access token format" unless parts.length == 3
206
-
207
- begin
208
- payload = JSON.parse(Base64.decode64(parts[1]))
209
- rescue JSON::ParserError
210
- raise AuthError, "Couldn't decode payload from access token"
211
- end
212
-
213
- exp = payload['exp']
214
- raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
215
-
216
- Time.at(exp)
217
- end
218
-
219
521
  def handle_response(response)
220
522
  status = response.code.to_i
221
523
  message = response.message
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minisky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-03-31 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: base64
@@ -35,25 +34,20 @@ files:
35
34
  - CHANGELOG.md
36
35
  - LICENSE.txt
37
36
  - README.md
38
- - example/ask_password.rb
39
- - example/fetch_my_posts.rb
40
- - example/fetch_profile.rb
41
- - example/post_skeet.rb
42
- - example/science_feed.rb
43
37
  - lib/minisky.rb
38
+ - lib/minisky/compat.rb
44
39
  - lib/minisky/errors.rb
45
40
  - lib/minisky/minisky.rb
46
41
  - lib/minisky/requests.rb
47
42
  - lib/minisky/version.rb
48
43
  - sig/minisky.rbs
49
- homepage: https://github.com/mackuba/minisky
44
+ homepage: https://ruby.sdk.blue
50
45
  licenses:
51
46
  - Zlib
52
47
  metadata:
53
- bug_tracker_uri: https://github.com/mackuba/minisky/issues
54
- changelog_uri: https://github.com/mackuba/minisky/blob/master/CHANGELOG.md
55
- source_code_uri: https://github.com/mackuba/minisky
56
- post_install_message:
48
+ bug_tracker_uri: https://tangled.org/mackuba.eu/minisky/issues
49
+ changelog_uri: https://tangled.org/mackuba.eu/minisky/blob/master/CHANGELOG.md
50
+ source_code_uri: https://tangled.org/mackuba.eu/minisky
57
51
  rdoc_options: []
58
52
  require_paths:
59
53
  - lib
@@ -68,8 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
68
62
  - !ruby/object:Gem::Version
69
63
  version: '0'
70
64
  requirements: []
71
- rubygems_version: 3.4.10
72
- signing_key:
65
+ rubygems_version: 4.0.3
73
66
  specification_version: 4
74
- summary: A minimal client of Bluesky/AtProto API
67
+ summary: A minimal client of Bluesky/ATProto API
75
68
  test_files: []
@@ -1,76 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: print 10 latest posts from the user's home feed.
4
- #
5
- # Instead of using a config file to read & store authentication info, this example uses a customized client class
6
- # which reads the password from the console and creates a throwaway access token.
7
- #
8
- # This approach makes sense for one-off scripts, but it shouldn't be used for things that need to be done repeatedly
9
- # and often (the authentication-related endpoints have lower rate limits than others).
10
-
11
- # load minisky from a local folder - you normally won't need this
12
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
13
-
14
- require 'io/console'
15
- require 'minisky'
16
-
17
- class TransientClient
18
- include Minisky::Requests
19
-
20
- attr_reader :config, :host
21
-
22
- def initialize(host, user)
23
- @host = host
24
- @config = { 'id' => user.gsub(/^@/, '') }
25
- end
26
-
27
- def ask_for_password
28
- print "Enter password for @#{config['id']}: "
29
- @config['pass'] = STDIN.noecho(&:gets).chomp
30
- puts
31
- end
32
-
33
- def save_config
34
- # ignore
35
- end
36
- end
37
-
38
- host, handle = ARGV
39
-
40
- unless host && handle
41
- puts "Usage: #{$PROGRAM_NAME} <pds_hostname> <handle>"
42
- exit 1
43
- end
44
-
45
- # create a client instance & read password
46
- bsky = TransientClient.new(host, handle)
47
- bsky.ask_for_password
48
-
49
- # fetch 10 posts from the user's home feed
50
- result = bsky.get_request('app.bsky.feed.getTimeline', { limit: 10 })
51
-
52
- result['feed'].each do |r|
53
- reason = r['reason']
54
- reply = r['reply']
55
- post = r['post']
56
-
57
- if reason && reason['$type'] == 'app.bsky.feed.defs#reasonRepost'
58
- puts "[Reposted by @#{reason['by']['handle']}]"
59
- end
60
-
61
- handle = post['author']['handle']
62
- timestamp = Time.parse(post['record']['createdAt']).getlocal
63
-
64
- puts "@#{handle} • #{timestamp}"
65
- puts
66
-
67
- if reply
68
- puts "[in reply to @#{reply['parent']['author']['handle']}]"
69
- puts
70
- end
71
-
72
- puts post['record']['text']
73
- puts
74
- puts "=" * 120
75
- puts
76
- end
@@ -1,46 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: sync all posts from your account (excluding replies and reposts) to a local JSON file. When run again, it
4
- # will only fetch new posts since the last time and append them to the file.
5
- #
6
- # Requires a bluesky.yml config file in the same directory with contents like this:
7
- # id: your.handle
8
- # pass: secretpass
9
-
10
- # load minisky from a local folder - you normally won't need this
11
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
12
-
13
- require 'minisky'
14
-
15
- CONFIG_FILE = File.join(__dir__, 'bluesky.yml')
16
- POSTS_FILE = File.join(__dir__, 'posts.json')
17
-
18
- # create a client instance
19
- bsky = Minisky.new('bsky.social', CONFIG_FILE)
20
-
21
- # print progress dots when loading multiple pages
22
- bsky.default_progress = '.'
23
-
24
- # load previously saved posts; we will only fetch posts newer than the last saved before
25
- posts = File.exist?(POSTS_FILE) ? JSON.parse(File.read(POSTS_FILE)) : []
26
- latest_date = posts[0] && posts[0]['indexedAt']
27
-
28
- # fetch all posts from my timeline (without replies) until the target timestamp
29
- results = bsky.fetch_all('app.bsky.feed.getAuthorFeed',
30
- { actor: bsky.user.did, filter: 'posts_no_replies', limit: 100 },
31
- field: 'feed',
32
- break_when: latest_date && proc { |x| x['post']['indexedAt'] <= latest_date }
33
- )
34
-
35
- # trim some data to save space
36
- new_posts = results.map { |x| x['post'] }
37
- .reject { |x| x['author']['did'] != bsky.user.did } # skip reposts
38
- .map { |x| x.except('author') } # skip author profile info
39
-
40
- posts = new_posts + posts
41
-
42
- puts
43
- puts "Fetched #{new_posts.length} new posts (total = #{posts.length})"
44
-
45
- # save all new and old posts back to the file
46
- File.write(POSTS_FILE, JSON.pretty_generate(posts))
@@ -1,53 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: fetch the profile info of a given user and their last 10 posts (excluding reposts).
4
- #
5
- # This script connects to the AppView server at api.bsky.app, which allows calling such endpoints as getProfile or
6
- # getAuthorFeed without authentication.
7
-
8
- # load minisky from a local folder - you normally won't need this
9
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
10
-
11
- require 'minisky'
12
- require 'time'
13
-
14
- if ARGV[0].to_s !~ /^@?[\w\-]+(\.[\w\-]+)+$/
15
- puts "Usage: #{$PROGRAM_NAME} <handle>"
16
- exit 1
17
- end
18
-
19
- handle = ARGV[0].gsub(/^@/, '')
20
-
21
- # passing nil as config file to use an unauthenticated client
22
- bsky = Minisky.new('api.bsky.app', nil)
23
-
24
- # fetch profile info
25
- profile = bsky.get_request('app.bsky.actor.getProfile', { actor: handle })
26
-
27
- # fetch posts, without replies - we fetch a bit more than we need because we'll also filter out reposts
28
- posts = bsky.get_request('app.bsky.feed.getAuthorFeed', { actor: handle, filter: 'posts_no_replies', limit: 40 })
29
-
30
- # print the profile
31
-
32
- puts
33
- puts "====[ @#{handle} • #{profile['displayName']} • #{profile['did']} ]===="
34
- puts
35
- puts profile['description']
36
- puts
37
- puts '=' * 80
38
- puts
39
-
40
- # print the posts
41
-
42
- posts['feed'].map { |r|
43
- r['post']
44
- }.select { |p|
45
- # select only posts from this account
46
- p['author']['handle'] == handle
47
- }.slice(0, 10).each { |p|
48
- time = Time.parse(p['record']['createdAt'])
49
- timestamp = time.getlocal.strftime('%a %d.%m %H:%M')
50
-
51
- puts "#{timestamp}: #{p['record']['text']}"
52
- puts
53
- }
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: make a new post (aka "skeet") with text passed in the argument to the script.
4
- #
5
- # Requires a bluesky.yml config file in the same directory with contents like this:
6
- # id: your.handle
7
- # pass: secretpass
8
-
9
- # load minisky from a local folder - you normally won't need this
10
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
11
-
12
- require 'minisky'
13
-
14
- if ARGV[0].to_s.empty?
15
- puts "Usage: #{$PROGRAM_NAME} <text>"
16
- exit 1
17
- end
18
-
19
- text = ARGV[0]
20
-
21
- # create a client instance
22
- bsky = Minisky.new('bsky.social', File.join(__dir__, 'bluesky.yml'))
23
-
24
- # to make a post, we upload a post record to the posts collection (app.bsky.feed.post) in the user's repo
25
-
26
- bsky.post_request('com.atproto.repo.createRecord', {
27
- repo: bsky.user.did,
28
- collection: 'app.bsky.feed.post',
29
- record: {
30
- text: text,
31
- createdAt: Time.now.iso8601, # we need to set the date to current time manually
32
- langs: ["en"] # if a post does not have a language set, it may be autodetected as an incorrect language
33
- }
34
- })
35
-
36
- puts "Posted ✓"
@@ -1,53 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: load last 10 posts from the "What's Science" feed and print the post text, data and author handle to the
4
- # terminal. Does not require any authentication.
5
- #
6
- # It's definitely not the most efficient way to do this, but it demonstrates how to load single records from the API.
7
- # (A more efficient way would be e.g. to connect to the AppView at api.bsky.app and make one call to getPosts.)
8
-
9
- # load minisky from a local folder - you normally won't need this
10
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
11
-
12
- require 'minisky'
13
- require 'time'
14
-
15
- # the "What's Science" custom feed by @bossett.bsky.social
16
- # the service host is hardcoded here, ideally you should fetch the feed record and read the hostname from there
17
- FEED_HOST = "bs.bossett.io"
18
- FEED_URI = "at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science"
19
-
20
- # fetch the feed from the feed server (getFeedSkeleton returns only a list or URIs of posts)
21
- # pass nil as the config file parameter to create an unauthenticated client
22
- feed_api = Minisky.new(FEED_HOST, nil)
23
- feed = feed_api.get_request('app.bsky.feed.getFeedSkeleton', { feed: FEED_URI, limit: 10 })
24
-
25
- # second client instance for the Bluesky API - again, pass nil to use without authentication
26
- bsky = Minisky.new('bsky.social', nil)
27
-
28
- # for each post URI, fetch the post record and the profile of its author
29
- entries = feed['feed'].map do |r|
30
- # AT URI is always: at://<did>/<collection>/<rkey>
31
- did, collection, rkey = r['post'].gsub('at://', '').split('/')
32
-
33
- print '.'
34
- post = bsky.get_request('com.atproto.repo.getRecord', { repo: did, collection: collection, rkey: rkey })
35
- author = bsky.get_request('com.atproto.repo.describeRepo', { repo: did })
36
-
37
- [post, author]
38
- end
39
-
40
- puts
41
-
42
- entries.each do |post, author|
43
- handle = author['handle']
44
- timestamp = Time.parse(post['value']['createdAt']).getlocal
45
- link = "https://bsky.app/profile/#{handle}/post/#{post['uri'].split('/').last}"
46
-
47
- puts "@#{handle} • #{timestamp} • #{link}"
48
- puts
49
- puts post['value']['text']
50
- puts
51
- puts "=" * 120
52
- puts
53
- end