minisky 0.5.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: 4ed55493afc5d821a8e67ea83bcdc8aa29a7d2c9c3afe40e178441a499516a2d
4
- data.tar.gz: 351119843f56206f7205872a26cbf66758273dfd35fe54b064a346ebb31d8bcb
3
+ metadata.gz: 11cd25820ca8ab1f4cd6f9c94604a8229fd2f824695341ccd052aae62bc085c6
4
+ data.tar.gz: ecca2bbad124246a93e7090a3be0ace4675208ad1750d36adfdfa523cf9fd420
5
5
  SHA512:
6
- metadata.gz: bc684d8ffe0105e8c8d198acf35c6e9ca4a4856e7a74961adaab548d6471e140bb3d4e42c23ef710caab5449c12136f72c08522ddc68b244b99ca9019a8be832
7
- data.tar.gz: 196a97d055b3cb6714df1379cbe5b64c8798e5e09e432bdf8ec652374f500cc8e6a34d39d60c976f2fc373c547d90f367fe54fe791e8d9d3eea98d618c96651a
6
+ metadata.gz: 2169712a4d8596840ae0aa9ab891cfccee951f73a7fce146105503a69d647545b3ee0ed8f475bf65096d88226cf66c07a8637048a076c65e9363023877b8b55d
7
+ data.tar.gz: 2283843f151c6f03ce5a275195e21ff85e33085c06a6db56bc9301c205d97ef7ea86ff245b0f609e406c2d54ddaa732b2e72930aba5ad75e27dab2ef7c1bac1c
data/CHANGELOG.md CHANGED
@@ -1,8 +1,25 @@
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
+
1
18
  ## [0.5.0] - 2024-12-27 🎄
2
19
 
3
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)
4
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
5
- * 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
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`)
6
23
  * `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
7
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
8
25
  * added `access_token_expired?` helper method
@@ -17,7 +34,7 @@
17
34
  * fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
18
35
  * allow passing options to the client in the initializer
19
36
  * aliased `default_progress` setting as `progress`
20
- * 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
21
38
 
22
39
  ## [0.3.1] - 2023-10-10
23
40
 
@@ -28,11 +45,11 @@
28
45
  * authentication improvements & changes:
29
46
  - Minisky now automatically manages access tokens, calling `check_access` manually is not necessary (set `auto_manage_tokens` to `false` to disable this)
30
47
  - `check_access` now just checks token's expiry time instead of making a request to `getSession`
31
- - added `send_auth_headers` option - set to `false` to not set auth header automatically, which is the default
32
- - removed default config file name - explicit file name is now required
33
- - 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
34
51
  - added `reset_tokens` helper method
35
- * 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
36
53
  * `user` wrapper can also be used for writing fields to the config
37
54
  * improved error handling
38
55
 
@@ -51,7 +68,7 @@
51
68
  * renamed `ident` field in the config hash to `id`
52
69
  * config is now accessed in `Requests` from the client object as a `config` property instead of `@config` ivar
53
70
  * config fields are exposed as a `user` wrapper object, e.g. `user.did` delegates to `@config['did']`
54
-
71
+
55
72
  ## [0.1.0] - 2023-09-01
56
73
 
57
74
  - extracted most code to a `Requests` module that can be included into a different client class with custom config handling
@@ -62,7 +79,7 @@
62
79
 
63
80
  ## [0.0.1] - 2023-08-30
64
81
 
65
- Initial release - extracted from original gist:
82
+ Initial release extracted from original gist:
66
83
 
67
84
  - logging in and refreshing the token
68
85
  - making GET & POST requests
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The zlib License
2
2
 
3
- Copyright (c) 2023-2024 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
@@ -5,18 +5,18 @@ Minisky is a minimal client of the Bluesky (ATProto) API. It provides a simple A
5
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
6
 
7
7
  > [!NOTE]
8
- > 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)
8
+ > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
9
9
 
10
10
 
11
11
  ## Installation
12
12
 
13
- 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/)).
14
14
 
15
15
  To install the Minisky gem, run the command:
16
16
 
17
17
  [sudo] gem install minisky
