minisky 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +43 -7
- data/example/ask_password.rb +76 -0
- data/example/fetch_profile.rb +53 -0
- data/example/post_skeet.rb +4 -2
- data/example/science_feed.rb +1 -0
- data/lib/minisky/minisky.rb +7 -1
- data/lib/minisky/requests.rb +47 -13
- data/lib/minisky/version.rb +1 -1
- metadata +19 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b22e3b707a428f0381e5c194ce6b5bd4118f6b6dfb7f2d249140c8025e3b1dd7
|
4
|
+
data.tar.gz: 2a6370ea6cb538c680252836295c411ff4c6df93b8c5aab9d8b73b843753febb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3f86def9480e86d2989a52f143eacee8e5b78fede45bd6ba49ea7ad54f1da88a55555421d3fdfdddad025bed21a708c830e5e9b2c351ba57e016234e200b9f74
|
7
|
+
data.tar.gz: f8f7faa8d7e5df7807d6e9bb7ee1b87d1d5ab5503c0193fd031190be6e6346004618c66efdba96970fc93117d4f587c53ff5f17585a2ffa3ddafb503abc5a3cc
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,16 @@
|
|
1
|
+
## [0.4.0] - 2024-03-31 🐣
|
2
|
+
|
3
|
+
* allow passing non-JSON body to requests (e.g. when uploading blobs)
|
4
|
+
* allow passing custom headers to requests, including overriding `Content-Type`
|
5
|
+
* fixed error when the response is success but not JSON (e.g. an empty body like in deleteRecord)
|
6
|
+
* allow passing options to the client in the initializer
|
7
|
+
* aliased `default_progress` setting as `progress`
|
8
|
+
* added `base64` dependency explicitly to the gemspec - fixes a warning in Ruby 3.3, since it will be extracted as an optional gem in 3.4
|
9
|
+
|
10
|
+
## [0.3.1] - 2023-10-10
|
11
|
+
|
12
|
+
* fixed Minisky not working on Ruby 2.x
|
13
|
+
|
1
14
|
## [0.3.0] - 2023-10-05
|
2
15
|
|
3
16
|
* authentication improvements & changes:
|
data/README.md
CHANGED
@@ -18,6 +18,32 @@ Or alternatively, add it to the `Gemfile` file for Bundler:
|
|
18
18
|
|
19
19
|
## Usage
|
20
20
|
|
21
|
+
All calls to the XRPC API are made through an instance of the `Minisky` class. There are two ways to use the library: with or without authentication.
|
22
|
+
|
23
|
+
|
24
|
+
### Unauthenticated access
|
25
|
+
|
26
|
+
You can access parts of the API anonymously without any authentication. This currently includes: read-only `com.atproto.*` routes on the PDS (user's data server) and most read-only `app.bsky.*` routes on the AppView server.
|
27
|
+
|
28
|
+
This allows you to do things like:
|
29
|
+
|
30
|
+
- look up specific records or lists of all records of a given type in any account (in their raw form)
|
31
|
+
- look up profile information about any account
|
32
|
+
- load complete threads or users' profile feeds from the AppView
|
33
|
+
|
34
|
+
To use Minisky this way, create a `Minisky` instance passing the API hostname string (at the moment there is only one server at `bsky.social`, but there will be more once federation support goes live) and `nil` as the configuration in the arguments:
|
35
|
+
|
36
|
+
```rb
|
37
|
+
require 'minisky'
|
38
|
+
|
39
|
+
bsky = Minisky.new('bsky.social', nil)
|
40
|
+
```
|
41
|
+
|
42
|
+
|
43
|
+
### Authenticated access
|
44
|
+
|
45
|
+
To use the complete API including posting or reading your home feed, you need to log in using your account info and get an access token which will be added as an authentication header to all requests.
|
46
|
+
|
21
47
|
First, you need to create a `.yml` config file with the authentication data, e.g. `bluesky.yml`. It should look like this:
|
22
48
|
|
23
49
|
```yaml
|
@@ -29,18 +55,20 @@ The `id` can be either your handle, or your DID, or the email you've used to sig
|
|
29
55
|
|
30
56
|
After you log in, this file will also be used to store your access & request tokens and DID. The data in the config file can be accessed through a `user` wrapper property that exposes them as methods, e.g. the password is available as `user.pass` and the DID as `user.did`.
|
31
57
|
|
32
|
-
Next, create the Minisky client instance, passing the server name and the config file name
|
58
|
+
Next, create the Minisky client instance, passing the server name and the config file name:
|
33
59
|
|
34
60
|
```rb
|
35
61
|
require 'minisky'
|
36
62
|
|
37
63
|
bsky = Minisky.new('bsky.social', 'bluesky.yml')
|
38
|
-
bsky.check_access
|
39
64
|
```
|
40
65
|
|
41
|
-
|
66
|
+
Minisky automatically manages your access and refresh tokens - it will first log you in using the login & password, and then use the refresh token to update the access token before the request when it expires.
|
67
|
+
|
42
68
|
|
43
|
-
|
69
|
+
### Making requests
|
70
|
+
|
71
|
+
With a `Minisky` client instance, you can make requests to the Bluesky API using `get_request` and `post_request`:
|
44
72
|
|
45
73
|
```rb
|
46
74
|
json = bsky.get_request('com.atproto.repo.listRecords', {
|
@@ -63,7 +91,7 @@ bsky.post_request('com.atproto.repo.createRecord', {
|
|
63
91
|
})
|
64
92
|
```
|
65
93
|
|
66
|
-
|
94
|
+
In authenticated mode, the requests use the saved access token for auth headers automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers for a given request, or `auth: sometoken` to use a specific other token. In unauthenticated mode, sending of auth headers is disabled.
|
67
95
|
|
68
96
|
The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days, but not earlier:
|
69
97
|
|
@@ -97,9 +125,18 @@ You can find more examples in the [example](https://github.com/mackuba/minisky/t
|
|
97
125
|
|
98
126
|
## Customization
|
99
127
|
|
100
|
-
The `Minisky` client currently supports
|
128
|
+
The `Minisky` client currently supports such configuration options:
|
101
129
|
|
102
130
|
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
|
131
|
+
- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
|
132
|
+
- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
|
133
|
+
|
134
|
+
In authenticated mode, you can disable the `send_auth_headers` option and then explicitly add `auth: true` to specific requests to include a header there.
|
135
|
+
|
136
|
+
You can also disable the `auto_manage_tokens` option - in this case you will need to call the `#check_access` method before a request to refresh a token if needed, or alternatively, call either `#login` or `#perform_token_refresh`.
|
137
|
+
|
138
|
+
|
139
|
+
### Using your own class
|
103
140
|
|
104
141
|
Instead of using the `Minisky` class, you can also make your own class that includes the `Minisky::Requests` module and provides a different way to load & save the config, e.g. from a JSON file:
|
105
142
|
|
@@ -128,7 +165,6 @@ It can then be used just like the `Minisky` class:
|
|
128
165
|
|
129
166
|
```rb
|
130
167
|
bsky = BlueskyClient.new('config/access.json')
|
131
|
-
bsky.check_access
|
132
168
|
bsky.get_request(...)
|
133
169
|
```
|
134
170
|
|
@@ -0,0 +1,76 @@
|
|
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
|
@@ -0,0 +1,53 @@
|
|
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
|
+
}
|
data/example/post_skeet.rb
CHANGED
@@ -21,13 +21,15 @@ text = ARGV[0]
|
|
21
21
|
# create a client instance
|
22
22
|
bsky = Minisky.new('bsky.social', File.join(__dir__, 'bluesky.yml'))
|
23
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
|
+
|
24
26
|
bsky.post_request('com.atproto.repo.createRecord', {
|
25
27
|
repo: bsky.user.did,
|
26
28
|
collection: 'app.bsky.feed.post',
|
27
29
|
record: {
|
28
30
|
text: text,
|
29
|
-
|
30
|
-
|
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
|
31
33
|
}
|
32
34
|
})
|
33
35
|
|
data/example/science_feed.rb
CHANGED
@@ -4,6 +4,7 @@
|
|
4
4
|
# terminal. Does not require any authentication.
|
5
5
|
#
|
6
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.)
|
7
8
|
|
8
9
|
# load minisky from a local folder - you normally won't need this
|
9
10
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
data/lib/minisky/minisky.rb
CHANGED
@@ -3,7 +3,7 @@ require 'yaml'
|
|
3
3
|
class Minisky
|
4
4
|
attr_reader :host, :config
|
5
5
|
|
6
|
-
def initialize(host, config_file)
|
6
|
+
def initialize(host, config_file, options = {})
|
7
7
|
@host = host
|
8
8
|
@config_file = config_file
|
9
9
|
|
@@ -18,6 +18,12 @@ class Minisky
|
|
18
18
|
@send_auth_headers = false
|
19
19
|
@auto_manage_tokens = false
|
20
20
|
end
|
21
|
+
|
22
|
+
if options
|
23
|
+
options.each do |k, v|
|
24
|
+
self.send("#{k}=", v)
|
25
|
+
end
|
26
|
+
end
|
21
27
|
end
|
22
28
|
|
23
29
|
def save_config
|
data/lib/minisky/requests.rb
CHANGED
@@ -39,6 +39,9 @@ class Minisky
|
|
39
39
|
instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
|
40
40
|
end
|
41
41
|
|
42
|
+
alias progress default_progress
|
43
|
+
alias progress= default_progress=
|
44
|
+
|
42
45
|
def base_url
|
43
46
|
@base_url ||= "https://#{host}/xrpc"
|
44
47
|
end
|
@@ -47,32 +50,40 @@ class Minisky
|
|
47
50
|
@user ||= User.new(config)
|
48
51
|
end
|
49
52
|
|
50
|
-
def get_request(method, params = nil, auth: default_auth_mode)
|
53
|
+
def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
|
51
54
|
check_access if auto_manage_tokens && auth == true
|
52
55
|
|
53
|
-
headers = authentication_header(auth)
|
56
|
+
headers = authentication_header(auth).merge(headers || {})
|
54
57
|
url = URI("#{base_url}/#{method}")
|
55
58
|
|
56
59
|
if params && !params.empty?
|
57
60
|
url.query = URI.encode_www_form(params)
|
58
61
|
end
|
59
62
|
|
60
|
-
|
63
|
+
request = Net::HTTP::Get.new(url, headers)
|
64
|
+
|
65
|
+
response = make_request(request)
|
61
66
|
handle_response(response)
|
62
67
|
end
|
63
68
|
|
64
|
-
def post_request(method, params = nil, auth: default_auth_mode)
|
69
|
+
def post_request(method, params = nil, auth: default_auth_mode, headers: nil)
|
65
70
|
check_access if auto_manage_tokens && auth == true
|
66
71
|
|
67
|
-
headers = authentication_header(auth).merge(
|
68
|
-
|
72
|
+
headers = authentication_header(auth).merge(headers || {})
|
73
|
+
headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
|
74
|
+
|
75
|
+
body = if params.is_a?(String) || params.nil?
|
76
|
+
params.to_s
|
77
|
+
else
|
78
|
+
params.to_json
|
79
|
+
end
|
69
80
|
|
70
81
|
response = Net::HTTP.post(URI("#{base_url}/#{method}"), body, headers)
|
71
82
|
handle_response(response)
|
72
83
|
end
|
73
84
|
|
74
85
|
def fetch_all(method, params = nil, field:,
|
75
|
-
auth: default_auth_mode, break_when: nil, max_pages: nil, progress: @default_progress)
|
86
|
+
auth: default_auth_mode, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
|
76
87
|
data = []
|
77
88
|
params = {} if params.nil?
|
78
89
|
pages = 0
|
@@ -80,7 +91,7 @@ class Minisky
|
|
80
91
|
loop do
|
81
92
|
print(progress) if progress
|
82
93
|
|
83
|
-
response = get_request(method, params, auth: auth)
|
94
|
+
response = get_request(method, params, auth: auth, headers: headers)
|
84
95
|
records = response[field]
|
85
96
|
cursor = response['cursor']
|
86
97
|
|
@@ -145,8 +156,32 @@ class Minisky
|
|
145
156
|
nil
|
146
157
|
end
|
147
158
|
|
159
|
+
if RUBY_VERSION.to_i == 2
|
160
|
+
alias_method :do_get_request, :get_request
|
161
|
+
alias_method :do_post_request, :post_request
|
162
|
+
private :do_get_request, :do_post_request
|
163
|
+
|
164
|
+
def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
|
165
|
+
params ||= kwargs unless kwargs.empty?
|
166
|
+
do_get_request(method, params, auth: auth, headers: headers)
|
167
|
+
end
|
168
|
+
|
169
|
+
def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
|
170
|
+
params ||= kwargs unless kwargs.empty?
|
171
|
+
do_post_request(method, params, auth: auth, headers: headers)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
|
148
176
|
private
|
149
177
|
|
178
|
+
def make_request(request)
|
179
|
+
# this long form is needed because #get_response only supports a headers param in Ruby 3.x
|
180
|
+
response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: true) do |http|
|
181
|
+
http.request(request)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
150
185
|
def default_auth_mode
|
151
186
|
!!send_auth_headers
|
152
187
|
end
|
@@ -184,16 +219,15 @@ class Minisky
|
|
184
219
|
def handle_response(response)
|
185
220
|
status = response.code.to_i
|
186
221
|
message = response.message
|
222
|
+
response_body = (response.content_type == 'application/json') ? JSON.parse(response.body) : response.body
|
187
223
|
|
188
224
|
case response
|
189
225
|
when Net::HTTPSuccess
|
190
|
-
|
226
|
+
response_body
|
191
227
|
when Net::HTTPRedirection
|
192
228
|
raise UnexpectedRedirect.new(status, message, response['location'])
|
193
229
|
else
|
194
|
-
|
195
|
-
|
196
|
-
error_class = if data.is_a?(Hash) && data['error'] == 'ExpiredToken'
|
230
|
+
error_class = if response_body.is_a?(Hash) && response_body['error'] == 'ExpiredToken'
|
197
231
|
ExpiredTokenError
|
198
232
|
elsif response.is_a?(Net::HTTPClientError)
|
199
233
|
ClientErrorResponse
|
@@ -203,7 +237,7 @@ class Minisky
|
|
203
237
|
BadResponse
|
204
238
|
end
|
205
239
|
|
206
|
-
raise error_class.new(status, message,
|
240
|
+
raise error_class.new(status, message, response_body)
|
207
241
|
end
|
208
242
|
end
|
209
243
|
end
|
data/lib/minisky/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: minisky
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kuba Suder
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
12
|
-
dependencies:
|
11
|
+
date: 2024-03-31 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: base64
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.1'
|
13
27
|
description: A very simple client class that lets you log in to the Bluesky API and
|
14
28
|
make any requests there.
|
15
29
|
email:
|
@@ -21,7 +35,9 @@ files:
|
|
21
35
|
- CHANGELOG.md
|
22
36
|
- LICENSE.txt
|
23
37
|
- README.md
|
38
|
+
- example/ask_password.rb
|
24
39
|
- example/fetch_my_posts.rb
|
40
|
+
- example/fetch_profile.rb
|
25
41
|
- example/post_skeet.rb
|
26
42
|
- example/science_feed.rb
|
27
43
|
- lib/minisky.rb
|