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 +4 -4
- data/CHANGELOG.md +25 -8
- data/LICENSE.txt +1 -1
- data/README.md +6 -6
- data/lib/minisky/compat.rb +20 -0
- data/lib/minisky/errors.rb +62 -4
- data/lib/minisky/minisky.rb +48 -8
- data/lib/minisky/requests.rb +298 -33
- data/lib/minisky/version.rb +1 -1
- metadata +9 -17
- data/example/ask_password.rb +0 -76
- data/example/fetch_my_posts.rb +0 -46
- data/example/fetch_profile.rb +0 -53
- data/example/find_missing_follows.rb +0 -67
- data/example/post_skeet.rb +0 -36
- data/example/science_feed.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11cd25820ca8ab1f4cd6f9c94604a8229fd2f824695341ccd052aae62bc085c6
|
|
4
|
+
data.tar.gz: ecca2bbad124246a93e7090a3be0ace4675208ad1750d36adfdfa523cf9fd420
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
32
|
-
- removed default config file name
|
|
33
|
-
- Minisky can now be used in unauthenticated mode
|
|
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
|
|
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
|
|
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
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
|
|
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
|
|
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
|
|
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://
|
|
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
|
|
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 ©
|
|
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
|
data/lib/minisky/errors.rb
CHANGED
|
@@ -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}")
|
data/lib/minisky/minisky.rb
CHANGED
|
@@ -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'
|
data/lib/minisky/requests.rb
CHANGED
|
@@ -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 :
|
|
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 :
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
427
|
+
return nil
|
|
181
428
|
end
|
|
182
429
|
|
|
183
430
|
exp = payload['exp']
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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:
|
|
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
|
|
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"
|
data/lib/minisky/version.rb
CHANGED
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.
|
|
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:
|
|
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://
|
|
44
|
+
homepage: https://ruby.sdk.blue
|
|
51
45
|
licenses:
|
|
52
46
|
- Zlib
|
|
53
47
|
metadata:
|
|
54
|
-
bug_tracker_uri: https://
|
|
55
|
-
changelog_uri: https://
|
|
56
|
-
source_code_uri: https://
|
|
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:
|
|
73
|
-
signing_key:
|
|
65
|
+
rubygems_version: 4.0.3
|
|
74
66
|
specification_version: 4
|
|
75
|
-
summary: A minimal client of Bluesky/
|
|
67
|
+
summary: A minimal client of Bluesky/ATProto API
|
|
76
68
|
test_files: []
|
data/example/ask_password.rb
DELETED
|
@@ -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
|
data/example/fetch_my_posts.rb
DELETED
|
@@ -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))
|
data/example/fetch_profile.rb
DELETED
|
@@ -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
|
data/example/post_skeet.rb
DELETED
|
@@ -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 ✓"
|
data/example/science_feed.rb
DELETED
|
@@ -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
|