18
18
 
19
- Or alternatively, add it to the `Gemfile` file for Bundler:
19
+ Or add it to your app's `Gemfile`:
20
20
 
21
21
  gem 'minisky', '~> 0.5'
22
22
 
@@ -47,7 +47,7 @@ bsky = Minisky.new('api.bsky.app', nil)
47
47
  > [!NOTE]
48
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
49
  >
50
- > To look up the PDS hostname of a user given their handle or DID, you can use the [didkit](https://github.com/mackuba/didkit) library.
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
51
  >
52
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
53
 
@@ -135,7 +135,7 @@ This will output a line like this:
135
135
  .................
136
136
  ```
137
137
 
138
- 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).
139
139
 
140
140
 
141
141
  ## Customization
@@ -192,7 +192,7 @@ The class needs to provide:
192
192
 
193
193
  ## Credits
194
194
 
195
- Copyright © 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
195
+ Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
196
196
 
197
197
  The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
198
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,36 +42,78 @@ 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
55
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
+ #
56
110
  class FieldNotSetError < Error
111
+
112
+ # @return [Array<String>] list of fields in the response data
57
113
  attr_reader :fields
58
114
 
115
+ # @param fields [Array<String>] list of fields in the response data
116
+ #
59
117
  def initialize(fields)
60
118
  @fields = fields
61
119
  super("Field parameter not provided; available fields: #{@fields.inspect}")
@@ -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,7 @@ 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
20
57
  end
21
58
 
22
59
  if active_repl?
@@ -30,15 +67,18 @@ class Minisky
30
67
  end
31
68
  end
32
69
 
70
+ def save_config
71
+ File.write(@config_file, YAML.dump(@config)) if @config_file
72
+ end
73
+
74
+
75
+ private
76
+
33
77
  def active_repl?
34
78
  return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
35
79
  return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
36
80
  false
37
81
  end
38
-
39
- def save_config
40
- File.write(@config_file, YAML.dump(@config)) if @config_file
41
- end
42
82
  end
43
83
 
44
84
  require_relative 'requests'
@@ -13,6 +13,10 @@ 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
@@ -26,19 +30,62 @@ 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
29
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])?)$/
30
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
+
31
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
+ #
32
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
+
33
63
  attr_writer :send_auth_headers
34
64
  attr_writer :auto_manage_tokens
35
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
+ #
36
74
  def send_auth_headers
37
- instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : true
75
+ instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : (config != nil)
38
76
  end
39
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
+ #
40
87
  def auto_manage_tokens
41
- instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
88
+ instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : (config != nil)
42
89
  end
43
90
 
44
91
  alias progress default_progress
@@ -53,9 +100,38 @@ class Minisky
53
100
  end
54
101
 
55
102
  def user
56
- @user ||= User.new(config)
103
+ @user ||= config && User.new(config)
57
104
  end
58
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
+
59
135
  def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
60
136
  check_access if auto_manage_tokens && auth == true
61
137
 
@@ -72,16 +148,52 @@ class Minisky
72
148
  handle_response(response)
73
149
  end
74
150
 
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
+
75
187
  def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
76
188
  check_access if auto_manage_tokens && auth == true
77
189
 
78
190
  headers = authentication_header(auth).merge(headers || {})
79
- headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
80
191
 
81
- body = if data.is_a?(String) || data.nil?
82
- data.to_s
192
+ if data.is_a?(String) || data.nil?
193
+ body = data.to_s
83
194
  else
84
- data.to_json
195
+ body = data.to_json
196
+ headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
85
197
  end
86
198
 
87
199
  url = build_request_uri(method)
@@ -94,6 +206,73 @@ class Minisky
94
206
  handle_response(response)
95
207
  end
96
208
 
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
+
97
276
  def fetch_all(method, params = nil, auth: default_auth_mode,
98
277
  field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
99
278
  data = []
@@ -116,7 +295,7 @@ class Minisky
116
295
  params[:cursor] = cursor
117
296
  pages += 1
118
297
 
119
- break if !cursor || records.empty? || pages == max_pages
298
+ break if !cursor || pages == max_pages || (stop_fetch_on_empty_page && records.empty?)
120
299
  break if break_when && records.any? { |x| break_when.call(x) }
121
300
  end
122
301
 
@@ -124,11 +303,42 @@ class Minisky
124
303
  data
125
304
  end
126
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
+
127
325
  def check_access
128
- 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?
129
331
  log_in
130
- :logged_in
131
- elsif access_token_expired?
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
132
342
  perform_token_refresh
133
343
  :refreshed
134
344
  else
@@ -136,8 +346,21 @@ class Minisky
136
346
  end
137
347
  end
138
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
+
139
362
  def log_in
140
- if user.id.nil? || user.pass.nil?
363
+ if user.nil? || !user.has_credentials?
141
364
  raise AuthError, "To log in, please provide a user id and password"
142
365
  end
143
366
 
@@ -146,6 +369,11 @@ class Minisky
146
369
  password: user.pass
147
370
  }
148
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
+
149
377
  json = post_request('com.atproto.server.createSession', data, auth: false)
150
378
 
151
379
  user.did = json['did']
@@ -156,8 +384,19 @@ class Minisky
156
384
  json
157
385
  end
158
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
+
159
398
  def perform_token_refresh
160
- if user.refresh_token.nil?
399
+ if user&.refresh_token.nil?
161
400
  raise AuthError, "Can't refresh access token - refresh token is missing"
162
401
  end
163
402
 
@@ -170,27 +409,64 @@ class Minisky
170
409
  json
171
410
  end
172
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
+
173
418
  def token_expiration_date(token)
419
+ return nil unless token.valid_encoding?
420
+
174
421
  parts = token.split('.')
175
- raise AuthError, "Invalid access token format" unless parts.length == 3
422
+ return nil unless parts.length == 3
176
423
 
177
424
  begin
178
425
  payload = JSON.parse(Base64.decode64(parts[1]))
179
426
  rescue JSON::ParserError
180
- raise AuthError, "Couldn't decode payload from access token"
427
+ return nil
181
428
  end
182
429
 
183
430
  exp = payload['exp']
184
- raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
431
+ return nil unless exp.is_a?(Numeric) && exp > 0
185
432
 
186
- Time.at(exp)
433
+ time = Time.at(exp)
434
+ return nil if time.year < 2023 || time.year > 2100
435
+
436
+ time
187
437
  end
188
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
+
189
445
  def access_token_expired?
190
- token_expiration_date(user.access_token) < Time.now + 60
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
191
457
  end
192
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
+
193
465
  def reset_tokens
466
+ if !user
467
+ raise AuthError, "User config is missing"
468
+ end
469
+
194
470
  user.access_token = nil
195
471
  user.refresh_token = nil
196
472
  save_config
@@ -198,19 +474,8 @@ class Minisky
198
474
  end
199
475
 
200
476
  if RUBY_VERSION.to_i == 2
201
- alias_method :do_get_request, :get_request
202
- alias_method :do_post_request, :post_request
203
- private :do_get_request, :do_post_request
204
-
205
- def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
206
- params ||= kwargs unless kwargs.empty?
207
- do_get_request(method, params, auth: auth, headers: headers)
208
- end
209
-
210
- def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
211
- params ||= kwargs unless kwargs.empty?
212
- do_post_request(method, params, auth: auth, headers: headers)
213
- end
477
+ require_relative 'compat'
478
+ prepend Ruby2Compat
214
479
  end
215
480
 
216
481
 
@@ -218,7 +483,7 @@ class Minisky
218
483
 
219
484
  def make_request(request)
220
485
  # this long form is needed because #get_response only supports a headers param in Ruby 3.x
221
- 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|
222
487
  http.request(request)
223
488
  end
224
489
  end
@@ -243,7 +508,7 @@ class Minisky
243
508
  if auth.is_a?(String)
244
509
  { 'Authorization' => "Bearer #{auth}" }
245
510
  elsif auth
246
- if user.access_token
511
+ if user&.access_token
247
512
  { 'Authorization' => "Bearer #{user.access_token}" }
248
513
  else
249
514
  raise AuthError, "Can't send auth headers, access token is missing"
@@ -1,5 +1,5 @@
1
1
  require_relative 'minisky'
2
2
 
3
3
  class Minisky
4
- VERSION = "0.5.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.5.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-12-27 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,26 +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/find_missing_follows.rb
42
- - example/post_skeet.rb
43
- - example/science_feed.rb
44
37
  - lib/minisky.rb
38
+ - lib/minisky/compat.rb
45
39
  - lib/minisky/errors.rb
46
40
  - lib/minisky/minisky.rb
47
41
  - lib/minisky/requests.rb
48
42
  - lib/minisky/version.rb
49
43
  - sig/minisky.rbs
50
- homepage: https://github.com/mackuba/minisky
44
+ homepage: https://ruby.sdk.blue
51
45
  licenses:
52
46
  - Zlib
53
47
  metadata:
54
- bug_tracker_uri: https://github.com/mackuba/minisky/issues
55
- changelog_uri: https://github.com/mackuba/minisky/blob/master/CHANGELOG.md
56
- source_code_uri: https://github.com/mackuba/minisky
57
- 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
58
51
  rdoc_options: []
59
52
  require_paths:
60
53
  - lib
@@ -69,8 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
62
  - !ruby/object:Gem::Version
70
63
  version: '0'
71
64
  requirements: []
72
- rubygems_version: 3.4.10
73
- signing_key:
65
+ rubygems_version: 4.0.3
74
66
  specification_version: 4
75
- summary: A minimal client of Bluesky/AtProto API
67
+ summary: A minimal client of Bluesky/ATProto API
76
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,67 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- # Example: fetch the list of accounts followed by a given user and check which of them have been deleted / deactivated.
4
-
5
- # load minisky from a local folder - you normally won't need this
6
- $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
7
-
8
- require 'didkit'
9
- require 'minisky'
10
-
11
- handle = ARGV[0].to_s.gsub(/^@/, '')
12
- if handle.empty?
13
- puts "Usage: #{$PROGRAM_NAME} <handle>"
14
- exit 1
15
- end
16
-
17
- pds_host = DID.resolve_handle(handle).get_document.pds_endpoint
18
- pds = Minisky.new(pds_host, nil, progress: '.')
19
-
20
- print "Fetching all follows of @#{handle} from #{pds_host}: "
21
-
22
- follows = pds.fetch_all('com.atproto.repo.listRecords',
23
- { repo: handle, collection: 'app.bsky.graph.follow', limit: 100 }, field: 'records')
24
-
25
- puts
26
- puts "Found #{follows.length} follows"
27
-
28
- appview = Minisky.new('api.bsky.app', nil)
29
-
30
- profiles = []
31
- i = 0
32
-
33
- puts
34
- print "Fetching profiles of all followed accounts: "
35
-
36
- # getProfiles lets us load multiple profiles in one request, but only up to 25 in one batch
37
-
38
- while i < follows.length
39
- batch = follows[i...i+25]
40
- dids = batch.map { |x| x['value']['subject'] }
41
- print '.'
42
- result = appview.get_request('app.bsky.actor.getProfiles', { actors: dids })
43
- profiles += result['profiles']
44
- i += 25
45
- end
46
-
47
- # these are DIDs that are on the follows list, but aren't being returned from getProfiles
48
- missing = follows.map { |x| x['value']['subject'] } - profiles.map { |x| x['did'] }
49
-
50
- puts
51
- puts "#{missing.length} followed accounts are missing:"
52
- puts
53
-
54
- missing.each do |did|
55
- begin
56
- doc = DID.new(did).get_document
57
- rescue OpenURI::HTTPError
58
- puts "#{did} (?) => DID not found"
59
- next
60
- end
61
-
62
- # check account status on their assigned PDS
63
- pds = Minisky.new(doc.pds_endpoint.gsub('http://', ''), nil)
64
- status = pds.get_request('com.atproto.sync.getRepoStatus', { did: did }).slice('status', 'active') rescue 'deleted'
65
-
66
- puts "#{did} (@#{doc.handles.first}) => #{status}"
67
- end
@@ -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