minisky 0.4.0 → 0.5.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 +12 -0
- data/LICENSE.txt +1 -1
- data/README.md +23 -8
- data/example/find_missing_follows.rb +67 -0
- data/lib/minisky/errors.rb +9 -0
- data/lib/minisky/minisky.rb +10 -0
- data/lib/minisky/requests.rb +64 -27
- data/lib/minisky/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4ed55493afc5d821a8e67ea83bcdc8aa29a7d2c9c3afe40e178441a499516a2d
|
4
|
+
data.tar.gz: 351119843f56206f7205872a26cbf66758273dfd35fe54b064a346ebb31d8bcb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: bc684d8ffe0105e8c8d198acf35c6e9ca4a4856e7a74961adaab548d6471e140bb3d4e42c23ef710caab5449c12136f72c08522ddc68b244b99ca9019a8be832
|
7
|
+
data.tar.gz: 196a97d055b3cb6714df1379cbe5b64c8798e5e09e432bdf8ec652374f500cc8e6a34d39d60c976f2fc373c547d90f367fe54fe791e8d9d3eea98d618c96651a
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,15 @@
|
|
1
|
+
## [0.5.0] - 2024-12-27 🎄
|
2
|
+
|
3
|
+
* `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
|
+
* 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
|
6
|
+
* `default_progress` is set by default to show progress using dots (`.`) if Minisky is loaded inside an IRB or Pry context
|
7
|
+
* 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
|
+
* added `access_token_expired?` helper method
|
9
|
+
* moved `token_expiration_date` to public methods
|
10
|
+
* `check_access` now returns a result symbol: `:logged_in`, `:refreshed` or `:ok`
|
11
|
+
* fixed `method_missing` setter on `User`
|
12
|
+
|
1
13
|
## [0.4.0] - 2024-03-31 🐣
|
2
14
|
|
3
15
|
* allow passing non-JSON body to requests (e.g. when uploading blobs)
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -1,7 +1,12 @@
|
|
1
|
-
# Minisky
|
1
|
+
# Minisky 🌤
|
2
2
|
|
3
3
|
Minisky is a minimal client of the Bluesky (ATProto) API. It provides a simple API client class that you can use to log in to the Bluesky API and make any GET and POST requests there. It's meant to be an easy way to start playing and experimenting with the AT Protocol API.
|
4
4
|
|
5
|
+
This is designed as a low-level XRPC client library - it purposefully does not include any convenience methods like "get posts" or "get profile" etc., it only provides base components that you could use to build a higher level API.
|
6
|
+
|
7
|
+
> [!NOTE]
|
8
|
+
> 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)
|
9
|
+
|
5
10
|
|
6
11
|
## Installation
|
7
12
|
|
@@ -13,7 +18,7 @@ To install the Minisky gem, run the command:
|
|
13
18
|
|
14
19
|
Or alternatively, add it to the `Gemfile` file for Bundler:
|
15
20
|
|
16
|
-
gem 'minisky', '~> 0.
|
21
|
+
gem 'minisky', '~> 0.5'
|
17
22
|
|
18
23
|
|
19
24
|
## Usage
|
@@ -31,14 +36,21 @@ This allows you to do things like:
|
|
31
36
|
- look up profile information about any account
|
32
37
|
- load complete threads or users' profile feeds from the AppView
|
33
38
|
|
34
|
-
To use Minisky this way, create a `Minisky` instance passing the API hostname string
|
39
|
+
To use Minisky this way, create a `Minisky` instance, passing the API hostname string and `nil` as the configuration in the arguments. Use the hostname `api.bsky.app` or `public.api.bsky.app` for the AppView, or a PDS hostname for the `com.atproto.*` raw data endpoints:
|
35
40
|
|
36
41
|
```rb
|
37
42
|
require 'minisky'
|
38
43
|
|
39
|
-
bsky = Minisky.new('bsky.
|
44
|
+
bsky = Minisky.new('api.bsky.app', nil)
|
40
45
|
```
|
41
46
|
|
47
|
+
> [!NOTE]
|
48
|
+
> To call PDS endpoints like `getRecord` or `listRecords`, you need to connect to the PDS of the user whose data you're loading, not to yours (unless it's the same one). Alternatively, you can use the `bsky.social` "entryway" PDS hostname for any Bluesky-hosted accounts, but this will not work for self-hosted accounts.
|
49
|
+
>
|
50
|
+
> To look up the PDS hostname of a user given their handle or DID, you can use the [didkit](https://github.com/mackuba/didkit) library.
|
51
|
+
>
|
52
|
+
> For the AppView, `api.bsky.app` connects directly to Bluesky's AppView, and `public.api.bsky.app` to a version with extra caching that will usually be faster.
|
53
|
+
|
42
54
|
|
43
55
|
### Authenticated access
|
44
56
|
|
@@ -53,9 +65,12 @@ pass: very-secret-password
|
|
53
65
|
|
54
66
|
The `id` can be either your handle, or your DID, or the email you've used to sign up. It's recommended that you use the "app password" that you can create in the settings instead of your main account password.
|
55
67
|
|
68
|
+
> [!NOTE]
|
69
|
+
> Bluesky has recently implemented OAuth, but Minisky doesn't support it yet - it will be added in a future version. App passwords should still be supported for a fairly long time.
|
70
|
+
|
56
71
|
After you log in, this file will also be used to store your access & request tokens and DID. The data in the config file can be accessed through a `user` wrapper property that exposes them as methods, e.g. the password is available as `user.pass` and the DID as `user.did`.
|
57
72
|
|
58
|
-
Next, create the Minisky client instance, passing
|
73
|
+
Next, create the Minisky client instance, passing your PDS hostname (for Bluesky-hosted PDSes, you can use either `bsky.social` or your specific PDS like `amanita.us-east.host.bsky.network`) and the name of the config file:
|
59
74
|
|
60
75
|
```rb
|
61
76
|
require 'minisky'
|
@@ -93,7 +108,7 @@ bsky.post_request('com.atproto.repo.createRecord', {
|
|
93
108
|
|
94
109
|
In authenticated mode, the requests use the saved access token for auth headers automatically. You can also pass `auth: false` or `auth: nil` to not send any authentication headers for a given request, or `auth: sometoken` to use a specific other token. In unauthenticated mode, sending of auth headers is disabled.
|
95
110
|
|
96
|
-
The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days
|
111
|
+
The third useful method you can use is `#fetch_all`, which loads multiple paginated responses and collects all returned items on a single list (you need to pass the name of the field that contains the items in the response). Optionally, you can also specify a limit of pages to load as `max_pages: n`, or a break condition `break_when` to stop fetching when any item matches it. You can use it to e.g. to fetch all of your posts from the last 30 days but not earlier:
|
97
112
|
|
98
113
|
```rb
|
99
114
|
time_limit = Time.now - 86400 * 30
|
@@ -127,7 +142,7 @@ You can find more examples in the [example](https://github.com/mackuba/minisky/t
|
|
127
142
|
|
128
143
|
The `Minisky` client currently supports such configuration options:
|
129
144
|
|
130
|
-
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `nil`)
|
145
|
+
- `default_progress` - a progress character to automatically use for `#fetch_all` calls (default: `.` when in an interactive console, `nil` otherwise)
|
131
146
|
- `send_auth_headers` - whether auth headers should be added by default (default: `true` in authenticated mode)
|
132
147
|
- `auto_manage_tokens` - whether access tokens should be generated and refreshed automatically when needed (default: `true` in authenticated mode)
|
133
148
|
|
@@ -177,7 +192,7 @@ The class needs to provide:
|
|
177
192
|
|
178
193
|
## Credits
|
179
194
|
|
180
|
-
Copyright ©
|
195
|
+
Copyright © 2024 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
|
181
196
|
|
182
197
|
The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
|
183
198
|
|
@@ -0,0 +1,67 @@
|
|
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/lib/minisky/errors.rb
CHANGED
@@ -52,4 +52,13 @@ class Minisky
|
|
52
52
|
@location = location
|
53
53
|
end
|
54
54
|
end
|
55
|
+
|
56
|
+
class FieldNotSetError < Error
|
57
|
+
attr_reader :fields
|
58
|
+
|
59
|
+
def initialize(fields)
|
60
|
+
@fields = fields
|
61
|
+
super("Field parameter not provided; available fields: #{@fields.inspect}")
|
62
|
+
end
|
63
|
+
end
|
55
64
|
end
|
data/lib/minisky/minisky.rb
CHANGED
@@ -19,6 +19,10 @@ class Minisky
|
|
19
19
|
@auto_manage_tokens = false
|
20
20
|
end
|
21
21
|
|
22
|
+
if active_repl?
|
23
|
+
@default_progress = '.'
|
24
|
+
end
|
25
|
+
|
22
26
|
if options
|
23
27
|
options.each do |k, v|
|
24
28
|
self.send("#{k}=", v)
|
@@ -26,6 +30,12 @@ class Minisky
|
|
26
30
|
end
|
27
31
|
end
|
28
32
|
|
33
|
+
def active_repl?
|
34
|
+
return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
|
35
|
+
return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
|
36
|
+
false
|
37
|
+
end
|
38
|
+
|
29
39
|
def save_config
|
30
40
|
File.write(@config_file, YAML.dump(@config)) if @config_file
|
31
41
|
end
|
data/lib/minisky/requests.rb
CHANGED
@@ -18,7 +18,7 @@ class Minisky
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def method_missing(name, *args)
|
21
|
-
if name.end_with?('=')
|
21
|
+
if name.to_s.end_with?('=')
|
22
22
|
@config[name.to_s.chop] = args[0]
|
23
23
|
else
|
24
24
|
@config[name.to_s]
|
@@ -26,6 +26,8 @@ class Minisky
|
|
26
26
|
end
|
27
27
|
end
|
28
28
|
|
29
|
+
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
|
+
|
29
31
|
module Requests
|
30
32
|
attr_accessor :default_progress
|
31
33
|
attr_writer :send_auth_headers
|
@@ -43,7 +45,11 @@ class Minisky
|
|
43
45
|
alias progress= default_progress=
|
44
46
|
|
45
47
|
def base_url
|
46
|
-
|
48
|
+
if host.include?('://')
|
49
|
+
host.chomp('/') + '/xrpc'
|
50
|
+
else
|
51
|
+
"https://#{host}/xrpc"
|
52
|
+
end
|
47
53
|
end
|
48
54
|
|
49
55
|
def user
|
@@ -54,7 +60,7 @@ class Minisky
|
|
54
60
|
check_access if auto_manage_tokens && auth == true
|
55
61
|
|
56
62
|
headers = authentication_header(auth).merge(headers || {})
|
57
|
-
url =
|
63
|
+
url = build_request_uri(method)
|
58
64
|
|
59
65
|
if params && !params.empty?
|
60
66
|
url.query = URI.encode_www_form(params)
|
@@ -66,24 +72,30 @@ class Minisky
|
|
66
72
|
handle_response(response)
|
67
73
|
end
|
68
74
|
|
69
|
-
def post_request(method,
|
75
|
+
def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
|
70
76
|
check_access if auto_manage_tokens && auth == true
|
71
77
|
|
72
78
|
headers = authentication_header(auth).merge(headers || {})
|
73
79
|
headers["Content-Type"] = "application/json" unless headers.keys.any? { |k| k.to_s.downcase == 'content-type' }
|
74
80
|
|
75
|
-
body = if
|
76
|
-
|
81
|
+
body = if data.is_a?(String) || data.nil?
|
82
|
+
data.to_s
|
77
83
|
else
|
78
|
-
|
84
|
+
data.to_json
|
79
85
|
end
|
80
86
|
|
81
|
-
|
87
|
+
url = build_request_uri(method)
|
88
|
+
|
89
|
+
if params && !params.empty?
|
90
|
+
url.query = URI.encode_www_form(params)
|
91
|
+
end
|
92
|
+
|
93
|
+
response = Net::HTTP.post(url, body, headers)
|
82
94
|
handle_response(response)
|
83
95
|
end
|
84
96
|
|
85
|
-
def fetch_all(method, params = nil,
|
86
|
-
|
97
|
+
def fetch_all(method, params = nil, auth: default_auth_mode,
|
98
|
+
field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
|
87
99
|
data = []
|
88
100
|
params = {} if params.nil?
|
89
101
|
pages = 0
|
@@ -92,6 +104,11 @@ class Minisky
|
|
92
104
|
print(progress) if progress
|
93
105
|
|
94
106
|
response = get_request(method, params, auth: auth, headers: headers)
|
107
|
+
|
108
|
+
if field.nil?
|
109
|
+
raise FieldNotSetError, response.keys.select { |f| response[f].is_a?(Array) }
|
110
|
+
end
|
111
|
+
|
95
112
|
records = response[field]
|
96
113
|
cursor = response['cursor']
|
97
114
|
|
@@ -110,8 +127,12 @@ class Minisky
|
|
110
127
|
def check_access
|
111
128
|
if !user.logged_in?
|
112
129
|
log_in
|
113
|
-
|
130
|
+
:logged_in
|
131
|
+
elsif access_token_expired?
|
114
132
|
perform_token_refresh
|
133
|
+
:refreshed
|
134
|
+
else
|
135
|
+
:ok
|
115
136
|
end
|
116
137
|
end
|
117
138
|
|
@@ -149,6 +170,26 @@ class Minisky
|
|
149
170
|
json
|
150
171
|
end
|
151
172
|
|
173
|
+
def token_expiration_date(token)
|
174
|
+
parts = token.split('.')
|
175
|
+
raise AuthError, "Invalid access token format" unless parts.length == 3
|
176
|
+
|
177
|
+
begin
|
178
|
+
payload = JSON.parse(Base64.decode64(parts[1]))
|
179
|
+
rescue JSON::ParserError
|
180
|
+
raise AuthError, "Couldn't decode payload from access token"
|
181
|
+
end
|
182
|
+
|
183
|
+
exp = payload['exp']
|
184
|
+
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
|
185
|
+
|
186
|
+
Time.at(exp)
|
187
|
+
end
|
188
|
+
|
189
|
+
def access_token_expired?
|
190
|
+
token_expiration_date(user.access_token) < Time.now + 60
|
191
|
+
end
|
192
|
+
|
152
193
|
def reset_tokens
|
153
194
|
user.access_token = nil
|
154
195
|
user.refresh_token = nil
|
@@ -182,6 +223,18 @@ class Minisky
|
|
182
223
|
end
|
183
224
|
end
|
184
225
|
|
226
|
+
def build_request_uri(method)
|
227
|
+
if method.is_a?(URI)
|
228
|
+
method
|
229
|
+
elsif method.include?('://')
|
230
|
+
URI(method)
|
231
|
+
elsif method =~ NSID_REGEXP
|
232
|
+
URI("#{base_url}/#{method}")
|
233
|
+
else
|
234
|
+
raise ArgumentError, "Invalid method name #{method.inspect} (should be an NSID, URL or an URI object)"
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
185
238
|
def default_auth_mode
|
186
239
|
!!send_auth_headers
|
187
240
|
end
|
@@ -200,22 +253,6 @@ class Minisky
|
|
200
253
|
end
|
201
254
|
end
|
202
255
|
|
203
|
-
def token_expiration_date(token)
|
204
|
-
parts = token.split('.')
|
205
|
-
raise AuthError, "Invalid access token format" unless parts.length == 3
|
206
|
-
|
207
|
-
begin
|
208
|
-
payload = JSON.parse(Base64.decode64(parts[1]))
|
209
|
-
rescue JSON::ParserError
|
210
|
-
raise AuthError, "Couldn't decode payload from access token"
|
211
|
-
end
|
212
|
-
|
213
|
-
exp = payload['exp']
|
214
|
-
raise AuthError, "Invalid token expiry data" unless exp.is_a?(Numeric) && exp > 0
|
215
|
-
|
216
|
-
Time.at(exp)
|
217
|
-
end
|
218
|
-
|
219
256
|
def handle_response(response)
|
220
257
|
status = response.code.to_i
|
221
258
|
message = response.message
|
data/lib/minisky/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: minisky
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.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: 2024-
|
11
|
+
date: 2024-12-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: base64
|
@@ -38,6 +38,7 @@ files:
|
|
38
38
|
- example/ask_password.rb
|
39
39
|
- example/fetch_my_posts.rb
|
40
40
|
- example/fetch_profile.rb
|
41
|
+
- example/find_missing_follows.rb
|
41
42
|
- example/post_skeet.rb
|
42
43
|
- example/science_feed.rb
|
43
44
|
- lib/minisky.rb
|